diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 977759e9cf..79b9283c1e 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -984,7 +984,8 @@ export class ClientGameRunner { this.eventBus.emit( new SendAttackIntentEvent( this.gameView.owner(tile).id(), - this.myPlayer!.troops() * this.renderer.uiState.attackRatio, + Math.floor(Math.max(100 * this.renderer.uiState.attackRatio, 1)), + this.myPlayer!.troops(), ), ); } else if (this.canAutoBoat(actions.buildableUnits, tile)) { @@ -1143,7 +1144,8 @@ export class ClientGameRunner { this.eventBus.emit( new SendAttackIntentEvent( this.gameView.owner(tile).id(), - this.myPlayer!.troops() * this.renderer.uiState.attackRatio, + Math.floor(Math.max(100 * this.renderer.uiState.attackRatio, 1)), + this.myPlayer!.troops(), ), ); } @@ -1177,12 +1179,22 @@ export class ClientGameRunner { mostRecentAttack.attackerID, ) as PlayerView; if (!attacker) return; - - const counterTroops = Math.min( - mostRecentAttack.troops, - this.renderer.uiState.attackRatio * this.myPlayer.troops(), + const myTroops = this.myPlayer.troops(); + const maxAttackRatio = + myTroops > 0 ? mostRecentAttack.troops / myTroops : 1; + this.eventBus.emit( + new SendAttackIntentEvent( + attacker.id(), + Math.ceil( + // Ensures the attackRatio is between 1% and the required percentage to defend fully. + Math.max( + 100 * Math.min(this.renderer.uiState.attackRatio, maxAttackRatio), + 1, + ), + ), + myTroops, + ), ); - this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops)); } private doRequestAllianceUnderCursor(): void { @@ -1267,7 +1279,8 @@ export class ClientGameRunner { this.eventBus.emit( new SendBoatAttackIntentEvent( tile, - this.myPlayer.troops() * this.renderer.uiState.attackRatio, + Math.floor(Math.max(100 * this.renderer.uiState.attackRatio, 1)), + this.myPlayer.troops(), ), ); } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index c1307af699..264a1648fa 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -71,14 +71,16 @@ 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, ) {} } export class SendBoatAttackIntentEvent implements GameEvent { constructor( public readonly dst: TileRef, - public readonly troops: number, + public readonly troopRatio: number, + public readonly troopCount: number, ) {} } @@ -484,14 +486,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..20e6ff66ed 100644 --- a/src/client/hud/layers/AttacksDisplay.ts +++ b/src/client/hud/layers/AttacksDisplay.ts @@ -200,12 +200,18 @@ 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(), + const myTroops = myPlayer.troops(); + const maxAttackRatio = myTroops > 0 ? attack.troops / myTroops : 1; + this.eventBus.emit( + new SendAttackIntentEvent( + attacker.id(), + Math.ceil( + // Ensures the attackRatio is between 1% and the required percentage to defend fully. + Math.max(100 * Math.min(this.uiState.attackRatio, maxAttackRatio), 1), + ), + myPlayer.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..ef4c894ad3 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(), + Math.max(Math.floor(100 * this.uiState.attackRatio), 1), + player.troops(), ), ); } @@ -36,7 +37,8 @@ export class PlayerActionHandler { this.eventBus.emit( new SendBoatAttackIntentEvent( targetTile, - this.uiState.attackRatio * player.troops(), + Math.max(Math.floor(100 * this.uiState.attackRatio), 1), + player.troops(), ), ); } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index eeccfd94ce..11f6aed8b3 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -365,7 +365,8 @@ 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().int().nonnegative(), + troopRatio: z.number().int().gt(0).max(100), }); export const SpawnIntentSchema = z.object({ @@ -375,7 +376,8 @@ export const SpawnIntentSchema = z.object({ export const BoatAttackIntentSchema = z.object({ type: z.literal("boat"), - troops: z.number().nonnegative(), + troopCount: z.number().int().nonnegative(), + troopRatio: z.number().int().gt(0).max(100), dst: z.number(), }); diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index ccdb792d69..8dcfc914c3 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -42,11 +42,76 @@ export class Executor { this.random = new PseudoRandom(simpleHash(gameID) + 1); } + private computeRatio( + counterTroopRatio: number, + remainingTroopRatio: number, + totalRatioUsage: number, + ): number { + const factor = 100 ** (counterTroopRatio - 1); + return Math.floor( + (100 * (100 * factor - remainingTroopRatio)) / factor / 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 counterTroopRatio_perClientID = new Map(); + const remainingTroopRatio_perClientID = new Map(); + const totalRatioUsage_perClientID = new Map(); + for (const intent of turn.intents) { + switch (intent.type) { + case "boat": + case "attack": { + counterTroopRatio_perClientID.set( + intent.clientID, + (counterTroopRatio_perClientID.get(intent.clientID) ?? 0) + 1, + ); + remainingTroopRatio_perClientID.set( + intent.clientID, + (remainingTroopRatio_perClientID.get(intent.clientID) ?? 1) * + (100 - 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( + counterTroopRatio_perClientID.get(intent.clientID)!, + remainingTroopRatio_perClientID.get(intent.clientID)!, + totalRatioUsage_perClientID.get(intent.clientID)!, + ), + ); + default: + return this.createExec(intent, undefined); + } + }); } - createExec(intent: StampedIntent): Execution { + private applyTroopFactor( + troopRatioFactor: number, + intent: { troopRatio: number; troopCount: number }, + ): number { + return Math.floor( + (troopRatioFactor * intent.troopRatio * intent.troopCount) / 10000, + ); + } + + createExec(intent: StampedIntent, troopRatioFactor = 100): Execution { const player = this.mg.playerByClientID(intent.clientID); if (!player) { console.warn(`player with clientID ${intent.clientID} not found`); @@ -57,7 +122,7 @@ export class Executor { switch (intent.type) { case "attack": { return new AttackExecution( - intent.troops, + this.applyTroopFactor(troopRatioFactor, intent), player, intent.targetID, null, @@ -72,7 +137,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, + this.applyTroopFactor(troopRatioFactor, intent), + ); case "allianceRequest": return new AllianceRequestExecution(player, intent.recipient); case "allianceReject": diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 781f04704b..315dd088bc 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -22,10 +22,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++) { @@ -111,7 +107,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]; @@ -144,7 +142,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..1df9507a1b --- /dev/null +++ b/tests/core/execution/ExecutionManager.test.ts @@ -0,0 +1,133 @@ +import { TransportShipExecution } from "src/core/execution/TransportShipExecution"; +import { Game } from "../../..//src/core/game/Game"; +import { ClientID, GameID, 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"; +import { setup } from "../../util/Setup"; + +describe("Executor", () => { + let game: Game; + let executor: Executor; + const gameID: GameID = "test_game"; + const clientID: ClientID = "test_client"; + const mockPlayer: any = 7; + + beforeEach(async () => { + game = await setup("plains", {}); + 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: 60, + troopCount: 100, + targetID: "target1", + }, + { + type: "delete_unit", + clientID: "client3", + unitId: 1001, + }, + { + type: "allianceExtension", + clientID: "client3", + recipient: "alliance1", + }, + { + type: "attack", + clientID: "client1", + troopRatio: 60, + troopCount: 100, + targetID: "target2", + }, + { + type: "attack", + clientID: "client2", + troopRatio: 90, + troopCount: 200, + targetID: "target2", + }, + { + type: "attack", + clientID: "client3", + troopRatio: 50, + troopCount: 1000, + targetID: "target1", + }, + { + type: "boat", + clientID: "client3", + troopRatio: 10, + troopCount: 1000, + dst: 42, + }, + { + type: "attack", + clientID: "client3", + troopRatio: 50, + troopCount: 1000, + targetID: "target3", + }, + ], + }; + + 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) => 100; + 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 + 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, + // Factor is (1.0-(0.4*0.4))/1.2 = 0.70 + ).toBe(0.7 * (0.6 + 0.6) * 100); + + expect( + (executionsBuggy[5] as any).startTroops + + (executionsBuggy[6] as any).troops + + (executionsBuggy[7] as any).startTroops, + ).toBe((0.5 + 0.1 + 0.5) * 1000); + expect( + (executions[5] as any).startTroops + + (executions[6] as any).troops + + (executions[7] as any).startTroops, + // Factor is (1.0-(0.5*0.5*0.9))/1.1 = 0.704545... BUT 0.70 because of rounding + ).toBe(0.7 * (0.5 + 0.1 + 0.5) * 1000); + }); +});