mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 15:00:43 +00:00
18fb513326
## Description: ### Short path for multi-source HPA* Math was not mathing, increased the bounds to 260x260, it is a bit slower but should work better. The short path was breaking when player owned a lot of shores. This is because the bounding box of tiles with less than 120 distance + 10 padding could be as big as 260x260 and the optimized array was set to 140x140. I made mistake of calculating it as `2 * (60 + 10)` instead of `2 * (120 + 10)`. ### LoS path refinement Previously, we ran 2 passes of LoS smoothing on the path. However, since we are effectively tracing the same path, the line of sight is essentially the same. This PR makes second line of sight stop on water tiles with magnitude `n + 1` compared to first path. Practically, this means it'll attempt LoS exactly 1 tile after previous corner. See screenshot. <img width="1299" height="1151" alt="image" src="https://github.com/user-attachments/assets/726be236-1ff8-406c-896a-02902a762ab0" /> ### SendBoatAttackIntentEvent The flow of sending transport ships is currently strange. This PR makes the flow more sane. **Old flow** ``` - Player clicks TARGET tile, it can be deep inland - Client asks Worker for the best START tile to TARGET tile - Worker answers `false`, since the tile is inland - Client sends BoatAttackIntent with START=false and TARGET tiles set - Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET - Worker re-computes best START to DESTINATION - Worker sends boat from START to DESTINATION ``` **New flow** ``` - Player clicks TARGET tile, it can be deep inland - Client sends BoatAttackIntent with TARGET - Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET - Worker computes START as the best tile to DESTINATION - Worker sends boat from START to DESTINATION ``` ## 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: moleole
461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
import { AttackExecution } from "../src/core/execution/AttackExecution";
|
|
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
|
|
import { TransportShipExecution } from "../src/core/execution/TransportShipExecution";
|
|
import {
|
|
Game,
|
|
Player,
|
|
PlayerInfo,
|
|
PlayerType,
|
|
UnitType,
|
|
} from "../src/core/game/Game";
|
|
import { TileRef } from "../src/core/game/GameMap";
|
|
import { GameID } from "../src/core/Schemas";
|
|
import { setup } from "./util/Setup";
|
|
import { TestConfig } from "./util/TestConfig";
|
|
import { constructionExecution } from "./util/utils";
|
|
|
|
let game: Game;
|
|
const gameID: GameID = "game_id";
|
|
let attacker: Player;
|
|
let defender: Player;
|
|
let defenderSpawn: TileRef;
|
|
let attackerSpawn: TileRef;
|
|
|
|
function sendBoat(target: TileRef, troops: number) {
|
|
game.addExecution(new TransportShipExecution(defender, target, troops));
|
|
}
|
|
|
|
const immunityPhaseTicks = 10;
|
|
function waitForImmunityToEnd() {
|
|
for (let i = 0; i < immunityPhaseTicks + 1; i++) {
|
|
game.executeNextTick();
|
|
}
|
|
}
|
|
|
|
describe("Attack", () => {
|
|
beforeEach(async () => {
|
|
game = await setup("ocean_and_land", {
|
|
infiniteGold: true,
|
|
instantBuild: true,
|
|
infiniteTroops: true,
|
|
});
|
|
const attackerInfo = new PlayerInfo(
|
|
"attacker dude",
|
|
PlayerType.Human,
|
|
null,
|
|
"attacker_id",
|
|
);
|
|
game.addPlayer(attackerInfo);
|
|
const defenderInfo = new PlayerInfo(
|
|
"defender dude",
|
|
PlayerType.Human,
|
|
null,
|
|
"defender_id",
|
|
);
|
|
game.addPlayer(defenderInfo);
|
|
|
|
defenderSpawn = game.ref(0, 15);
|
|
attackerSpawn = game.ref(0, 10);
|
|
|
|
game.addExecution(
|
|
new SpawnExecution(
|
|
gameID,
|
|
game.player(attackerInfo.id).info(),
|
|
attackerSpawn,
|
|
),
|
|
new SpawnExecution(
|
|
gameID,
|
|
game.player(defenderInfo.id).info(),
|
|
defenderSpawn,
|
|
),
|
|
);
|
|
|
|
while (game.inSpawnPhase()) {
|
|
game.executeNextTick();
|
|
}
|
|
|
|
attacker = game.player(attackerInfo.id);
|
|
defender = game.player(defenderInfo.id);
|
|
|
|
game.addExecution(
|
|
new AttackExecution(100, defender, game.terraNullius().id()),
|
|
);
|
|
game.executeNextTick();
|
|
while (defender.outgoingAttacks().length > 0) {
|
|
game.executeNextTick();
|
|
}
|
|
|
|
(game.config() as TestConfig).setDefaultNukeSpeed(50);
|
|
});
|
|
|
|
test("Nuke reduce attacking troop counts", async () => {
|
|
// Not building exactly spawn to it's better protected from attacks (but still
|
|
// on defender territory)
|
|
constructionExecution(game, defender, 1, 1, UnitType.MissileSilo);
|
|
expect(defender.units(UnitType.MissileSilo)).toHaveLength(1);
|
|
game.addExecution(new AttackExecution(100, attacker, defender.id()));
|
|
constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3);
|
|
const nuke = defender.units(UnitType.AtomBomb)[0];
|
|
expect(nuke.isActive()).toBe(true);
|
|
|
|
expect(attacker.outgoingAttacks()).toHaveLength(1);
|
|
expect(attacker.outgoingAttacks()[0].troops()).toBe(98);
|
|
|
|
// Make the nuke go kaboom
|
|
game.executeNextTick();
|
|
expect(nuke.isActive()).toBe(false);
|
|
expect(attacker.outgoingAttacks()[0].troops()).not.toBe(97);
|
|
expect(attacker.outgoingAttacks()[0].troops()).toBeLessThan(90);
|
|
});
|
|
|
|
test("Nuke reduce attacking boat troop count", async () => {
|
|
constructionExecution(game, defender, 1, 1, UnitType.MissileSilo);
|
|
expect(defender.units(UnitType.MissileSilo)).toHaveLength(1);
|
|
|
|
sendBoat(game.ref(15, 8), 100);
|
|
|
|
constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3);
|
|
const nuke = defender.units(UnitType.AtomBomb)[0];
|
|
expect(nuke.isActive()).toBe(true);
|
|
|
|
const ship = defender.units(UnitType.TransportShip)[0];
|
|
expect(ship.troops()).toBe(100);
|
|
|
|
game.executeNextTick();
|
|
|
|
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), 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;
|
|
let playerB: Player;
|
|
|
|
function addPlayerToGame(
|
|
playerInfo: PlayerInfo,
|
|
game: Game,
|
|
tile: TileRef,
|
|
): Player {
|
|
game.addPlayer(playerInfo);
|
|
game.addExecution(new SpawnExecution(gameID, playerInfo, tile));
|
|
return game.player(playerInfo.id);
|
|
}
|
|
|
|
describe("Attack race condition with alliance requests", () => {
|
|
beforeEach(async () => {
|
|
game = await setup("ocean_and_land", {
|
|
infiniteGold: true,
|
|
instantBuild: true,
|
|
infiniteTroops: true,
|
|
});
|
|
|
|
const playerAInfo = new PlayerInfo(
|
|
"playerA",
|
|
PlayerType.Human,
|
|
null,
|
|
"playerA_id",
|
|
);
|
|
playerA = addPlayerToGame(playerAInfo, game, game.ref(0, 10));
|
|
|
|
const playerBInfo = new PlayerInfo(
|
|
"playerB",
|
|
PlayerType.Human,
|
|
null,
|
|
"playerB_id",
|
|
);
|
|
playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 10));
|
|
|
|
while (game.inSpawnPhase()) {
|
|
game.executeNextTick();
|
|
}
|
|
});
|
|
|
|
it("Should not mark attacker as traitor when alliance is formed after attack starts", async () => {
|
|
// Player A sends alliance request to Player B
|
|
const allianceRequest = playerA.createAllianceRequest(playerB);
|
|
expect(allianceRequest).not.toBeNull();
|
|
|
|
// Player A attacks Player B
|
|
const attackExecution = new AttackExecution(
|
|
null,
|
|
playerA,
|
|
playerB.id(),
|
|
null,
|
|
);
|
|
game.addExecution(attackExecution);
|
|
|
|
// Player B counter-attacks Player A
|
|
const counterAttackExecution = new AttackExecution(
|
|
null,
|
|
playerB,
|
|
playerA.id(),
|
|
null,
|
|
);
|
|
|
|
// Player B accepts the alliance request
|
|
if (allianceRequest) {
|
|
allianceRequest.accept();
|
|
}
|
|
|
|
game.addExecution(counterAttackExecution);
|
|
|
|
// Execute a few ticks to process the attacks
|
|
for (let i = 0; i < 5; i++) {
|
|
game.executeNextTick();
|
|
}
|
|
|
|
// Player A should not be marked as traitor because the alliance was formed after the attack started
|
|
expect(playerA.isTraitor()).toBe(false);
|
|
|
|
expect(playerA.isAlliedWith(playerB)).toBe(true);
|
|
expect(playerB.isAlliedWith(playerA)).toBe(true);
|
|
// The attacks should have retreated due to the alliance being formed
|
|
expect(playerA.outgoingAttacks()).toHaveLength(0);
|
|
expect(playerB.outgoingAttacks()).toHaveLength(0);
|
|
});
|
|
|
|
it("Should prevent player from attacking allied player", async () => {
|
|
// Create an alliance between Player A and Player B
|
|
const allianceRequest = playerA.createAllianceRequest(playerB);
|
|
if (allianceRequest) {
|
|
allianceRequest.accept();
|
|
}
|
|
|
|
// Verify alliance exists
|
|
expect(playerA.isAlliedWith(playerB)).toBe(true);
|
|
expect(playerB.isAlliedWith(playerA)).toBe(true);
|
|
|
|
// Player A tries to attack Player B (should be blocked)
|
|
const attackExecution = new AttackExecution(
|
|
null,
|
|
playerA,
|
|
playerB.id(),
|
|
null,
|
|
);
|
|
game.addExecution(attackExecution);
|
|
|
|
// Execute a few ticks to process the attack
|
|
for (let i = 0; i < 10; i++) {
|
|
game.executeNextTick();
|
|
}
|
|
|
|
// No ongoing attacks should exist for either side
|
|
expect(playerA.outgoingAttacks()).toHaveLength(0);
|
|
expect(playerB.outgoingAttacks()).toHaveLength(0);
|
|
expect(playerA.incomingAttacks()).toHaveLength(0);
|
|
expect(playerB.incomingAttacks()).toHaveLength(0);
|
|
});
|
|
|
|
test("Should cancel alliance requests if the recipient attacks", async () => {
|
|
// Player A sends alliance request to Player B
|
|
const allianceRequest = playerA.createAllianceRequest(playerB);
|
|
expect(allianceRequest).not.toBeNull();
|
|
expect(playerB.incomingAllianceRequests()).toHaveLength(1);
|
|
|
|
// Player B attacks Player A
|
|
const attackExecution = new AttackExecution(
|
|
null,
|
|
playerB,
|
|
playerA.id(),
|
|
null,
|
|
);
|
|
game.addExecution(attackExecution);
|
|
|
|
// Execute a few ticks to process the attacks
|
|
for (let i = 0; i < 5; i++) {
|
|
game.executeNextTick();
|
|
}
|
|
// Alliance request should be denied since player B attacked
|
|
expect(playerA.outgoingAllianceRequests()).toHaveLength(0);
|
|
expect(playerB.incomingAllianceRequests()).toHaveLength(0);
|
|
});
|
|
|
|
test("Should cancel the proper alliance request among many", async () => {
|
|
// Add a new player to have more alliance requests
|
|
const playerCInfo = new PlayerInfo(
|
|
"playerB",
|
|
PlayerType.Human,
|
|
null,
|
|
"playerB_id",
|
|
);
|
|
const playerC = addPlayerToGame(playerCInfo, game, game.ref(10, 10));
|
|
|
|
// Player A sends alliance request to Player B
|
|
const allianceRequestAtoB = playerA.createAllianceRequest(playerB);
|
|
expect(allianceRequestAtoB).not.toBeNull();
|
|
|
|
// Player C also sends alliance request to Player B
|
|
const allianceRequestCtoB = playerC.createAllianceRequest(playerB);
|
|
expect(allianceRequestCtoB).not.toBeNull();
|
|
|
|
expect(playerB.incomingAllianceRequests()).toHaveLength(2);
|
|
|
|
// Player B attacks Player A
|
|
const attackExecution = new AttackExecution(
|
|
null,
|
|
playerB,
|
|
playerA.id(),
|
|
null,
|
|
);
|
|
game.addExecution(attackExecution);
|
|
|
|
// Execute a few ticks to process the attacks
|
|
for (let i = 0; i < 5; i++) {
|
|
game.executeNextTick();
|
|
}
|
|
// Alliance request A->B should be denied since player B attacked
|
|
expect(playerA.outgoingAllianceRequests()).toHaveLength(0);
|
|
// However C->B should remain
|
|
expect(playerB.incomingAllianceRequests()).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("Attack immunity", () => {
|
|
beforeEach(async () => {
|
|
game = await setup("ocean_and_land", {
|
|
infiniteGold: true,
|
|
instantBuild: true,
|
|
infiniteTroops: true,
|
|
});
|
|
|
|
(game.config() as TestConfig).setSpawnImmunityDuration(immunityPhaseTicks);
|
|
|
|
const playerAInfo = new PlayerInfo(
|
|
"playerA",
|
|
PlayerType.Human,
|
|
null,
|
|
"playerA_id",
|
|
);
|
|
// close to the water to send boats
|
|
playerA = addPlayerToGame(playerAInfo, game, game.ref(7, 0));
|
|
|
|
const playerBInfo = new PlayerInfo(
|
|
"playerB",
|
|
PlayerType.Human,
|
|
null,
|
|
"playerB_id",
|
|
);
|
|
playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15));
|
|
|
|
while (game.inSpawnPhase()) {
|
|
game.executeNextTick();
|
|
}
|
|
});
|
|
|
|
test("Should not be able to attack during immunity phase", async () => {
|
|
// Player A attacks Player B
|
|
const attackExecution = new AttackExecution(
|
|
null,
|
|
playerA,
|
|
playerB.id(),
|
|
null,
|
|
);
|
|
game.addExecution(attackExecution);
|
|
game.executeNextTick();
|
|
expect(playerA.outgoingAttacks()).toHaveLength(0);
|
|
});
|
|
|
|
test("Should be able to attack after immunity phase", async () => {
|
|
waitForImmunityToEnd();
|
|
// Player A attacks Player B
|
|
const attackExecution = new AttackExecution(
|
|
null,
|
|
playerA,
|
|
playerB.id(),
|
|
null,
|
|
);
|
|
game.addExecution(attackExecution);
|
|
game.executeNextTick();
|
|
expect(playerA.outgoingAttacks()).toHaveLength(1);
|
|
});
|
|
|
|
test("Ensure a player can't attack during all the immunity phase", async () => {
|
|
// Execute a few ticks but stop right before the immunity phase is over
|
|
for (let i = 0; i < immunityPhaseTicks - 1; i++) {
|
|
game.executeNextTick();
|
|
}
|
|
// Player A attacks Player B
|
|
game.addExecution(new AttackExecution(null, playerA, playerB.id(), null));
|
|
game.executeNextTick(); // ticks === immunityPhaseTicks here
|
|
// Attack is not possible during immunity
|
|
expect(playerA.outgoingAttacks()).toHaveLength(0);
|
|
|
|
// Retry after the immunity is over
|
|
game.executeNextTick(); // ticks === immunityPhaseTicks + 1
|
|
game.addExecution(new AttackExecution(null, playerA, playerB.id(), null));
|
|
game.executeNextTick();
|
|
// Attack is now possible right after
|
|
expect(playerA.outgoingAttacks()).toHaveLength(1);
|
|
});
|
|
|
|
test("Should not be able to send a boat during immunity phase", async () => {
|
|
// Player A sends a boat targeting Player B
|
|
game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10));
|
|
game.executeNextTick();
|
|
expect(playerA.units(UnitType.TransportShip)).toHaveLength(0);
|
|
});
|
|
|
|
test("Should be able to send a boat after immunity phase", async () => {
|
|
waitForImmunityToEnd();
|
|
// Player A sends a boat targeting Player B
|
|
game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10));
|
|
game.executeNextTick();
|
|
expect(playerA.units(UnitType.TransportShip)).toHaveLength(1);
|
|
});
|
|
|
|
test("Should be able to attack nations during immunity phase", async () => {
|
|
const nationId = "nation_id";
|
|
const nation = new PlayerInfo("nation", PlayerType.Nation, null, nationId);
|
|
game.addPlayer(nation);
|
|
// Player A attacks the nation
|
|
const attackExecution = new AttackExecution(null, playerA, nationId, null);
|
|
game.addExecution(attackExecution);
|
|
game.executeNextTick();
|
|
expect(playerA.outgoingAttacks()).toHaveLength(1);
|
|
});
|
|
|
|
test("Should be able to attack bots during immunity phase", async () => {
|
|
const botId = "bot_id";
|
|
const bot = new PlayerInfo("bot", PlayerType.Bot, null, botId);
|
|
game.addPlayer(bot);
|
|
// Player A attacks the bot
|
|
const attackExecution = new AttackExecution(null, playerA, botId, null);
|
|
game.addExecution(attackExecution);
|
|
game.executeNextTick();
|
|
expect(playerA.outgoingAttacks()).toHaveLength(1);
|
|
});
|
|
|
|
test("Can't send nuke during immunity phase", async () => {
|
|
constructionExecution(game, playerA, 7, 0, UnitType.MissileSilo);
|
|
expect(playerA.units(UnitType.MissileSilo)).toHaveLength(1);
|
|
// Player A sends a bomb to player B
|
|
constructionExecution(game, playerA, 0, 11, UnitType.AtomBomb, 3);
|
|
expect(playerA.units(UnitType.AtomBomb)).toHaveLength(0);
|
|
// Now wait for immunity to end
|
|
waitForImmunityToEnd();
|
|
// And send the exact same order
|
|
constructionExecution(game, playerA, 0, 11, UnitType.AtomBomb, 3);
|
|
expect(playerA.units(UnitType.AtomBomb)).toHaveLength(1);
|
|
});
|
|
});
|