diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 2f3331654d..1a7df2240a 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -943,7 +943,8 @@ export class ClientGameRunner { this.eventBus.emit( new SendAttackIntentEvent( this.gameView.owner(tile).id(), - this.myPlayer!.troops() * this.renderer.uiState.attackRatio, + this.renderer.uiState.attackRatio, + this.myPlayer!.troops(), ), ); } else if (this.canAutoBoat(actions.buildableUnits, tile)) { @@ -1102,7 +1103,8 @@ export class ClientGameRunner { this.eventBus.emit( new SendAttackIntentEvent( this.gameView.owner(tile).id(), - this.myPlayer!.troops() * this.renderer.uiState.attackRatio, + this.renderer.uiState.attackRatio, + this.myPlayer!.troops(), ), ); } @@ -1136,12 +1138,14 @@ export class ClientGameRunner { mostRecentAttack.attackerID, ) as PlayerView; if (!attacker) return; - - const counterTroops = Math.min( - mostRecentAttack.troops, - this.renderer.uiState.attackRatio * this.myPlayer.troops(), + this.eventBus.emit( + new SendAttackIntentEvent( + attacker.id(), + this.renderer.uiState.attackRatio, + this.myPlayer.troops(), + mostRecentAttack.troops, + ), ); - this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops)); } private doRequestAllianceUnderCursor(): void { @@ -1226,7 +1230,8 @@ export class ClientGameRunner { this.eventBus.emit( new SendBoatAttackIntentEvent( tile, - this.myPlayer.troops() * this.renderer.uiState.attackRatio, + this.renderer.uiState.attackRatio, + this.myPlayer.troops(), ), ); } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index c1307af699..044bc927c2 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -71,14 +71,17 @@ export class SendSpawnIntentEvent implements GameEvent { export class SendAttackIntentEvent implements GameEvent { constructor( public readonly targetID: PlayerID | null, - public readonly troops: number, + public readonly troopRatio: number, + public readonly troopCount: number, + public readonly maxTroopCount: number | null = null, ) {} } export class SendBoatAttackIntentEvent implements GameEvent { constructor( public readonly dst: TileRef, - public readonly troops: number, + public readonly troopRatio: number, + public readonly troopCount: number, ) {} } @@ -484,14 +487,16 @@ export class Transport { this.sendIntent({ type: "attack", targetID: event.targetID, - troops: event.troops, + troopRatio: event.troopRatio, + troopCount: event.troopCount, }); } private onSendBoatAttackIntent(event: SendBoatAttackIntentEvent) { this.sendIntent({ type: "boat", - troops: event.troops, + troopRatio: event.troopRatio, + troopCount: event.troopCount, dst: event.dst, }); } diff --git a/src/client/hud/layers/AttacksDisplay.ts b/src/client/hud/layers/AttacksDisplay.ts index 0f83d1cdee..ee86b6dff0 100644 --- a/src/client/hud/layers/AttacksDisplay.ts +++ b/src/client/hud/layers/AttacksDisplay.ts @@ -200,12 +200,14 @@ export class AttacksDisplay extends LitElement implements Controller { const myPlayer = this.game.myPlayer(); if (!myPlayer) return; - - const counterTroops = Math.min( - attack.troops, - this.uiState.attackRatio * myPlayer.troops(), + this.eventBus.emit( + new SendAttackIntentEvent( + attacker.id(), + this.uiState.attackRatio, + myPlayer.troops(), + attack.troops, + ), ); - this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops)); } private renderIncomingAttacks() { diff --git a/src/client/hud/layers/PlayerActionHandler.ts b/src/client/hud/layers/PlayerActionHandler.ts index bb9c6613dc..d771f84cee 100644 --- a/src/client/hud/layers/PlayerActionHandler.ts +++ b/src/client/hud/layers/PlayerActionHandler.ts @@ -27,7 +27,8 @@ export class PlayerActionHandler { this.eventBus.emit( new SendAttackIntentEvent( targetId, - this.uiState.attackRatio * player.troops(), + this.uiState.attackRatio, + player.troops(), ), ); } @@ -36,7 +37,8 @@ export class PlayerActionHandler { this.eventBus.emit( new SendBoatAttackIntentEvent( targetTile, - this.uiState.attackRatio * player.troops(), + this.uiState.attackRatio, + player.troops(), ), ); } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 68bd7f7241..8f23f987ad 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -359,7 +359,10 @@ export const AllianceExtensionIntentSchema = z.object({ export const AttackIntentSchema = z.object({ type: z.literal("attack"), targetID: ID.nullable(), - troops: z.number().nonnegative().nullable(), + troopCount: z.number().nonnegative(), + troopRatio: z.number().gt(0).max(1), + // maxTroopSent present only for retaliation. + maxTroopSent: z.number().nonnegative().optional(), }); export const SpawnIntentSchema = z.object({ @@ -369,7 +372,8 @@ export const SpawnIntentSchema = z.object({ export const BoatAttackIntentSchema = z.object({ type: z.literal("boat"), - troops: z.number().nonnegative(), + troopCount: z.number().nonnegative(), + troopRatio: z.number().gt(0).max(1), dst: z.number(), }); diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index ccdb792d69..9e07538162 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -42,11 +42,57 @@ export class Executor { this.random = new PseudoRandom(simpleHash(gameID) + 1); } + private computeRatio( + remainingTroopRatio: number, + totalRatioUsage: number, + ): number { + return (1 - remainingTroopRatio) / totalRatioUsage; + } + createExecs(turn: Turn): Execution[] { - return turn.intents.map((i) => this.createExec(i)); + // In the rare case a client sends multiple troopRatio-orders, + // we need to "merge" their orders instead of executing them in parallel. + // (two 60% attacks should be one 84% attack, not one 120% attack) + // But, they may be of different types/on different targets + // (hence we do two (84/120)*60% = 42% attacks). + const remainingTroopRatio_perClientID = new Map(); + const totalRatioUsage_perClientID = new Map(); + for (const intent of turn.intents) { + switch (intent.type) { + case "boat": + case "attack": { + remainingTroopRatio_perClientID.set( + intent.clientID, + (remainingTroopRatio_perClientID.get(intent.clientID) ?? 1) * + (1 - intent.troopRatio), + ); + totalRatioUsage_perClientID.set( + intent.clientID, + (totalRatioUsage_perClientID.get(intent.clientID) ?? 0) + + intent.troopRatio, + ); + } + } + } + + return turn.intents.map((intent) => { + switch (intent.type) { + case "boat": + case "attack": + return this.createExec( + intent, + this.computeRatio( + remainingTroopRatio_perClientID.get(intent.clientID)!, + totalRatioUsage_perClientID.get(intent.clientID)!, + ), + ); + default: + return this.createExec(intent, undefined); + } + }); } - createExec(intent: StampedIntent): Execution { + createExec(intent: StampedIntent, troopRatioFactor?: number): Execution { const player = this.mg.playerByClientID(intent.clientID); if (!player) { console.warn(`player with clientID ${intent.clientID} not found`); @@ -57,7 +103,12 @@ export class Executor { switch (intent.type) { case "attack": { return new AttackExecution( - intent.troops, + Math.floor( + Math.min( + troopRatioFactor! * intent.troopRatio * intent.troopCount, + intent.maxTroopSent ?? intent.troopCount, + ), + ), player, intent.targetID, null, @@ -72,7 +123,11 @@ export class Executor { case "spawn": return new SpawnExecution(this.gameID, player.info(), intent.tile); case "boat": - return new TransportShipExecution(player, intent.dst, intent.troops); + return new TransportShipExecution( + player, + intent.dst, + Math.floor(troopRatioFactor! * intent.troopRatio * intent.troopCount), + ); case "allianceRequest": return new AllianceRequestExecution(player, intent.recipient); case "allianceReject": diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index da7ec9a2a4..98eecc5576 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -21,10 +21,6 @@ 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++) { @@ -110,7 +106,9 @@ describe("Attack", () => { constructionExecution(game, defender, 1, 1, UnitType.MissileSilo); expect(defender.units(UnitType.MissileSilo)).toHaveLength(1); - sendBoat(game.ref(15, 8), 100); + game.addExecution( + new TransportShipExecution(defender, game.ref(15, 8), 100), + ); constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3); const nuke = defender.units(UnitType.AtomBomb)[0]; @@ -129,7 +127,9 @@ describe("Attack", () => { const player_start_troops = defender.troops(); const boat_troops = player_start_troops * 0.5; - sendBoat(game.ref(15, 8), boat_troops); + game.addExecution( + new TransportShipExecution(defender, game.ref(15, 8), boat_troops), + ); game.executeNextTick(); diff --git a/tests/core/execution/ExecutionManager.test.ts b/tests/core/execution/ExecutionManager.test.ts new file mode 100644 index 0000000000..27af037ddd --- /dev/null +++ b/tests/core/execution/ExecutionManager.test.ts @@ -0,0 +1,134 @@ +import { TransportShipExecution } from "src/core/execution/TransportShipExecution"; +import { Game } from "../../..//src/core/game/Game"; +import { + ClientID, + GameID, + StampedIntent, + Turn, +} from "../../../src/core/Schemas"; +import { AttackExecution } from "../../../src/core/execution/AttackExecution"; +import { DeleteUnitExecution } from "../../../src/core/execution/DeleteUnitExecution"; +import { Executor } from "../../../src/core/execution/ExecutionManager"; +import { AllianceExtensionExecution } from "../../../src/core/execution/alliance/AllianceExtensionExecution"; + +describe("Executor", () => { + const game: Game = undefined as any; + let executor: Executor; + const gameID: GameID = "test_game"; + const clientID: ClientID = "test_client"; + const mockPlayer: any = 7; + + beforeEach(() => { + executor = new Executor(game, gameID, clientID); + }); + + test("createExecs merges attack-ratio-based intents from same client ID", () => { + // Mock the mg.playerByClientID method to not trigger early exit from the createExecs() function + (executor as any).mg = { + playerByClientID: (id: number) => mockPlayer, + }; + + const turn: Turn = { + turnNumber: 1, + intents: [ + { + type: "attack", + clientID: "client1", + troopRatio: 0.6, + troopCount: 100, + targetID: "target1", + }, + { + type: "delete_unit", + unitId: 1001, + }, + { + type: "allianceExtension", + clientID: "client3", + allianceID: "alliance1", + }, + { + type: "attack", + clientID: "client1", + troopRatio: 0.6, + troopCount: 100, + targetID: "target2", + }, + { + type: "attack", + clientID: "client2", + troopRatio: 0.9, + troopCount: 200, + targetID: "target2", + }, + { + type: "attack", + clientID: "client3", + troopRatio: 0.5, + troopCount: 1000, + targetID: "target1", + }, + { + type: "boat", + clientID: "client3", + troopRatio: 0.1, + troopCount: 1000, + targetID: "target2", + }, + { + type: "attack", + clientID: "client3", + troopRatio: 0.5, + troopCount: 1000, + targetID: "target3", + }, + ] as StampedIntent[], + }; + + const executions = executor.createExecs(turn); + expect(executions).toHaveLength(8); + expect(executions[0]).toBeInstanceOf(AttackExecution); + expect(executions[1]).toBeInstanceOf(DeleteUnitExecution); + expect(executions[2]).toBeInstanceOf(AllianceExtensionExecution); + expect(executions[3]).toBeInstanceOf(AttackExecution); + expect(executions[4]).toBeInstanceOf(AttackExecution); + expect(executions[5]).toBeInstanceOf(AttackExecution); + expect(executions[6]).toBeInstanceOf(TransportShipExecution); + expect(executions[7]).toBeInstanceOf(AttackExecution); + + // Mock the computeRatio method to previous, buggy, version. + (executor as any).computeRatio = (a: number, b: number) => 1; + const executionsBuggy = executor.createExecs(turn); + expect(executionsBuggy).toHaveLength(8); + + // We check that the non attack-ratio-based intents are the same. + expect(executionsBuggy[1]).toStrictEqual(executions[1]); + expect(executionsBuggy[2]).toStrictEqual(executions[2]); + expect(executionsBuggy[4]).toStrictEqual(executions[4]); + + // Total troops sent when buggy ratio is used is 0.6*100 + 0.6*100 = 120. + expect( + (executionsBuggy[0] as any).startTroops + + (executionsBuggy[3] as any).startTroops, + ).toBe(0.6 * 100 + 0.6 * 100); + + // The total should be equal to sequenced 60% attacks, meaning the first sends 60% of 100, + // and the second sends 60% of the remaining 40, which is 24. Total = 84. + // BUT the attacks are considered equals, ensuring that the total troops sent is 0.6*100 + 0.6*(100 - 0.6*100) = 84. + expect( + (executions[0] as any).startTroops + (executions[3] as any).startTroops, + ).toBe(0.6 * 100 + 0.6 * (100 - 0.6 * 100)); + + expect( + (executionsBuggy[5] as any).startTroops + + (executionsBuggy[6] as any).troops + + (executionsBuggy[7] as any).startTroops, + ).toBe(0.5 * 1000 + 0.1 * 1000 + 0.5 * 1000); + expect( + (executions[5] as any).startTroops + + (executions[6] as any).troops + + (executions[7] as any).startTroops, + // We remove one because of rounding + ).toBe(0.5 * 1000 + 0.5 * 500 + 0.1 * 250 - 1); + }); +});