mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 17:36:44 +00:00
62e15d2794
## Description: Cut worker→main bandwidth ~3.3× by switching PlayerUpdate from a full per-tick snapshot to a field-level diff. PlayerImpl.toUpdate() now caches the last sent update and returns only changed fields, or null if nothing changed. The client-side applyStateUpdate() merges instead of overwriting. Per-tick total dropped from ~297 KB to ~89 KB; the Player bucket alone went from 258 KB/tick to 50 KB/tick. Diff/apply logic lives in a new GameUpdateUtils.ts module with unit tests. ## 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: evan
461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
import { AttackExecution } from "../src/core/execution/AttackExecution";
|
|
import { MarkDisconnectedExecution } from "../src/core/execution/MarkDisconnectedExecution";
|
|
import { PlayerExecution } from "../src/core/execution/PlayerExecution";
|
|
import { TransportShipExecution } from "../src/core/execution/TransportShipExecution";
|
|
import { getSpawnTiles } from "../src/core/execution/Util";
|
|
import { WarshipExecution } from "../src/core/execution/WarshipExecution";
|
|
import {
|
|
Game,
|
|
GameMode,
|
|
HumansVsNations,
|
|
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;
|
|
|
|
function spawnPlayerForTest(game: Game, player: Player, x: number, y: number) {
|
|
const spawn = game.map().ref(x, y);
|
|
for (const tile of getSpawnTiles(game, spawn, false)) {
|
|
player.conquer(tile);
|
|
}
|
|
player.setSpawnTile(spawn);
|
|
game.addExecution(new PlayerExecution(player));
|
|
}
|
|
|
|
describe("Disconnected", () => {
|
|
beforeEach(async () => {
|
|
const player1Info = new PlayerInfo(
|
|
"Active Player",
|
|
PlayerType.Human,
|
|
null,
|
|
"player1_id",
|
|
);
|
|
|
|
const player2Info = new PlayerInfo(
|
|
"Disconnected Player",
|
|
PlayerType.Human,
|
|
null,
|
|
"player2_id",
|
|
);
|
|
|
|
game = await setup(
|
|
"plains",
|
|
{
|
|
infiniteGold: true,
|
|
instantBuild: true,
|
|
},
|
|
[player1Info, player2Info],
|
|
);
|
|
|
|
player1 = game.player(player1Info.id);
|
|
player2 = game.player(player2Info.id);
|
|
player1.conquer(game.ref(1, 1));
|
|
player2.conquer(game.ref(7, 7));
|
|
});
|
|
|
|
describe("Player disconnected state", () => {
|
|
test("should initialize players as not disconnected", () => {
|
|
expect(player1.isDisconnected()).toBe(false);
|
|
expect(player2.isDisconnected()).toBe(false);
|
|
});
|
|
|
|
test("should mark player as disconnected and not disconnected", () => {
|
|
player1.markDisconnected(true);
|
|
expect(player1.isDisconnected()).toBe(true);
|
|
|
|
player1.markDisconnected(false);
|
|
expect(player1.isDisconnected()).toBe(false);
|
|
});
|
|
|
|
test("should include disconnected state in player update", () => {
|
|
player1.markDisconnected(true);
|
|
const update = player1.toUpdate();
|
|
expect(update?.isDisconnected).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Player view", () => {
|
|
test("should reflect disconnected state in player view", () => {
|
|
// Mark player2 as disconnected
|
|
player2.markDisconnected(true);
|
|
|
|
// Get player1's view of player2
|
|
const player2View = game.player(player2.id());
|
|
expect(player2View.isDisconnected()).toBe(true);
|
|
|
|
// Mark player2 as connected again
|
|
player2.markDisconnected(false);
|
|
|
|
// Verify the view is updated
|
|
const updatedPlayer2View = game.player(player2.id());
|
|
expect(updatedPlayer2View.isDisconnected()).toBe(false);
|
|
});
|
|
|
|
test("should maintain disconnected state in view across game ticks", () => {
|
|
player2.markDisconnected(true);
|
|
executeTicks(game, 3);
|
|
|
|
const player2View = game.player(player2.id());
|
|
expect(player2View.isDisconnected()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("MarkDisconnectedExecution", () => {
|
|
test("should mark player as disconnected when executed", () => {
|
|
const execution = new MarkDisconnectedExecution(player1, true);
|
|
game.addExecution(execution);
|
|
executeTicks(game, 1);
|
|
expect(player1.isDisconnected()).toBe(true);
|
|
expect(execution.isActive()).toBe(false);
|
|
});
|
|
|
|
test("should handle multiple players with different disconnected states", () => {
|
|
const execution1 = new MarkDisconnectedExecution(player1, true);
|
|
const execution2 = new MarkDisconnectedExecution(player2, false);
|
|
game.addExecution(execution1, execution2);
|
|
executeTicks(game, 1);
|
|
expect(player1.isDisconnected()).toBe(true);
|
|
expect(player2.isDisconnected()).toBe(false);
|
|
});
|
|
|
|
test("should not be active during spawn phase", () => {
|
|
const execution = new MarkDisconnectedExecution(player1, true);
|
|
expect(execution.activeDuringSpawnPhase()).toBe(false);
|
|
});
|
|
|
|
test("should handle multiple executions for same player in same tick", () => {
|
|
const execution1 = new MarkDisconnectedExecution(player1, true);
|
|
const execution2 = new MarkDisconnectedExecution(player1, false);
|
|
game.addExecution(execution1, execution2);
|
|
executeTicks(game, 1);
|
|
// Last execution should win
|
|
expect(player1.isDisconnected()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Disconnected state persistence", () => {
|
|
test("should maintain disconnected state across game ticks", () => {
|
|
player1.markDisconnected(true);
|
|
executeTicks(game, 5);
|
|
expect(player1.isDisconnected()).toBe(true);
|
|
});
|
|
|
|
test("should maintain disconnected state in player updates across ticks", () => {
|
|
player1.markDisconnected(true);
|
|
executeTicks(game, 3);
|
|
// toUpdate() returns diffs after the first call, so query engine state
|
|
// directly rather than the wire payload (which only carries changed fields).
|
|
expect(player1.isDisconnected()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Edge cases", () => {
|
|
test("should handle marking same disconnected state multiple times", () => {
|
|
player1.markDisconnected(true);
|
|
player1.markDisconnected(true);
|
|
player1.markDisconnected(true);
|
|
expect(player1.isDisconnected()).toBe(true);
|
|
|
|
player1.markDisconnected(false);
|
|
player1.markDisconnected(false);
|
|
player1.markDisconnected(false);
|
|
expect(player1.isDisconnected()).toBe(false);
|
|
});
|
|
|
|
test("should handle execution with same disconnected state", () => {
|
|
player1.markDisconnected(true);
|
|
const execution = new MarkDisconnectedExecution(player1, true);
|
|
game.addExecution(execution);
|
|
executeTicks(game, 1);
|
|
expect(player1.isDisconnected()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Disconnected team member interactions", () => {
|
|
const coastX = 7;
|
|
|
|
beforeEach(async () => {
|
|
const player1Info = new PlayerInfo(
|
|
"Player1",
|
|
PlayerType.Human,
|
|
null,
|
|
"player_1_id",
|
|
false,
|
|
"CLAN",
|
|
);
|
|
const player2Info = new PlayerInfo(
|
|
"Player2",
|
|
PlayerType.Human,
|
|
null,
|
|
"player_2_id",
|
|
false,
|
|
"CLAN",
|
|
);
|
|
|
|
game = await setup(
|
|
"half_land_half_ocean",
|
|
{
|
|
infiniteGold: true,
|
|
instantBuild: true,
|
|
gameMode: GameMode.Team,
|
|
playerTeams: HumansVsNations,
|
|
},
|
|
[player1Info, player2Info],
|
|
undefined,
|
|
UseRealAttackLogic, // don't use TestConfig's mock attackLogic
|
|
);
|
|
|
|
player1 = game.player(player1Info.id);
|
|
player2 = game.player(player2Info.id);
|
|
spawnPlayerForTest(game, player1, coastX - 2, 1);
|
|
spawnPlayerForTest(game, player2, coastX - 2, 4);
|
|
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),
|
|
);
|
|
|
|
while (player2.isAlive()) {
|
|
game.executeNextTick();
|
|
}
|
|
|
|
// retreat() fires in the tick after player2's last tile is conquered
|
|
// (toConquer empties, refreshToConquer() finds nothing, then retreat).
|
|
game.executeNextTick();
|
|
|
|
// startTroops returned with no malus -> no net troop loss, only passive growth
|
|
expect(player1.troops()).toBeGreaterThanOrEqual(troopsBeforeAttack);
|
|
});
|
|
|
|
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, enemyShoreTile, 100),
|
|
);
|
|
|
|
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 closest owner shore tile", () => {
|
|
player1.conquer(game.map().ref(coastX, 4));
|
|
player2.conquer(game.map().ref(coastX, 1));
|
|
|
|
// Use a far destination so boat is still in transit after attack completes
|
|
const enemyShoreTile = game.map().ref(coastX, 15);
|
|
|
|
game.addExecution(
|
|
new TransportShipExecution(player2, enemyShoreTile, 100),
|
|
);
|
|
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);
|
|
|
|
const expectedRetreatTile = player1.bestTransportShipSpawn(
|
|
transportShip.tile(),
|
|
);
|
|
expect(expectedRetreatTile).not.toBe(false);
|
|
|
|
transportShip.updateTransportShipState({ isRetreating: true });
|
|
executeTicks(game, 2);
|
|
|
|
expect(transportShip.targetTile()).toBe(expectedRetreatTile);
|
|
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, enemyShoreTile, boatTroops),
|
|
);
|
|
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.updateTransportShipState({ isRetreating: true });
|
|
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);
|
|
});
|
|
});
|
|
});
|