mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-16 14:52:52 +00:00
Merge branch 'main' into local-attack
This commit is contained in:
@@ -3,8 +3,8 @@ import { PseudoRandom } from "../PseudoRandom";
|
||||
import { ClientID, GameID, StampedIntent, Turn } from "../Schemas";
|
||||
import { simpleHash } from "../Util";
|
||||
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
|
||||
import { AllianceRejectExecution } from "./alliance/AllianceRejectExecution";
|
||||
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
|
||||
import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution";
|
||||
import { BreakAllianceExecution } from "./alliance/BreakAllianceExecution";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
import { BoatRetreatExecution } from "./BoatRetreatExecution";
|
||||
@@ -75,12 +75,8 @@ export class Executor {
|
||||
return new TransportShipExecution(player, intent.dst, intent.troops);
|
||||
case "allianceRequest":
|
||||
return new AllianceRequestExecution(player, intent.recipient);
|
||||
case "allianceRequestReply":
|
||||
return new AllianceRequestReplyExecution(
|
||||
intent.requestor,
|
||||
player,
|
||||
intent.accept,
|
||||
);
|
||||
case "allianceReject":
|
||||
return new AllianceRejectExecution(intent.requestor, player);
|
||||
case "breakAlliance":
|
||||
return new BreakAllianceExecution(player, intent.recipient);
|
||||
case "targetPlayer":
|
||||
|
||||
@@ -63,6 +63,7 @@ export class MirvExecution implements Execution {
|
||||
}
|
||||
if (this.targetPlayer !== this.player) {
|
||||
this.targetPlayer.updateRelation(this.player, -100);
|
||||
this.player.updateRelation(this.targetPlayer, -100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Execution, Game } from "../game/Game";
|
||||
import { RailNetwork } from "../game/RailNetwork";
|
||||
|
||||
export class RecomputeRailClusterExecution implements Execution {
|
||||
constructor(private railNetwork: RailNetwork) {}
|
||||
|
||||
isActive(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {}
|
||||
|
||||
tick(ticks: number): void {
|
||||
this.railNetwork.recomputeClusters();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Execution, Game, Player, PlayerID } from "../../game/Game";
|
||||
|
||||
export class AllianceRejectExecution implements Execution {
|
||||
private active = true;
|
||||
|
||||
constructor(
|
||||
private requestorID: PlayerID,
|
||||
private recipient: Player,
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
if (!mg.hasPlayer(this.requestorID)) {
|
||||
console.warn(
|
||||
`[AllianceRejectExecution] Requestor ${this.requestorID} not found`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
const requestor = mg.player(this.requestorID);
|
||||
|
||||
if (requestor.isFriendly(this.recipient)) {
|
||||
console.warn(
|
||||
`[AllianceRejectExecution] Player ${this.requestorID} cannot reject alliance with ${this.recipient.id}, already allied`,
|
||||
);
|
||||
} else {
|
||||
const request = requestor
|
||||
.outgoingAllianceRequests()
|
||||
.find((ar) => ar.recipient() === this.recipient);
|
||||
if (request === undefined) {
|
||||
console.warn(
|
||||
`[AllianceRejectExecution] Player ${this.requestorID} cannot reject alliance with ${this.recipient.id}, no alliance request found`,
|
||||
);
|
||||
} else {
|
||||
request.reject();
|
||||
}
|
||||
}
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
AllianceRequest,
|
||||
Execution,
|
||||
Game,
|
||||
MessageType,
|
||||
Player,
|
||||
PlayerID,
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
|
||||
export class AllianceRequestExecution implements Execution {
|
||||
@@ -39,6 +41,19 @@ export class AllianceRequestExecution implements Execution {
|
||||
// then accept it instead of creating a new one.
|
||||
this.active = false;
|
||||
incoming.accept();
|
||||
|
||||
// Update player relations
|
||||
this.requestor.updateRelation(recipient, 100);
|
||||
recipient.updateRelation(this.requestor, 100);
|
||||
|
||||
// Automatically remove embargoes only if they were automatically created
|
||||
if (this.requestor.hasEmbargoAgainst(recipient))
|
||||
this.requestor.endTemporaryEmbargo(recipient);
|
||||
if (recipient.hasEmbargoAgainst(this.requestor))
|
||||
recipient.endTemporaryEmbargo(this.requestor);
|
||||
|
||||
// Cancel incoming nukes between players
|
||||
this.cancelNukesBetweenAlliedPlayers(recipient);
|
||||
} else {
|
||||
this.req = this.requestor.createAllianceRequest(recipient);
|
||||
}
|
||||
@@ -69,4 +84,51 @@ export class AllianceRequestExecution implements Execution {
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
cancelNukesBetweenAlliedPlayers(recipient: Player): void {
|
||||
const neutralized = new Map<Player, number>();
|
||||
|
||||
const players = [this.requestor, recipient];
|
||||
|
||||
for (const launcher of players) {
|
||||
for (const unit of launcher.units(
|
||||
UnitType.AtomBomb,
|
||||
UnitType.HydrogenBomb,
|
||||
)) {
|
||||
if (!unit.isActive() || unit.reachedTarget()) continue;
|
||||
|
||||
const targetTile = unit.targetTile();
|
||||
if (!targetTile) continue;
|
||||
|
||||
const targetOwner = this.mg.owner(targetTile);
|
||||
if (!targetOwner.isPlayer()) continue;
|
||||
|
||||
const other = launcher === this.requestor ? recipient : this.requestor;
|
||||
if (targetOwner !== other) continue;
|
||||
|
||||
unit.delete(false);
|
||||
neutralized.set(launcher, (neutralized.get(launcher) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [launcher, count] of neutralized) {
|
||||
const other = launcher === this.requestor ? recipient : this.requestor;
|
||||
|
||||
this.mg.displayMessage(
|
||||
"events_display.alliance_nukes_destroyed_outgoing",
|
||||
MessageType.ALLIANCE_ACCEPTED,
|
||||
launcher.id(),
|
||||
undefined,
|
||||
{ name: other.displayName(), count },
|
||||
);
|
||||
|
||||
this.mg.displayMessage(
|
||||
"events_display.alliance_nukes_destroyed_incoming",
|
||||
MessageType.ALLIANCE_ACCEPTED,
|
||||
other.id(),
|
||||
undefined,
|
||||
{ name: launcher.displayName(), count },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
MessageType,
|
||||
Player,
|
||||
PlayerID,
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
|
||||
export class AllianceRequestReplyExecution implements Execution {
|
||||
private active = true;
|
||||
private requestor: Player | null = null;
|
||||
|
||||
constructor(
|
||||
private requestorID: PlayerID,
|
||||
private recipient: Player,
|
||||
private accept: boolean,
|
||||
) {}
|
||||
|
||||
private cancelNukesBetweenAlliedPlayers(
|
||||
mg: Game,
|
||||
p1: Player,
|
||||
p2: Player,
|
||||
): void {
|
||||
const neutralized = new Map<Player, number>();
|
||||
|
||||
const players = [p1, p2];
|
||||
|
||||
for (const launcher of players) {
|
||||
for (const unit of launcher.units(
|
||||
UnitType.AtomBomb,
|
||||
UnitType.HydrogenBomb,
|
||||
)) {
|
||||
if (!unit.isActive() || unit.reachedTarget()) continue;
|
||||
|
||||
const targetTile = unit.targetTile();
|
||||
if (!targetTile) continue;
|
||||
|
||||
const targetOwner = mg.owner(targetTile);
|
||||
if (!targetOwner.isPlayer()) continue;
|
||||
|
||||
const other = launcher === p1 ? p2 : p1;
|
||||
if (targetOwner !== other) continue;
|
||||
|
||||
unit.delete(false);
|
||||
neutralized.set(launcher, (neutralized.get(launcher) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [launcher, count] of neutralized) {
|
||||
const other = launcher === p1 ? p2 : p1;
|
||||
|
||||
mg.displayMessage(
|
||||
"events_display.alliance_nukes_destroyed_outgoing",
|
||||
MessageType.ALLIANCE_ACCEPTED,
|
||||
launcher.id(),
|
||||
undefined,
|
||||
{ name: other.displayName(), count },
|
||||
);
|
||||
|
||||
mg.displayMessage(
|
||||
"events_display.alliance_nukes_destroyed_incoming",
|
||||
MessageType.ALLIANCE_ACCEPTED,
|
||||
other.id(),
|
||||
undefined,
|
||||
{ name: launcher.displayName(), count },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
if (!mg.hasPlayer(this.requestorID)) {
|
||||
console.warn(
|
||||
`AllianceRequestReplyExecution requester ${this.requestorID} not found`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.requestor = mg.player(this.requestorID);
|
||||
|
||||
if (this.requestor.isFriendly(this.recipient)) {
|
||||
console.warn("already allied");
|
||||
} else {
|
||||
const request = this.requestor
|
||||
.outgoingAllianceRequests()
|
||||
.find((ar) => ar.recipient() === this.recipient);
|
||||
if (request === undefined) {
|
||||
console.warn("no alliance request found");
|
||||
} else {
|
||||
if (this.accept) {
|
||||
request.accept();
|
||||
this.requestor.updateRelation(this.recipient, 100);
|
||||
this.recipient.updateRelation(this.requestor, 100);
|
||||
|
||||
this.cancelNukesBetweenAlliedPlayers(
|
||||
mg,
|
||||
this.requestor,
|
||||
this.recipient,
|
||||
);
|
||||
} else {
|
||||
request.reject();
|
||||
}
|
||||
}
|
||||
}
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export class NationAllianceBehavior {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Reject if otherPlayer has allied with 50% or more of all players (Hard and Impossible only)
|
||||
// Reject if otherPlayer has allied with a lot of players (Hard and Impossible only)
|
||||
// To make sure there are enough non-friendly players in the game to stop the crown with nukes
|
||||
if (this.hasTooManyAlliances(otherPlayer)) {
|
||||
return false;
|
||||
@@ -148,7 +148,7 @@ export class NationAllianceBehavior {
|
||||
.filter((p) => p.type() !== PlayerType.Bot).length;
|
||||
const otherPlayerAlliances = otherPlayer.alliances().length;
|
||||
|
||||
if (difficulty !== Difficulty.Hard) {
|
||||
if (difficulty === Difficulty.Hard) {
|
||||
return otherPlayerAlliances >= totalPlayers * 0.5;
|
||||
} else {
|
||||
return otherPlayerAlliances >= totalPlayers * 0.25;
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
Game,
|
||||
Gold,
|
||||
Player,
|
||||
PlayerID,
|
||||
PlayerType,
|
||||
Tick,
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
@@ -18,7 +20,15 @@ import {
|
||||
respondToMIRV,
|
||||
} from "./NationEmojiBehavior";
|
||||
|
||||
// 30 seconds at 10 ticks/second
|
||||
const MIRV_COOLDOWN_TICKS = 300;
|
||||
|
||||
export class NationMIRVBehavior {
|
||||
// Shared across all NationMIRVBehavior instances.
|
||||
// Tracks the last tick a MIRV was sent at each player, so multiple nations don't pile-on the same target.
|
||||
// Especially important for games with very high starting gold settings.
|
||||
private static recentMirvTargets = new Map<PlayerID, Tick>();
|
||||
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
private game: Game,
|
||||
@@ -119,19 +129,19 @@ export class NationMIRVBehavior {
|
||||
}
|
||||
|
||||
const inboundMIRVSender = this.selectCounterMirvTarget();
|
||||
if (inboundMIRVSender) {
|
||||
if (inboundMIRVSender && !this.wasRecentlyMirved(inboundMIRVSender)) {
|
||||
this.maybeSendMIRV(inboundMIRVSender);
|
||||
return true;
|
||||
}
|
||||
|
||||
const victoryDenialTarget = this.selectVictoryDenialTarget();
|
||||
if (victoryDenialTarget) {
|
||||
if (victoryDenialTarget && !this.wasRecentlyMirved(victoryDenialTarget)) {
|
||||
this.maybeSendMIRV(victoryDenialTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
const steamrollStopTarget = this.selectSteamrollStopTarget();
|
||||
if (steamrollStopTarget) {
|
||||
if (steamrollStopTarget && !this.wasRecentlyMirved(steamrollStopTarget)) {
|
||||
this.maybeSendMIRV(steamrollStopTarget);
|
||||
return true;
|
||||
}
|
||||
@@ -223,6 +233,17 @@ export class NationMIRVBehavior {
|
||||
return null;
|
||||
}
|
||||
|
||||
// MIRV Cooldown Methods
|
||||
private wasRecentlyMirved(target: Player): boolean {
|
||||
const lastTick = NationMIRVBehavior.recentMirvTargets.get(target.id());
|
||||
if (lastTick === undefined) return false;
|
||||
return this.game.ticks() - lastTick < MIRV_COOLDOWN_TICKS;
|
||||
}
|
||||
|
||||
private recordMirvHit(target: Player): void {
|
||||
NationMIRVBehavior.recentMirvTargets.set(target.id(), this.game.ticks());
|
||||
}
|
||||
|
||||
// MIRV Helper Methods
|
||||
private getValidMirvTargetPlayers(): Player[] {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
@@ -261,6 +282,7 @@ export class NationMIRVBehavior {
|
||||
const centerTile = this.calculateTerritoryCenter(enemy);
|
||||
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
|
||||
this.game.addExecution(new MirvExecution(this.player, centerTile));
|
||||
this.recordMirvHit(enemy);
|
||||
this.emojiBehavior.sendEmoji(AllPlayers, EMOJI_NUKE);
|
||||
respondToMIRV(this.game, this.random, enemy);
|
||||
}
|
||||
|
||||
@@ -659,8 +659,8 @@ export class NationNukeBehavior {
|
||||
this.recentlySentNukes.push([tick, tile, nukeType]);
|
||||
if (nukeType === UnitType.AtomBomb) {
|
||||
this.atomBombsLaunched++;
|
||||
// Increase perceived cost by 35% each time to simulate saving up for a MIRV (higher than hydro to make atom bombs less attractive for the lategame)
|
||||
this.atomBombPerceivedCost = (this.atomBombPerceivedCost * 135n) / 100n;
|
||||
// Increase perceived cost by 50% each time to simulate saving up for a MIRV (higher than hydro to make atom bombs less attractive for the lategame)
|
||||
this.atomBombPerceivedCost = (this.atomBombPerceivedCost * 150n) / 100n;
|
||||
} else if (nukeType === UnitType.HydrogenBomb) {
|
||||
this.hydrogenBombsLaunched++;
|
||||
// Increase perceived cost by 25% each time to simulate saving up for a MIRV
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Game,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
isStructureType,
|
||||
Player,
|
||||
PlayerID,
|
||||
PlayerType,
|
||||
@@ -199,6 +200,13 @@ export class AiAttackBehavior {
|
||||
borderingFriends: Player[],
|
||||
borderingEnemies: Player[],
|
||||
) {
|
||||
// In games with high starting gold, nations will quickly build a lot of cities
|
||||
// This causes them to expand slowly (cities increase max troops), and bots will steal their structures
|
||||
// In this case: Attack bots before ratio checks
|
||||
if (this.hasNeighboringBotWithStructures()) {
|
||||
if (this.attackBots()) return;
|
||||
}
|
||||
|
||||
// Save up troops until we reach the reserve ratio
|
||||
if (!this.hasReserveRatioTroops()) return;
|
||||
|
||||
@@ -345,6 +353,18 @@ export class AiAttackBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
private hasNeighboringBotWithStructures(): boolean {
|
||||
return this.player
|
||||
.neighbors()
|
||||
.some(
|
||||
(n) =>
|
||||
n.isPlayer() &&
|
||||
n.type() === PlayerType.Bot &&
|
||||
!this.player.isFriendly(n) &&
|
||||
n.units().some((u) => isStructureType(u.type())),
|
||||
);
|
||||
}
|
||||
|
||||
private hasReserveRatioTroops(): boolean {
|
||||
const maxTroops = this.game.config().maxTroops(this.player);
|
||||
const ratio = this.player.troops() / maxTroops;
|
||||
@@ -380,6 +400,7 @@ export class AiAttackBehavior {
|
||||
|
||||
// Sort neighboring bots by density (troops / tiles) and attempt to attack many of them (Parallel attacks)
|
||||
// sendAttack will do nothing if we don't have enough reserve troops left
|
||||
// Bots that own structures are prioritized as targets (they might have stolen our structures and they will delete them!)
|
||||
private attackBots(): boolean {
|
||||
const bots = this.player
|
||||
.neighbors()
|
||||
@@ -397,7 +418,16 @@ export class AiAttackBehavior {
|
||||
this.botAttackTroopsSent = 0;
|
||||
|
||||
const density = (p: Player) => p.troops() / p.numTilesOwned();
|
||||
const sortedBots = bots.slice().sort((a, b) => density(a) - density(b));
|
||||
const ownsStructures = (p: Player) =>
|
||||
p.units().some((u) => isStructureType(u.type()));
|
||||
const sortedBots = bots.slice().sort((a, b) => {
|
||||
const aHasStructures = ownsStructures(a);
|
||||
const bHasStructures = ownsStructures(b);
|
||||
if (aHasStructures !== bHasStructures) {
|
||||
return aHasStructures ? -1 : 1;
|
||||
}
|
||||
return density(a) - density(b);
|
||||
});
|
||||
const reducedBots = sortedBots.slice(0, this.getBotAttackMaxParallelism());
|
||||
|
||||
for (const bot of reducedBots) {
|
||||
@@ -700,9 +730,14 @@ export class AiAttackBehavior {
|
||||
|
||||
private sendLandAttack(target: Player | TerraNullius) {
|
||||
const maxTroops = this.game.config().maxTroops(this.player);
|
||||
const reserveRatio = target.isPlayer()
|
||||
? this.reserveRatio
|
||||
: this.expandRatio;
|
||||
const botWithStructures =
|
||||
target.isPlayer() &&
|
||||
target.type() === PlayerType.Bot &&
|
||||
target.units().some((u) => isStructureType(u.type()));
|
||||
// Use the expand ratio when attacking a bot that owns structures — we need to
|
||||
// recapture those structures ASAP, even before reaching the normal reserve.
|
||||
const useReserve = target.isPlayer() && !botWithStructures;
|
||||
const reserveRatio = useReserve ? this.reserveRatio : this.expandRatio;
|
||||
const targetTroops = maxTroops * reserveRatio;
|
||||
|
||||
let troops;
|
||||
|
||||
Reference in New Issue
Block a user