Merge branch 'main' into test-reload-mobile

This commit is contained in:
VariableVince
2025-11-29 22:56:42 +01:00
committed by GitHub
234 changed files with 7981 additions and 2952 deletions
+139
View File
@@ -0,0 +1,139 @@
import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution";
import { DonateGoldExecution } from "../src/core/execution/DonateGoldExecution";
import { Game, Player, PlayerType } from "../src/core/game/Game";
import { playerInfo, setup } from "./util/Setup";
let game: Game;
let player1: Player;
let player2: Player;
describe("Alliance Donation", () => {
beforeEach(async () => {
game = await setup(
"plains",
{
infiniteGold: false,
instantBuild: true,
infiniteTroops: false,
donateGold: true,
donateTroops: true,
},
[
playerInfo("player1", PlayerType.Human),
playerInfo("player2", PlayerType.Human),
],
);
player1 = game.player("player1");
player1.conquer(game.ref(0, 0));
player1.addGold(1000n);
player1.addTroops(1000);
player2 = game.player("player2");
player2.conquer(game.ref(0, 1));
player2.addGold(100n);
player2.addTroops(100);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
test("Can donate gold after alliance formed by reply", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeTruthy();
expect(player2.isAlliedWith(player1)).toBeTruthy();
expect(player1.isFriendly(player2)).toBeTruthy();
expect(player2.isFriendly(player1)).toBeTruthy();
expect(player1.canDonateGold(player2)).toBeTruthy();
const goldBefore = player2.gold();
const success = player1.donateGold(player2, 100n);
expect(success).toBeTruthy();
expect(player2.gold()).toBe(goldBefore + 100n);
});
test("Can donate troops after alliance formed by reply", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeTruthy();
expect(player2.isAlliedWith(player1)).toBeTruthy();
expect(player1.canDonateTroops(player2)).toBeTruthy();
const troopsBefore = player2.troops();
const success = player1.donateTroops(player2, 100);
expect(success).toBeTruthy();
expect(player2.troops()).toBe(troopsBefore + 100);
});
test("Can donate gold after alliance formed by mutual request", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeTruthy();
expect(player2.isAlliedWith(player1)).toBeTruthy();
expect(player1.isFriendly(player2)).toBeTruthy();
expect(player2.isFriendly(player1)).toBeTruthy();
expect(player1.canDonateGold(player2)).toBeTruthy();
const goldBefore = player2.gold();
const success = player1.donateGold(player2, 100n);
expect(success).toBeTruthy();
expect(player2.gold()).toBe(goldBefore + 100n);
});
test("Can donate troops after alliance formed by mutual request", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeTruthy();
expect(player2.isAlliedWith(player1)).toBeTruthy();
expect(player1.canDonateTroops(player2)).toBeTruthy();
const troopsBefore = player2.troops();
const success = player1.donateTroops(player2, 100);
expect(success).toBeTruthy();
expect(player2.troops()).toBe(troopsBefore + 100);
});
test("Can donate immediately after accepting alliance (race condition)", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
const goldBefore = player2.gold();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.addExecution(new DonateGoldExecution(player1, player2.id(), 100));
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeTruthy();
expect(player2.isAlliedWith(player1)).toBeTruthy();
game.executeNextTick();
// Donation should have succeeded
expect(player2.gold()).toBe(goldBefore + 100n);
});
});
+20
View File
@@ -111,6 +111,26 @@ describe("Attack", () => {
expect(nuke.isActive()).toBe(false);
expect(defender.units(UnitType.TransportShip)[0].troops()).toBeLessThan(90);
});
test("Boat penalty on retreat Transport Ship arrival", async () => {
const player_start_troops = defender.troops();
const boat_troops = player_start_troops * 0.5;
sendBoat(game.ref(15, 8), game.ref(10, 5), boat_troops);
game.executeNextTick();
const ship = defender.units(UnitType.TransportShip)[0];
expect(ship.troops()).toBe(boat_troops);
expect(ship.isActive()).toBe(true);
ship.orderBoatRetreat();
game.executeNextTick();
expect(ship.isActive()).toBe(false);
expect(boat_troops).toBeLessThan(defender.troops());
expect(defender.troops()).toBeLessThan(player_start_troops);
});
});
let playerA: Player;
+321 -1
View File
@@ -1,12 +1,25 @@
import { AttackExecution } from "../src/core/execution/AttackExecution";
import { MarkDisconnectedExecution } from "../src/core/execution/MarkDisconnectedExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { TransportShipExecution } from "../src/core/execution/TransportShipExecution";
import { WarshipExecution } from "../src/core/execution/WarshipExecution";
import {
Game,
GameMode,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "../src/core/game/Game";
import { toInt } from "../src/core/Util";
import { setup } from "./util/Setup";
import { UseRealAttackLogic } from "./util/TestConfig";
import { executeTicks } from "./util/utils";
let game: Game;
let player1: Player;
let player2: Player;
let enemy: Player;
describe("Disconnected", () => {
beforeEach(async () => {
@@ -158,4 +171,311 @@ describe("Disconnected", () => {
expect(player1.isDisconnected()).toBe(true);
});
});
describe("Disconnected team member interactions", () => {
const coastX = 7;
beforeEach(async () => {
const player1Info = new PlayerInfo(
"[CLAN]Player1",
PlayerType.Human,
null,
"player_1_id",
);
const player2Info = new PlayerInfo(
"[CLAN]Player2",
PlayerType.Human,
null,
"player_2_id",
);
game = await setup(
"half_land_half_ocean",
{
infiniteGold: true,
instantBuild: true,
gameMode: GameMode.Team,
playerTeams: 2, // ignore player2 "kicked" console warn
},
[player1Info, player2Info],
undefined,
UseRealAttackLogic, // don't use TestConfig's mock attackLogic
);
game.addExecution(
new SpawnExecution(player1Info, game.map().ref(coastX - 2, 1)),
new SpawnExecution(player2Info, game.map().ref(coastX - 2, 4)),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player1 = game.player(player1Info.id);
player2 = game.player(player2Info.id);
player2.markDisconnected(false);
expect(player1.team()).not.toBeNull();
expect(player2.team()).not.toBeNull();
expect(player1.isOnSameTeam(player2)).toBe(true);
});
test("Team Warships should not attack disconnected team mate ships", () => {
const warship = player1.buildUnit(
UnitType.Warship,
game.map().ref(coastX + 1, 10),
{
patrolTile: game.map().ref(coastX + 1, 10),
},
);
game.addExecution(new WarshipExecution(warship));
const transportShip = player2.buildUnit(
UnitType.TransportShip,
game.map().ref(coastX + 1, 11),
{
troops: 100,
},
);
player2.markDisconnected(true);
executeTicks(game, 10);
expect(warship.targetUnit()).toBe(undefined);
expect(transportShip.isActive()).toBe(true);
expect(transportShip.owner()).toBe(player2);
});
test("Disconnected player Warship should not attack team members' ships", () => {
const warship = player2.buildUnit(
UnitType.Warship,
game.map().ref(coastX + 1, 5),
{
patrolTile: game.map().ref(coastX + 1, 10),
},
);
game.addExecution(new WarshipExecution(warship));
const transportShip = player1.buildUnit(
UnitType.TransportShip,
game.map().ref(coastX + 1, 6),
{
troops: 100,
},
);
player2.markDisconnected(true);
executeTicks(game, 10);
expect(warship.targetUnit()).toBe(undefined);
expect(transportShip.isActive()).toBe(true);
expect(transportShip.owner()).toBe(player1);
});
test("Player can attack disconnected team mate without troop loss", () => {
player2.conquer(game.map().ref(coastX - 2, 2));
player2.conquer(game.map().ref(coastX - 2, 3));
player2.markDisconnected(true);
const troopsBeforeAttack = player1.troops();
const startTroops = troopsBeforeAttack * 0.25;
game.addExecution(
new AttackExecution(startTroops, player1, player2.id(), null),
);
let expectedTotalGrowth = 0n;
let afterTickZero = false;
while (player2.isAlive()) {
if (afterTickZero) {
// No growth on tick 0, troop additions start from tick 1
const troopIncThisTick = game.config().troopIncreaseRate(player1);
expectedTotalGrowth += toInt(troopIncThisTick);
}
game.executeNextTick();
afterTickZero = true;
}
// Tick for retreat() in AttackExecution to add back startTtoops to owner troops
const troopIncThisTick1 = game.config().troopIncreaseRate(player1);
expectedTotalGrowth += toInt(troopIncThisTick1);
game.executeNextTick();
const expectedFinalTroops = Number(
toInt(troopsBeforeAttack) + expectedTotalGrowth,
);
// Verify no troop loss
expect(player1.troops()).toBe(expectedFinalTroops);
});
test("Conqueror gets conquered disconnected team member's transport- and warships", () => {
const warship = player2.buildUnit(
UnitType.Warship,
game.map().ref(coastX + 1, 1),
{
patrolTile: game.map().ref(coastX + 1, 1),
},
);
const transportShip = player2.buildUnit(
UnitType.TransportShip,
game.map().ref(coastX + 1, 3),
{
troops: 100,
},
);
player2.conquer(game.map().ref(coastX - 2, 1));
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(warship.owner()).toBe(player1);
expect(transportShip.owner()).toBe(player1);
});
test("Captured transport ship landing attack should be in name of new owner", () => {
player2.conquer(game.map().ref(coastX, 1));
player2.conquer(game.map().ref(coastX - 1, 1));
player2.conquer(game.map().ref(coastX, 2));
const enemyShoreTile = game.map().ref(coastX, 15);
game.addExecution(
new TransportShipExecution(
player2,
null,
enemyShoreTile,
100,
game.map().ref(coastX, 1),
),
);
executeTicks(game, 1);
expect(player2.isAlive()).toBe(true);
const transportShip = player2.units(UnitType.TransportShip)[0];
expect(player2.units(UnitType.TransportShip).length).toBe(1);
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(transportShip.owner()).toBe(player1);
executeTicks(game, 30);
// Verify ship landed and tile ownership transferred to new ship owner
expect(game.owner(enemyShoreTile)).toBe(player1);
});
test("Captured transport ship should retreat to owner's shore tile", () => {
player1.conquer(game.map().ref(coastX, 4));
player2.conquer(game.map().ref(coastX, 1));
const enemyShoreTile = game.map().ref(coastX, 8);
game.addExecution(
new TransportShipExecution(
player2,
null,
enemyShoreTile,
100,
game.map().ref(coastX, 1),
),
);
executeTicks(game, 1);
const transportShip = player2.units(UnitType.TransportShip)[0];
expect(player2.units(UnitType.TransportShip).length).toBe(1);
expect(transportShip.targetTile()).toBe(enemyShoreTile);
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(transportShip.owner()).toBe(player1);
transportShip.orderBoatRetreat();
executeTicks(game, 2);
expect(transportShip.targetTile()).not.toBe(enemyShoreTile);
expect(game.owner(transportShip.targetTile()!)).toBe(player1);
});
test("Retreating transport ship is deleted if new owner has no shore tiles", () => {
player2.conquer(game.map().ref(coastX, 1));
player2.conquer(game.map().ref(coastX - 6, 2));
player1.conquer(game.map().ref(coastX - 6, 3));
const enemyShoreTile = game.map().ref(coastX, 15);
const boatTroops = 100;
game.addExecution(
new TransportShipExecution(
player2,
null,
enemyShoreTile,
boatTroops,
game.map().ref(coastX, 1),
),
);
executeTicks(game, 1);
const transportShip = player2.units(UnitType.TransportShip)[0];
expect(player2.units(UnitType.TransportShip).length).toBe(1);
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(transportShip.owner()).toBe(player1);
// Make sure player1 has no shore tiles for the ship to retreat to anymore
const enemyInfo = new PlayerInfo(
"Enemy",
PlayerType.Human,
null,
"enemy_id",
);
enemy = game.addPlayer(enemyInfo);
const shoreTiles = Array.from(player1.borderTiles()).filter((t) =>
game.isShore(t),
);
shoreTiles.forEach((tile) => {
enemy.conquer(tile);
});
expect(
Array.from(player1.borderTiles()).filter((t) => game.isShore(t)).length,
).toBe(0);
executeTicks(game, 1);
const troopIncPerTick = game.config().troopIncreaseRate(player1);
const expectedTroopGrowth = toInt(troopIncPerTick * 1);
const expectedFinalTroops = Number(
toInt(player1.troops()) + expectedTroopGrowth,
);
transportShip.orderBoatRetreat();
executeTicks(game, 1);
expect(transportShip.isActive()).toBe(false);
// Also test if boat troops were returned to player1 as new ship owner
expect(player1.troops()).toBe(expectedFinalTroops + boatTroops);
});
});
});
+5 -5
View File
@@ -79,7 +79,7 @@ describe("FakeHuman MIRV Retaliation", () => {
const mirvCountBefore = fakehuman.units(UnitType.MIRV).length;
// Initialize fakehuman with FakeHumanExecution to enable retaliation logic
const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info());
const fakehumanNation = new Nation(new Cell(50, 50), fakehuman.info());
// Try different game IDs to account for hesitation odds
const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`);
@@ -247,7 +247,7 @@ describe("FakeHuman MIRV Retaliation", () => {
const mirvCountBefore = fakehuman.units(UnitType.MIRV).length;
// Initialize fakehuman with FakeHumanExecution to enable victory denial logic
const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info());
const fakehumanNation = new Nation(new Cell(50, 50), fakehuman.info());
// Try different game IDs to account for hesitation odds
const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`);
@@ -400,7 +400,7 @@ describe("FakeHuman MIRV Retaliation", () => {
const mirvCountBefore = fakehuman.units(UnitType.MIRV).length;
// Initialize fakehuman with FakeHumanExecution to enable steamroll stop logic
const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info());
const fakehumanNation = new Nation(new Cell(50, 50), fakehuman.info());
// Try different game IDs to account for hesitation odds
const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`);
@@ -551,7 +551,7 @@ describe("FakeHuman MIRV Retaliation", () => {
const mirvCountBefore = fakehuman.units(UnitType.MIRV).length;
// Initialize fakehuman with FakeHumanExecution to enable steamroll stop logic
const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info());
const fakehumanNation = new Nation(new Cell(50, 50), fakehuman.info());
// Try different game IDs to account for hesitation odds
const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`);
@@ -685,7 +685,7 @@ describe("FakeHuman MIRV Retaliation", () => {
const mirvCountBefore = fakehuman.units(UnitType.MIRV).length;
// Initialize fakehuman with FakeHumanExecution to enable team victory denial logic
const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info());
const fakehumanNation = new Nation(new Cell(50, 50), fakehuman.info());
// Try different game IDs to account for hesitation odds
const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`);
+41
View File
@@ -0,0 +1,41 @@
import { computeAllianceClipPath } from "../src/client/graphics/PlayerIcons";
describe("PlayerIcons", () => {
describe("computeAllianceClipPath", () => {
test("returns full visibility (20% top cut) when alliance time is at 100%", () => {
const result = computeAllianceClipPath(1.0);
// topCut = 20 + (1 - 1.0) * 80 * 0.78 = 20 + 0 = 20.00
expect(result).toBe("inset(20.00% -2px 0 -2px)");
});
test("returns maximum cut (82.40% top cut) when alliance time is at 0%", () => {
const result = computeAllianceClipPath(0.0);
// topCut = 20 + (1 - 0.0) * 80 * 0.78 = 20 + 62.4 = 82.40
expect(result).toBe("inset(82.40% -2px 0 -2px)");
});
test("returns 51.20% top cut when alliance time is at 50%", () => {
const result = computeAllianceClipPath(0.5);
// topCut = 20 + (1 - 0.5) * 80 * 0.78 = 20 + 31.2 = 51.20
expect(result).toBe("inset(51.20% -2px 0 -2px)");
});
test("returns 27.80% top cut when alliance time is at 87.5%", () => {
const result = computeAllianceClipPath(0.875);
// topCut = 20 + (1 - 0.875) * 80 * 0.78 = 20 + 7.8 = 27.80
expect(result).toBe("inset(27.80% -2px 0 -2px)");
});
test("returns 74.60% top cut when alliance time is at 12.5%", () => {
const result = computeAllianceClipPath(0.125);
// topCut = 20 + (1 - 0.125) * 80 * 0.78 = 20 + 54.6 = 74.60
expect(result).toBe("inset(74.60% -2px 0 -2px)");
});
test("includes -2px horizontal overscan to prevent subpixel gaps", () => {
const result = computeAllianceClipPath(0.5);
expect(result).toContain("-2px");
expect(result.match(/-2px/g)).toHaveLength(2); // Should appear twice (left and right)
});
});
});
+18 -3
View File
@@ -41,7 +41,12 @@ describe("PortExecution", () => {
game.config().tradeShipShortRangeDebuff = () => 0;
player.conquer(game.ref(7, 10));
const execution = new PortExecution(player, game.ref(7, 10));
const spawn = player.canBuild(UnitType.Port, game.ref(7, 10));
if (spawn === false) {
throw new Error("Unable to build port for test");
}
const port = player.buildUnit(UnitType.Port, spawn, {});
const execution = new PortExecution(port);
execution.init(game, 0);
execution.tick(0);
@@ -60,7 +65,12 @@ describe("PortExecution", () => {
game.config().tradeShipShortRangeDebuff = () => 0;
player.conquer(game.ref(7, 10));
const execution = new PortExecution(player, game.ref(7, 10));
const spawn = player.canBuild(UnitType.Port, game.ref(7, 10));
if (spawn === false) {
throw new Error("Unable to build port for test");
}
const port = player.buildUnit(UnitType.Port, spawn, {});
const execution = new PortExecution(port);
execution.init(game, 0);
execution.tick(0);
@@ -78,7 +88,12 @@ describe("PortExecution", () => {
game.config().tradeShipShortRangeDebuff = () => 100;
player.conquer(game.ref(7, 10));
const execution = new PortExecution(player, game.ref(7, 10));
const spawn = player.canBuild(UnitType.Port, game.ref(7, 10));
if (spawn === false) {
throw new Error("Unable to build port for test");
}
const port = player.buildUnit(UnitType.Port, spawn, {});
const execution = new PortExecution(port);
execution.init(game, 0);
execution.tick(0);
+7 -1
View File
@@ -145,7 +145,13 @@ describe("Shell Random Damage", () => {
});
test("Defense post shell attacks have random damage", () => {
const defensePost = new DefensePostExecution(player1, game.ref(coastX, 5));
player1.conquer(game.ref(coastX, 5));
const spawn = player1.canBuild(UnitType.DefensePost, game.ref(coastX, 5));
if (spawn === false) {
throw new Error("Unable to build defense post for test");
}
const defensePostUnit = player1.buildUnit(UnitType.DefensePost, spawn, {});
const defensePost = new DefensePostExecution(defensePostUnit);
const target = player2.buildUnit(
UnitType.Warship,
-1
View File
@@ -11,7 +11,6 @@ describe("assignTeams", () => {
PlayerType.Human,
null, // clientID (null for testing)
id,
null, // nation (null for testing)
);
};
+7 -4
View File
@@ -121,8 +121,8 @@ describe("UILayer", () => {
ui.redraw();
const unit = {
id: () => 2,
type: () => "Construction",
constructionType: () => "City",
type: () => "City",
isUnderConstruction: () => true,
owner: () => ({ id: () => 1 }),
tile: () => ({}),
isActive: () => true,
@@ -141,17 +141,20 @@ describe("UILayer", () => {
ui.redraw();
const unit = {
id: () => 2,
type: () => "Construction",
constructionType: () => "City",
type: () => "City",
isUnderConstruction: () => true,
owner: () => ({ id: () => 1 }),
tile: () => ({}),
isActive: () => true,
createdAt: () => 1,
markedForDeletion: () => false,
} as unknown as UnitView;
ui.onUnitEvent(unit);
expect(ui["allProgressBars"].has(2)).toBe(true);
game.ticks = () => 6; // simulate enough ticks for completion
// simulate construction finished
(unit as any).isUnderConstruction = () => false;
ui.tick();
expect(ui["allProgressBars"].has(2)).toBe(false);
});
@@ -40,6 +40,8 @@ describe("NukeExecution", () => {
player = game.player("player_id");
otherPlayer = game.player("other_id");
player.conquer(game.ref(1, 1));
});
test("nuke should destroy buildings and redraw out of range buildings", async () => {
@@ -76,6 +78,7 @@ describe("NukeExecution", () => {
});
test("nuke should only be targetable near src and dst", async () => {
player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {});
const nukeExec = new NukeExecution(
UnitType.AtomBomb,
player,
+71
View File
@@ -0,0 +1,71 @@
import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution";
import { SpawnExecution } from "../../src/core/execution/SpawnExecution";
import {
Game,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "../../src/core/game/Game";
import { setup } from "../util/Setup";
describe("Construction economy", () => {
let game: Game;
let player: Player;
beforeEach(async () => {
game = await setup("ocean_and_land", {
infiniteGold: false,
instantBuild: false,
infiniteTroops: true,
});
const info = new PlayerInfo(
"builder",
PlayerType.Human,
null,
"builder_id",
);
game.addPlayer(info);
const spawn = game.ref(0, 10);
game.addExecution(new SpawnExecution(info, spawn));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player = game.player(info.id);
});
test("City charges gold once and no refund thereafter (allow passive income)", () => {
const target = game.ref(0, 10);
const cost = game.unitInfo(UnitType.City).cost(player);
player.addGold(cost);
expect(player.gold()).toBe(cost);
const startTick = game.ticks();
game.addExecution(new ConstructionExecution(player, UnitType.City, target));
// First tick usually initializes the execution, second tick performs build and deduction
game.executeNextTick();
game.executeNextTick();
const afterBuild = player.gold();
const ticksAfterBuild = BigInt(game.ticks() - startTick);
const passivePerTick = 100n; // DefaultConfig goldAdditionRate for humans
expect(afterBuild < cost).toBe(true); // cost was deducted
expect(afterBuild <= ticksAfterBuild * passivePerTick).toBe(true); // only passive income allowed
// Advance through construction duration
const duration = game.unitInfo(UnitType.City).constructionDuration ?? 0;
for (let i = 0; i <= duration + 2; i++) game.executeNextTick();
const finalGold = player.gold();
const ticksElapsed = BigInt(game.ticks() - startTick);
// Ensure no refund equal to cost snuck back in; only passive income accumulated
expect(finalGold < cost).toBe(true);
expect(finalGold <= ticksElapsed * passivePerTick).toBe(true);
// Structure exists and is active
expect(player.units(UnitType.City)).toHaveLength(1);
expect(
(player.units(UnitType.City)[0] as any).isUnderConstruction?.() ?? false,
).toBe(false);
});
});
+197
View File
@@ -0,0 +1,197 @@
import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution";
import { SpawnExecution } from "../../src/core/execution/SpawnExecution";
import {
Game,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "../../src/core/game/Game";
import { setup } from "../util/Setup";
describe("Hydrogen Bomb and MIRV flows", () => {
let game: Game;
let player: Player;
beforeEach(async () => {
game = await setup("plains", { infiniteGold: true, instantBuild: true });
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
game.addPlayer(info);
game.addExecution(new SpawnExecution(info, game.ref(1, 1)));
while (game.inSpawnPhase()) game.executeNextTick();
player = game.player(info.id);
player.conquer(game.ref(1, 1));
});
test("Hydrogen bomb launches when silo exists and cannot use silo under construction", () => {
// Build a silo instantly and launch Hydrogen Bomb
game.addExecution(
new ConstructionExecution(player, UnitType.MissileSilo, game.ref(1, 1)),
);
game.executeNextTick();
game.executeNextTick();
expect(player.units(UnitType.MissileSilo)).toHaveLength(1);
// Launch Hydrogen Bomb
const target = game.ref(7, 7);
game.addExecution(
new ConstructionExecution(player, UnitType.HydrogenBomb, target),
);
game.executeNextTick();
game.executeNextTick();
game.executeNextTick();
expect(player.units(UnitType.HydrogenBomb).length).toBeGreaterThan(0);
// Now build another silo with construction time and ensure it won't be used
// Use non-instant config by simulating an under-construction flag on a new silo
// (Use normal construction with default duration in a fresh game instance)
});
test("Hydrogen bomb launch fails when silo is under construction and succeeds after completion", async () => {
// Set up a game without instantBuild to test construction duration
const gameWithConstruction = await setup("plains", {
infiniteGold: false,
instantBuild: false,
});
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
gameWithConstruction.addPlayer(info);
gameWithConstruction.addExecution(
new SpawnExecution(info, gameWithConstruction.ref(1, 1)),
);
while (gameWithConstruction.inSpawnPhase())
gameWithConstruction.executeNextTick();
const playerWithConstruction = gameWithConstruction.player(info.id);
playerWithConstruction.conquer(gameWithConstruction.ref(1, 1));
const siloTile = gameWithConstruction.ref(7, 7);
playerWithConstruction.conquer(siloTile);
// Capture gold before starting silo construction
const goldBeforeSilo = playerWithConstruction.gold();
const siloCost = gameWithConstruction
.unitInfo(UnitType.MissileSilo)
.cost(playerWithConstruction);
playerWithConstruction.addGold(siloCost);
// Start construction of silo
gameWithConstruction.addExecution(
new ConstructionExecution(
playerWithConstruction,
UnitType.MissileSilo,
siloTile,
),
);
gameWithConstruction.executeNextTick();
gameWithConstruction.executeNextTick();
// Verify silo exists and is under construction
const silos = playerWithConstruction.units(UnitType.MissileSilo);
expect(silos.length).toBe(1);
const silo = silos[0];
expect(silo.isUnderConstruction()).toBe(true);
// Capture gold after construction started
const goldAfterConstruction = playerWithConstruction.gold();
expect(goldAfterConstruction).toBeLessThan(goldBeforeSilo + siloCost);
// Attempt to launch HydrogenBomb while silo is under construction
const targetTile = gameWithConstruction.ref(10, 10);
const hydrogenBombCountBefore = playerWithConstruction.units(
UnitType.HydrogenBomb,
).length;
const canBuildResult = playerWithConstruction.canBuild(
UnitType.HydrogenBomb,
targetTile,
);
expect(canBuildResult).toBe(false); // Should fail because silo is under construction
// Try to add execution - should fail
gameWithConstruction.addExecution(
new ConstructionExecution(
playerWithConstruction,
UnitType.HydrogenBomb,
targetTile,
),
);
gameWithConstruction.executeNextTick();
gameWithConstruction.executeNextTick();
// Assert launch does not succeed
const hydrogenBombCountAfter = playerWithConstruction.units(
UnitType.HydrogenBomb,
).length;
expect(hydrogenBombCountAfter).toBe(hydrogenBombCountBefore);
// Assert no refunds during construction
const goldDuringConstruction = playerWithConstruction.gold();
expect(goldDuringConstruction >= goldAfterConstruction).toBe(true);
// Advance ticks to complete construction
const constructionDuration =
gameWithConstruction.unitInfo(UnitType.MissileSilo)
.constructionDuration ?? 0;
for (let i = 0; i < constructionDuration + 2; i++) {
gameWithConstruction.executeNextTick();
}
// Verify silo is complete
const completedSilo = playerWithConstruction.units(UnitType.MissileSilo)[0];
expect(completedSilo.isUnderConstruction()).toBe(false);
// Now launch should succeed - ensure we have gold and target is conquered
playerWithConstruction.conquer(targetTile);
const hydrogenBombCost = gameWithConstruction
.unitInfo(UnitType.HydrogenBomb)
.cost(playerWithConstruction);
playerWithConstruction.addGold(hydrogenBombCost);
const canBuildAfterCompletion = playerWithConstruction.canBuild(
UnitType.HydrogenBomb,
targetTile,
);
expect(canBuildAfterCompletion).not.toBe(false);
gameWithConstruction.addExecution(
new ConstructionExecution(
playerWithConstruction,
UnitType.HydrogenBomb,
targetTile,
),
);
gameWithConstruction.executeNextTick();
gameWithConstruction.executeNextTick();
gameWithConstruction.executeNextTick();
// Verify launch succeeded
const hydrogenBombCountAfterSuccess = playerWithConstruction.units(
UnitType.HydrogenBomb,
).length;
expect(hydrogenBombCountAfterSuccess).toBeGreaterThan(
hydrogenBombCountBefore,
);
});
test("MIRV launches when silo exists and targets player-owned tiles", () => {
// Build a silo instantly
game.addExecution(
new ConstructionExecution(player, UnitType.MissileSilo, game.ref(1, 1)),
);
game.executeNextTick();
game.executeNextTick();
expect(player.units(UnitType.MissileSilo)).toHaveLength(1);
// Launch MIRV at a player-owned tile (the silo tile)
const target = game.ref(1, 1);
game.addExecution(new ConstructionExecution(player, UnitType.MIRV, target));
game.executeNextTick(); // init
game.executeNextTick(); // create MIRV unit
game.executeNextTick();
// MIRV should appear briefly before separation, otherwise warheads should be queued
const mirvs = player.units(UnitType.MIRV).length;
const warheads = player.units(UnitType.MIRVWarhead).length;
expect(mirvs > 0 || warheads > 0).toBe(true);
});
});
+103
View File
@@ -0,0 +1,103 @@
// Mock BuildMenu to avoid importing lit and other ESM-heavy deps in this unit test
jest.mock(
"../src/client/graphics/layers/BuildMenu",
() => ({
BuildMenu: class {},
flattenedBuildTable: [],
}),
{ virtual: true },
);
// Mock Utils to avoid touching DOM (document) during tests
jest.mock("../src/client/Utils", () => ({
translateText: (k: string) => k,
getSvgAspectRatio: async () => 1,
}));
import {
COLORS,
rootMenuElement,
type MenuElementParams,
} from "../src/client/graphics/layers/RadialMenuElements";
// Minimal stubs to satisfy types used in rootMenuElement.subMenu and allyBreak actions
const makePlayer = (id: string) =>
({
id: () => id,
isAlliedWith: (other: any) =>
other && typeof other.id === "function" && other.id() !== id
? true
: true,
}) as unknown as import("../src/core/game/GameView").PlayerView;
const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
const myPlayer = (opts?.myPlayer as any) ?? makePlayer("p1");
const selected = (opts?.selected as any) ?? makePlayer("p2");
return {
myPlayer,
selected,
tile: {} as any,
playerActions: {
canAttack: true,
interaction: {
canBreakAlliance: true,
canSendAllianceRequest: false,
canEmbargo: false,
},
} as any,
game: {
inSpawnPhase: () => false,
owner: () => ({ isPlayer: () => false }),
} as any,
buildMenu: {
canBuildOrUpgrade: () => false,
cost: () => 0,
count: () => 0,
sendBuildOrUpgrade: () => {},
} as any,
emojiTable: {} as any,
playerActionHandler: {
handleBreakAlliance: jest.fn(),
handleEmbargo: jest.fn(),
handleDonateGold: jest.fn(),
handleDonateTroops: jest.fn(),
handleTargetPlayer: jest.fn(),
} as any,
playerPanel: {
show: jest.fn(),
} as any,
chatIntegration: {
createQuickChatMenu: jest.fn(() => []),
} as any,
eventBus: {} as any,
closeMenu: jest.fn(),
};
};
const findAllyBreak = (items: any[]) =>
items.find((i) => i && i.id === "ally_break");
describe("RadialMenuElements ally break", () => {
test("shows break option with correct color when allied", () => {
const params = makeParams();
const items = rootMenuElement.subMenu!(params);
const ally = findAllyBreak(items)!;
expect(ally).toBeTruthy();
expect(ally.name).toBe("break");
expect(ally.color).toBe(COLORS.breakAlly);
});
test("action calls handleBreakAlliance and closes menu", () => {
const params = makeParams();
const items = rootMenuElement.subMenu!(params);
const ally = findAllyBreak(items)!;
ally.action!(params);
expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith(
params.myPlayer,
params.selected,
);
expect(params.closeMenu).toHaveBeenCalled();
});
});
+595 -7
View File
@@ -1,14 +1,602 @@
{
"map": {
"height": 1948,
"num_land_tiles": 2544294,
"width": 4110
"num_land_tiles": 2317767,
"width": 4108
},
"mini_map": {
"map16x": {
"height": 487,
"num_land_tiles": 128871,
"width": 1027
},
"map4x": {
"height": 974,
"num_land_tiles": 618860,
"width": 2055
"num_land_tiles": 556211,
"width": 2054
},
"name": "Giant World Map",
"nations": []
"name": "Giant_World_Map",
"nations": [
{
"coordinates": [2309, 535],
"flag": "tr",
"name": "Türkiye",
"strength": 1
},
{
"coordinates": [2030, 409],
"flag": "west_germany",
"name": "West Germany",
"strength": 1
},
{
"coordinates": [2074, 382],
"flag": "east_germany",
"name": "East Germany",
"strength": 1
},
{
"coordinates": [1966, 442],
"flag": "fr",
"name": "France",
"strength": 1
},
{
"coordinates": [1872, 528],
"flag": "Fascist Spain",
"name": "Spain",
"strength": 1
},
{
"coordinates": [2074, 498],
"flag": "it",
"name": "Italy",
"strength": 1
},
{
"coordinates": [1912, 379],
"flag": "gb",
"name": "United Kingdom",
"strength": 1
},
{
"coordinates": [1841, 373],
"flag": "ie",
"name": "Ireland",
"strength": 1
},
{
"coordinates": [2153, 378],
"flag": "pl",
"name": "Poland",
"strength": 1
},
{
"coordinates": [2178, 539],
"flag": "gr",
"name": "Greece",
"strength": 1
},
{
"coordinates": [2222, 493],
"flag": "bg",
"name": "Bulgaria",
"strength": 1
},
{
"coordinates": [2135, 481],
"flag": "yugoslavia",
"name": "Yugoslavia",
"strength": 1
},
{
"coordinates": [2242, 461],
"flag": "Communist Romania",
"name": "Romania",
"strength": 1
},
{
"coordinates": [2163, 441],
"flag": "hu",
"name": "Hungary",
"strength": 1
},
{
"coordinates": [2272, 418],
"flag": "Ukrainian SSR",
"name": "Ukrainian SSR",
"strength": 1
},
{
"coordinates": [2093, 297],
"flag": "se",
"name": "Sweden",
"strength": 1
},
{
"coordinates": [2027, 282],
"flag": "no",
"name": "Norway",
"strength": 1
},
{
"coordinates": [2191, 194],
"flag": "Sami flag",
"name": "Sapmi",
"strength": 1
},
{
"coordinates": [2206, 262],
"flag": "fi",
"name": "Finland",
"strength": 1
},
{
"coordinates": [2376, 363],
"flag": "Russian SSR",
"name": "Russian SSR",
"strength": 1
},
{
"coordinates": [2222, 371],
"flag": "Byelorussian SSR",
"name": "Byelorussian SSR",
"strength": 1
},
{
"coordinates": [2441, 507],
"flag": "Georgian SSR",
"name": "Georgian SSR",
"strength": 1
},
{
"coordinates": [2402, 580],
"flag": "Second Republic of Iraq",
"name": "Iraq",
"strength": 1
},
{
"coordinates": [2353, 595],
"flag": "sy",
"name": "Syria",
"strength": 1
},
{
"coordinates": [2414, 679],
"flag": "sa",
"name": "Saudi Arabia",
"strength": 1
},
{
"coordinates": [2434, 815],
"flag": "North yemen",
"name": "North Yemen",
"strength": 1
},
{
"coordinates": [2479, 824],
"flag": "south yemen",
"name": "South Yemen",
"strength": 1
},
{
"coordinates": [2554, 724],
"flag": "ae",
"name": "United Arab Emirates",
"strength": 1
},
{
"coordinates": [2532, 609],
"flag": "Pahlavi Iran",
"name": "Iran",
"strength": 1
},
{
"coordinates": [2683, 650],
"flag": "pk",
"name": "Pakistan",
"strength": 1
},
{
"coordinates": [2654, 580],
"flag": "af",
"name": "Afghanistan",
"strength": 1
},
{
"coordinates": [2727, 416],
"flag": "Kazakh SSR",
"name": "Kazakh SSR",
"strength": 1
},
{
"coordinates": [2556, 544],
"flag": "Turkmen SSR",
"name": "Turkmen SSR",
"strength": 1
},
{
"coordinates": [2947, 362],
"flag": "Zheleznogorsk",
"name": "Zheleznogorsk",
"strength": 1
},
{
"coordinates": [3252, 229],
"flag": "Siberia",
"name": "Siberia",
"strength": 1
},
{
"coordinates": [2810, 744],
"flag": "in",
"name": "India",
"strength": 1
},
{
"coordinates": [1717, 237],
"flag": "is",
"name": "Iceland",
"strength": 1
},
{
"coordinates": [2944, 709],
"flag": "bd",
"name": "Bangladesh",
"strength": 1
},
{
"coordinates": [2868, 635],
"flag": "np",
"name": "Nepal",
"strength": 1
},
{
"coordinates": [3254, 672],
"flag": "cn",
"name": "China",
"strength": 1
},
{
"coordinates": [3373, 521],
"flag": "kp",
"name": "North Korea",
"strength": 1
},
{
"coordinates": [3389, 573],
"flag": "kr",
"name": "South Korea",
"strength": 1
},
{
"coordinates": [3515, 571],
"flag": "jp",
"name": "Japan",
"strength": 1
},
{
"coordinates": [3026, 457],
"flag": "mn",
"name": "Mongolia",
"strength": 1
},
{
"coordinates": [3229, 995],
"flag": "id",
"name": "Indonesia",
"strength": 1
},
{
"coordinates": [3121, 755],
"flag": "vn",
"name": "North Vietnam",
"strength": 1
},
{
"coordinates": [3153, 833],
"flag": "South Vietnam",
"name": "South Vietnam",
"strength": 1
},
{
"coordinates": [3013, 722],
"flag": "Burma2",
"name": "Burma",
"strength": 1
},
{
"coordinates": [3095, 822],
"flag": "kh",
"name": "Cambodia",
"strength": 1
},
{
"coordinates": [3538, 1067],
"flag": "pg",
"name": "Papua New Guinea",
"strength": 1
},
{
"coordinates": [3542, 1356],
"flag": "au",
"name": "Australia",
"strength": 1
},
{
"coordinates": [3422, 1203],
"flag": "Australian Aboriginal Flag",
"name": "Nawan-mirri",
"strength": 1
},
{
"coordinates": [3880, 1521],
"flag": "nz",
"name": "New Zealand",
"strength": 1
},
{
"coordinates": [2632, 1893],
"flag": "aq",
"name": "Antarctica",
"strength": 1
},
{
"coordinates": [2038, 590],
"flag": "tn",
"name": "Tunisia",
"strength": 1
},
{
"coordinates": [2116, 653],
"flag": "ly",
"name": "Libya",
"strength": 1
},
{
"coordinates": [2281, 653],
"flag": "United Arab Republic",
"name": "United Arab Republic",
"strength": 1
},
{
"coordinates": [1859, 613],
"flag": "ma",
"name": "Morocco",
"strength": 1
},
{
"coordinates": [1943, 615],
"flag": "dz",
"name": "Algeria",
"strength": 1
},
{
"coordinates": [2317, 754],
"flag": "sd",
"name": "Sudan",
"strength": 1
},
{
"coordinates": [2466, 918],
"flag": "so",
"name": "Somalia",
"strength": 1
},
{
"coordinates": [2352, 895],
"flag": "Imperial Ethiopia",
"name": "Ethiopia",
"strength": 1
},
{
"coordinates": [1790, 729],
"flag": "Mauritania",
"name": "Mauritania",
"strength": 1
},
{
"coordinates": [2154, 764],
"flag": "td",
"name": "Chad",
"strength": 1
},
{
"coordinates": [2051, 745],
"flag": "ne",
"name": "Niger",
"strength": 1
},
{
"coordinates": [2040, 930],
"flag": "ng",
"name": "Nigeria",
"strength": 1
},
{
"coordinates": [1805, 907],
"flag": "lr",
"name": "Liberia",
"strength": 1
},
{
"coordinates": [2195, 918],
"flag": "cf",
"name": "Central African Republic",
"strength": 1
},
{
"coordinates": [2197, 1070],
"flag": "Zaire",
"name": "Zaire",
"strength": 1
},
{
"coordinates": [2189, 1372],
"flag": "Apartheid South Africa",
"name": "South Africa",
"strength": 1
},
{
"coordinates": [2452, 1247],
"flag": "mg",
"name": "Madagascar",
"strength": 1
},
{
"coordinates": [2356, 1165],
"flag": "mz",
"name": "Mozambique",
"strength": 1
},
{
"coordinates": [2368, 1032],
"flag": "tz",
"name": "Tanzania",
"strength": 1
},
{
"coordinates": [1934, 762],
"flag": "ml",
"name": "Mali",
"strength": 1
},
{
"coordinates": [2128, 1292],
"flag": "Apartheid South Africa",
"name": "South West Africa",
"strength": 1
},
{
"coordinates": [2099, 1178],
"flag": "ao",
"name": "Angola",
"strength": 1
},
{
"coordinates": [1375, 1121],
"flag": "br",
"name": "Brazil",
"strength": 1
},
{
"coordinates": [1203, 1059],
"flag": "amazonas",
"name": "Amazonas",
"strength": 1
},
{
"coordinates": [1210, 1395],
"flag": "ar",
"name": "Argentina",
"strength": 1
},
{
"coordinates": [1107, 1419],
"flag": "cl",
"name": "Chile",
"strength": 1
},
{
"coordinates": [1064, 1114],
"flag": "pe",
"name": "Peru",
"strength": 1
},
{
"coordinates": [1065, 938],
"flag": "co",
"name": "Colombia",
"strength": 1
},
{
"coordinates": [1192, 938],
"flag": "ve",
"name": "Venezuela",
"strength": 1
},
{
"coordinates": [913, 833],
"flag": "ni",
"name": "Nicaragua",
"strength": 1
},
{
"coordinates": [788, 744],
"flag": "mx",
"name": "Mexico",
"strength": 1
},
{
"coordinates": [1011, 555],
"flag": "us",
"name": "USA",
"strength": 1
},
{
"coordinates": [800, 624],
"flag": "Texas",
"name": "Texas",
"strength": 1
},
{
"coordinates": [551, 564],
"flag": "California",
"name": "California",
"strength": 1
},
{
"coordinates": [703, 483],
"flag": "Utah",
"name": "Utah",
"strength": 1
},
{
"coordinates": [1077, 444],
"flag": "Quebec",
"name": "Quebec",
"strength": 1
},
{
"coordinates": [1231, 395],
"flag": "Newfoundland",
"name": "Newfoundland",
"strength": 1
},
{
"coordinates": [967, 418],
"flag": "ca",
"name": "Canada",
"strength": 1
},
{
"coordinates": [170, 244],
"flag": "Alaska",
"name": "Alaska",
"strength": 1
},
{
"coordinates": [741, 234],
"flag": "Nunavut",
"name": "Nunavut",
"strength": 1
},
{
"coordinates": [484, 256],
"flag": "Yukon",
"name": "Yukon",
"strength": 1
},
{
"coordinates": [1434, 223],
"flag": "gl",
"name": "Greenland",
"strength": 1
},
{
"coordinates": [2247, 1229],
"flag": "Rhodesia",
"name": "Rhodesia",
"strength": 1
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+1 -1
View File
@@ -1 +1 @@
€À`"€À`"€À`"€À`"
€À`!€À`!€À`!€À`!
+1 -1
View File
@@ -1 +1 @@
€€€À`!"#€€€À`!"#€€€À`!"#€€€À`!"#€€€À`!"#€€€À`!"#€€€À`!"#€€€À`!"#
€€€À`!!"€€€À`!!"€€€À`!!"€€€À`!!"€€€À`!!"€€€À`!!"€€€À`!!"€€€À`!!"
+1 -1
View File
@@ -1 +1 @@
€À`"€À`"€À`!€À`"
€À`!€À`!€À`!€À`!
+1 -1
View File
@@ -1 +1 @@
€€€À`!"#€€€À`!""€€€À`!"!€€€À`!!€€€€À`!!`€€€À`!"!€€€À`!""€€€À`!"#
€€€À`!!!€€€À`!!!€€€À`!!`€€€À`!€€€À`!!`€€€À`!!!€€€À`!!!€€€À`!!"
+2 -1
View File
@@ -25,6 +25,7 @@ export async function setup(
_gameConfig: Partial<GameConfig> = {},
humans: PlayerInfo[] = [],
currentDir: string = __dirname,
ConfigClass: typeof TestConfig = TestConfig,
): Promise<Game> {
// Suppress console.debug for tests.
console.debug = () => {};
@@ -70,7 +71,7 @@ export async function setup(
randomSpawn: false,
..._gameConfig,
};
const config = new TestConfig(
const config = new ConfigClass(
serverConfig,
gameConfig,
new UserSettings(),
+23
View File
@@ -85,3 +85,26 @@ export class TestConfig extends DefaultConfig {
return 1;
}
}
export class UseRealAttackLogic extends TestConfig {
// Override to use DefaultConfig's real attackLogic
attackLogic(
gm: Game,
attackTroops: number,
attacker: Player,
defender: Player | TerraNullius,
tileToConquer: TileRef,
): {
attackerTroopLoss: number;
defenderTroopLoss: number;
tilesPerTickUsed: number;
} {
return DefaultConfig.prototype.attackLogic.call(
this,
gm,
attackTroops,
attacker,
defender,
tileToConquer,
);
}
}