Files
OpenFrontIO/src/core/GameRunner.ts
T
FloPinguin 23e4bf6725 ☢️ Nations send much better nukes now (Part 1) ☢️ (#2756)
This is a very important PR for HumansVsNations (But also for
singleplayer).
Humans will throw lots of nukes onto nations, but nations didn't do
that. Until now :)

## Refactor

- Moved all the nuking logic to the new file `NationNukeBehavior.ts`
- Moved `randTerritoryTileArray()` and `randTerritoryTile()` to the new
file `NationUtils.ts` because we need that method in multiple places now
- Because we already have an `NationUtils.ts` (It contains the method
`createNationsForGame` for HumansVsNations) I renamed the old one to
`NationCreation.ts` to avoid confusion

## Bug fixed

- `allRelationsSorted()` in `PlayerImpl` returned dead players all the
time... Which caused nations to not attack / send nukes in some cases...

## Nuke-sending features / improvements

- On hard and impossible difficulty, nations no longer make sure that
nukes will only hit inside of their targets border. This logic very
often stopped nations from throwing nukes. Now their nukes are allowed
to hit TerraNullius (=> ocean!). And in team games, it's even allowed
that their nukes hit other non-friendly players as well! This is very
important for HumansVsNations.
- The basic check for SAMs now gets skipped if we are on easy difficulty
(easy nations are not smart enough to do that)
- I improved the basic check for SAMs (medium difficulty) a bit (nations
send less nukes into SAMs)
- On hard and impossible difficulty, we now use the new method
`isTrajectoryInterceptableBySam()` to avoid SAMs completely. It's
mirroring `NukeTrajectoryPreviewLayer.ts` logic a bit.
- I added "perceived cost" to simulate nations saving up for a MIRV
(Otherwise most hard/impossible nations will spend all their gold on
nukes). But if we are in a team game (MIRVs are not relevant) or if we
already saved up for a MIRV, the "perceived cost" gets ignored.
- Updated the "most hated player" selection in `findBestNukeTarget()` to
ignore very weak players. We don't need to throw nukes at players which
we can easily steamroll by land.
- Added `findFFACrownTarget()` to nuke the crown (based on difficulty).
- Added `findStrongestTeamTarget()` to nuke the strongest team.
- Updated `randTerritoryTile()` so that it has a higher chance of
returning the tiles of a
"leftover-nuked-to-death-player-with-some-tiles-left": `if
(p.numTilesOwned() <= 100) {return
random.randElement(Array.from(p.tiles()));}`.
- Changed `const range = nukeType === UnitType.HydrogenBomb ? 60 : 15`
to `config().nukeMagnitudes(nukeType).inner`. Should make more sense.
- Adjusted `nukeTileScore()` to search for units in
`this.mg.config().nukeMagnitudes(nukeType).inner` instead of fixed 25
- Adjusted `nukeTileScore()` to account for unit levels (levels got
ignored previously). Also increased score for ports from 10_000 to
15_000.
- I made sure that nations can nuke EVERY SINGLE TILE from an enemy,
even if the enemy has no structures ("Prefer tiles that are closer to a
silo" can no longer make the `nukeTileScore()` drop too much,
`bestValue` in `maybeSendNuke()` starts at -1 now)
- In the entire nuking logic, factories were missing. Now they are
added.

## Media

Nation team vs. nation team: They are nuking the very last pixels of
red, just like humans would do it 😀

<img width="915" height="683" alt="image"
src="https://github.com/user-attachments/assets/109c7921-b959-4aa9-a971-0d7742971686"
/>

Hard difficulty FFA game: Nations throwing much more nukes. And they are
nuking the crown.


https://github.com/user-attachments/assets/a6e43924-a6ca-4b1a-a578-4e4f8252e383

Lots of nukes flying:


https://github.com/user-attachments/assets/8fc4edad-a6e6-4476-8a86-08cdef58169e

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin

---------

Co-authored-by: iamlewis <lewismmmm@gmail.com>
2026-01-01 13:29:46 -08:00

266 lines
7.3 KiB
TypeScript

import { placeName } from "../client/graphics/NameBoxCalculator";
import { getConfig } from "./configuration/ConfigLoader";
import { Executor } from "./execution/ExecutionManager";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Attack,
Cell,
Game,
GameUpdates,
NameViewData,
Player,
PlayerActions,
PlayerBorderTiles,
PlayerID,
PlayerInfo,
PlayerProfile,
PlayerType,
} from "./game/Game";
import { createGame } from "./game/GameImpl";
import { TileRef } from "./game/GameMap";
import { GameMapLoader } from "./game/GameMapLoader";
import {
ErrorUpdate,
GameUpdateType,
GameUpdateViewData,
} from "./game/GameUpdates";
import { createNationsForGame } from "./game/NationCreation";
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
import { PseudoRandom } from "./PseudoRandom";
import { ClientID, GameStartInfo, Turn } from "./Schemas";
import { simpleHash } from "./Util";
import { censorNameWithClanTag } from "./validations/username";
export async function createGameRunner(
gameStart: GameStartInfo,
clientID: ClientID,
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const gameMap = await loadGameMap(
gameStart.config.gameMap,
gameStart.config.gameMapSize,
mapLoader,
);
const random = new PseudoRandom(simpleHash(gameStart.gameID));
const humans = gameStart.players.map((p) => {
return new PlayerInfo(
p.clientID === clientID ? p.username : censorNameWithClanTag(p.username),
PlayerType.Human,
p.clientID,
random.nextID(),
p.isLobbyCreator ?? false,
);
});
const nations = createNationsForGame(
gameStart,
gameMap.nations,
humans.length,
random,
);
const game: Game = createGame(
humans,
nations,
gameMap.gameMap,
gameMap.miniGameMap,
config,
);
const gr = new GameRunner(
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
);
gr.init();
return gr;
}
export class GameRunner {
private turns: Turn[] = [];
private currTurn = 0;
private isExecuting = false;
private playerViewData: Record<PlayerID, NameViewData> = {};
constructor(
public game: Game,
private execManager: Executor,
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
) {}
init() {
if (this.game.config().isRandomSpawn()) {
this.game.addExecution(...this.execManager.spawnPlayers());
}
if (this.game.config().bots() > 0) {
this.game.addExecution(
...this.execManager.spawnBots(this.game.config().numBots()),
);
}
if (this.game.config().spawnNations()) {
this.game.addExecution(...this.execManager.nationExecutions());
}
this.game.addExecution(new WinCheckExecution());
}
public addTurn(turn: Turn): void {
this.turns.push(turn);
}
public executeNextTick() {
if (this.isExecuting) {
return;
}
if (this.currTurn >= this.turns.length) {
return;
}
this.isExecuting = true;
this.game.addExecution(
...this.execManager.createExecs(this.turns[this.currTurn]),
);
this.currTurn++;
let updates: GameUpdates;
let tickExecutionDuration: number = 0;
try {
const startTime = performance.now();
updates = this.game.executeNextTick();
const endTime = performance.now();
tickExecutionDuration = endTime - startTime;
} catch (error: unknown) {
if (error instanceof Error) {
console.error("Game tick error:", error.message);
this.callBack({
errMsg: error.message,
stack: error.stack,
} as ErrorUpdate);
} else {
console.error("Game tick error:", error);
}
return;
}
if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) {
this.game
.players()
.filter(
(p) =>
p.type() === PlayerType.Human || p.type() === PlayerType.Nation,
)
.forEach(
(p) => (this.playerViewData[p.id()] = placeName(this.game, p)),
);
}
if (this.game.ticks() < 3 || this.game.ticks() % 30 === 0) {
this.game.players().forEach((p) => {
this.playerViewData[p.id()] = placeName(this.game, p);
});
}
// Many tiles are updated to pack it into an array
const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update);
updates[GameUpdateType.Tile] = [];
this.callBack({
tick: this.game.ticks(),
packedTileUpdates: new BigUint64Array(packedTileUpdates),
updates: updates,
playerNameViewData: this.playerViewData,
tickExecutionDuration: tickExecutionDuration,
});
this.isExecuting = false;
}
public playerActions(
playerID: PlayerID,
x?: number,
y?: number,
): PlayerActions {
const player = this.game.player(playerID);
const tile =
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
const actions = {
canAttack: tile !== null && player.canAttack(tile),
buildableUnits: player.buildableUnits(tile),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
canEmbargoAll: player.canEmbargoAll(),
} as PlayerActions;
if (tile !== null && this.game.hasOwner(tile)) {
const other = this.game.owner(tile) as Player;
actions.interaction = {
sharedBorder: player.sharesBorderWith(other),
canSendEmoji: player.canSendEmoji(other),
canTarget: player.canTarget(other),
canSendAllianceRequest: player.canSendAllianceRequest(other),
canBreakAlliance: player.isAlliedWith(other),
canDonateGold: player.canDonateGold(other),
canDonateTroops: player.canDonateTroops(other),
canEmbargo: !player.hasEmbargoAgainst(other),
};
const alliance = player.allianceWith(other as Player);
if (alliance) {
actions.interaction.allianceExpiresAt = alliance.expiresAt();
}
}
return actions;
}
public playerProfile(playerID: number): PlayerProfile {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
return player.playerProfile();
}
public playerBorderTiles(playerID: PlayerID): PlayerBorderTiles {
const player = this.game.player(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
return {
borderTiles: player.borderTiles(),
} as PlayerBorderTiles;
}
public attackAveragePosition(
playerID: number,
attackID: string,
): Cell | null {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
const condition = (a: Attack) => a.id() === attackID;
const attack =
player.outgoingAttacks().find(condition) ??
player.incomingAttacks().find(condition);
if (attack === undefined) {
return null;
}
return attack.averagePosition();
}
public bestTransportShipSpawn(
playerID: PlayerID,
targetTile: TileRef,
): TileRef | false {
const player = this.game.player(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
return player.bestTransportShipSpawn(targetTile);
}
}