☢️ 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>
This commit is contained in:
FloPinguin
2026-01-01 13:29:46 -08:00
committed by GitHub
co-authored by iamlewis
parent 96622779d1
commit 23e4bf6725
8 changed files with 551 additions and 210 deletions
+145 -16
View File
@@ -1,6 +1,7 @@
import {
Difficulty,
Game,
GameMode,
Player,
PlayerType,
Relation,
@@ -118,13 +119,11 @@ export class AiAttackBehavior {
};
const hated = (): boolean => {
const mostHated = this.player.allRelationsSorted()[0];
if (
mostHated !== undefined &&
mostHated.relation === Relation.Hostile &&
this.player.isFriendly(mostHated.player) === false
) {
this.sendAttack(mostHated.player);
for (const relation of this.player.allRelationsSorted()) {
if (relation.relation !== Relation.Hostile) continue;
const other = relation.player;
if (this.player.isFriendly(other)) continue;
this.sendAttack(other);
return true;
}
return false;
@@ -201,7 +200,7 @@ export class AiAttackBehavior {
}
}
findBestNukeTarget(borderingEnemies: Player[]): Player | null {
findBestNukeTarget(): Player | null {
// Retaliate against incoming attacks (Most important!)
const incomingAttackPlayer = this.findIncomingAttackPlayer();
if (incomingAttackPlayer) {
@@ -221,19 +220,149 @@ export class AiAttackBehavior {
}
}
// Find the most hated player with hostile relation
const mostHated = this.player.allRelationsSorted()[0];
if (
mostHated !== undefined &&
mostHated.relation === Relation.Hostile &&
this.player.isFriendly(mostHated.player) === false
) {
return mostHated.player;
// Find the most hated player
// Ignore much weaker players (we don't need nukes to deal with them)
const myMaxTroops = this.game.config().maxTroops(this.player);
for (const relation of this.player.allRelationsSorted()) {
if (relation.relation !== Relation.Hostile) continue;
const other = relation.player;
if (this.player.isFriendly(other)) continue;
const otherMaxTroops = this.game.config().maxTroops(other);
if (myMaxTroops >= otherMaxTroops * 2) continue;
return other;
}
// In FFAs, nuke the crown if they're far enough ahead
const crownTarget = this.findFFACrownTarget();
if (crownTarget) {
return crownTarget;
}
// In Teams, nuke the strongest team
const teamTarget = this.findStrongestTeamTarget();
if (teamTarget) {
return teamTarget;
}
return null;
}
private findFFACrownTarget(): Player | null {
const { difficulty, gameMode } = this.game.config().gameConfig();
if (gameMode !== GameMode.FFA) {
return null;
}
if (this.game.players().length <= 1) {
return null;
}
const sortedByTiles = this.game
.players()
.slice()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
const firstPlace = sortedByTiles[0];
// Don't target ourselves or allies
if (firstPlace === this.player || this.player.isFriendly(firstPlace)) {
return null;
}
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
if (numTilesWithoutFallout <= 0) {
return null;
}
const firstPlaceShare = firstPlace.numTilesOwned() / numTilesWithoutFallout;
const myShare = this.player.numTilesOwned() / numTilesWithoutFallout;
let threshold: number;
switch (difficulty) {
case Difficulty.Easy:
threshold = 0.4; // 40%
break;
case Difficulty.Medium:
threshold = 0.3; // 30%
break;
case Difficulty.Hard:
threshold = 0.2; // 20%
break;
case Difficulty.Impossible:
threshold = 0.1; // 10%
break;
default:
assertNever(difficulty);
}
// Check if first place has threshold% more tile-percentage of the map than us
if (firstPlaceShare - myShare > threshold) {
return firstPlace;
}
return null;
}
private findStrongestTeamTarget(): Player | null {
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
return null;
}
if (this.game.players().length <= 1) {
return null;
}
const teamTiles = new Map<string, number>();
const teamPlayers = new Map<string, Player[]>();
for (const p of this.game.players()) {
const team = p.team();
if (team === null) continue;
teamTiles.set(team, (teamTiles.get(team) ?? 0) + p.numTilesOwned());
let players = teamPlayers.get(team);
if (!players) {
players = [];
teamPlayers.set(team, players);
}
players.push(p);
}
const sortedTeams = Array.from(teamTiles.entries()).sort(
(a, b) => b[1] - a[1],
);
if (sortedTeams.length === 0) {
return null;
}
let strongestTeam = sortedTeams[0][0];
if (strongestTeam === this.player.team()) {
if (sortedTeams.length > 1) {
strongestTeam = sortedTeams[1][0];
} else {
return null;
}
}
const targetTeamPlayers = teamPlayers.get(strongestTeam)!;
if (this.random.chance(2)) {
// Strongest player
return targetTeamPlayers.reduce((prev, current) =>
this.game.config().maxTroops(prev) >
this.game.config().maxTroops(current)
? prev
: current,
);
} else {
// Random player
return this.random.randElement(targetTeamPlayers);
}
}
private hasReserveRatioTroops(): boolean {
const maxTroops = this.game.config().maxTroops(this.player);
const ratio = this.player.troops() / maxTroops;