From f4c07cbd8caa8aee8a86e3dfdca1d9651902676a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20B=C3=A9lisle?= <79760461+Katokoda@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:58:16 +0200 Subject: [PATCH 1/7] Makes tests self-contained --- tests/Attack.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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(); From 799ee08e2300b566b44a7a57f501bb2d7828e87e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20B=C3=A9lisle?= <79760461+Katokoda@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:08:23 +0200 Subject: [PATCH 2/7] The execution Manager now checks for multiple troop-ratio-intents for any client, and merges their troop consumption instead of adding them. Required to convey separately troopRatio and troopCount for these Intents --- src/client/ClientGameRunner.ts | 21 +++++--- src/client/Transport.ts | 19 ++++--- src/client/hud/layers/AttacksDisplay.ts | 12 +++-- src/client/hud/layers/PlayerActionHandler.ts | 20 ++++--- src/client/hud/layers/RadialMenuElements.ts | 22 ++++---- src/core/Schemas.ts | 11 ++-- src/core/configuration/Config.ts | 4 +- src/core/execution/DonateTroopExecution.ts | 3 +- src/core/execution/ExecutionManager.ts | 55 ++++++++++++++++++-- 9 files changed, 119 insertions(+), 48 deletions(-) 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..fcadf33bbc 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, ) {} } @@ -111,7 +114,8 @@ export class SendDonateGoldIntentEvent implements GameEvent { export class SendDonateTroopsIntentEvent implements GameEvent { constructor( public readonly recipient: PlayerView, - public readonly troops: number | null, + public readonly troopRatio: number, + public readonly troopCount: number, ) {} } @@ -484,14 +488,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, }); } @@ -532,7 +538,8 @@ export class Transport { this.sendIntent({ type: "donate_troops", recipient: event.recipient.id(), - troops: event.troops, + troopRatio: event.troopRatio, + troopCount: event.troopCount, }); } 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..982cdc5340 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(), ), ); } @@ -74,12 +76,14 @@ export class PlayerActionHandler { this.eventBus.emit(new SendDonateGoldIntentEvent(recipient, null)); } - handleDonateTroops(recipient: PlayerView, troops?: number) { - const amount = troops ?? null; - if (amount !== null && amount <= 0) { - return; - } - this.eventBus.emit(new SendDonateTroopsIntentEvent(recipient, amount)); + handleDonateTroops( + recipient: PlayerView, + troopRatio: number, + troopsCount: number, + ) { + this.eventBus.emit( + new SendDonateTroopsIntentEvent(recipient, troopRatio, troopsCount), + ); } handleEmbargo(recipient: PlayerView, action: "start" | "stop") { diff --git a/src/client/hud/layers/RadialMenuElements.ts b/src/client/hud/layers/RadialMenuElements.ts index c27ff668df..f2e716ae1e 100644 --- a/src/client/hud/layers/RadialMenuElements.ts +++ b/src/client/hud/layers/RadialMenuElements.ts @@ -296,7 +296,11 @@ const allyDonateTroopsElement: MenuElement = { color: COLORS.ally, icon: donateTroopIcon, action: (params: MenuElementParams) => { - params.playerActionHandler.handleDonateTroops(params.selected!); + params.playerActionHandler.handleDonateTroops( + params.selected!, + params.game.config().defaultDonationRatio(), + params.myPlayer.troops(), + ); params.closeMenu(); }, }; @@ -626,14 +630,14 @@ export const centerButtonElement: CenterButtonElement = { } else { if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { const selectedPlayer = params.selected as PlayerView; - const ratio = params.uiState?.attackRatio ?? 1; - const troopsToDonate = Math.floor(ratio * params.myPlayer.troops()); - if (troopsToDonate > 0) { - params.playerActionHandler.handleDonateTroops( - selectedPlayer, - troopsToDonate, - ); - } + const ratio = + params.uiState?.attackRatio ?? + params.game.config().defaultDonationRatio(); + params.playerActionHandler.handleDonateTroops( + selectedPlayer, + ratio, + params.myPlayer.troops(), + ); } else { params.playerActionHandler.handleAttack( params.myPlayer, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 68bd7f7241..c8564f99a2 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(), }); @@ -419,7 +423,8 @@ export const DonateGoldIntentSchema = z.object({ export const DonateTroopIntentSchema = z.object({ type: z.literal("donate_troops"), recipient: ID, - troops: z.number().nonnegative().nullable(), + troopCount: z.number().nonnegative(), + troopRatio: z.number().gt(0).max(1), }); export const BuildUnitIntentSchema = z.object({ diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 16d7ebee62..b7c656e751 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -486,8 +486,8 @@ export class Config { }; } - defaultDonationAmount(sender: Player): number { - return Math.floor(sender.troops() / 3); + defaultDonationRatio(): number { + return 1 / 3; } donateCooldown(): Tick { return 10 * 10; diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index cf06a041c1..0d98e71d61 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -25,7 +25,7 @@ export class DonateTroopsExecution implements Execution { constructor( private sender: Player, private recipientID: PlayerID, - private troops: number | null, + private troops: number, ) {} init(mg: Game, ticks: number): void { @@ -41,7 +41,6 @@ export class DonateTroopsExecution implements Execution { } this.recipient = mg.player(this.recipientID); - this.troops ??= mg.config().defaultDonationAmount(this.sender); const maxDonation = mg.config().maxTroops(this.recipient) - this.recipient.troops(); this.troops = Math.min(this.troops, maxDonation); diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index ccdb792d69..bdd3937f54 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -43,10 +43,46 @@ export class Executor { } 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). + let remainingTroopRatio_perClientID = new Map(); + var totalRatioUsage_perClientID = new Map(); + for (const intent of turn.intents) { + switch (intent.type) { + case "boat": + case "attack": + case "donate_troops": { + 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, + ); + } + default: + break; + } + } + + return turn.intents.map((intent) => + this.createExec( + intent, + remainingTroopRatio_perClientID.has(intent.clientID) + ? (1 - remainingTroopRatio_perClientID.get(intent.clientID)!) / + totalRatioUsage_perClientID.get(intent.clientID)! + : 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 +93,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 +113,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": @@ -87,7 +132,7 @@ export class Executor { return new DonateTroopsExecution( player, intent.recipient, - intent.troops, + Math.floor(troopRatioFactor! * intent.troopRatio * intent.troopCount), ); case "donate_gold": return new DonateGoldExecution(player, intent.recipient, intent.gold); From 18ffe467fe0a8206175d0e3ca011ccfd7d50ba1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20B=C3=A9lisle?= <79760461+Katokoda@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:51:55 +0200 Subject: [PATCH 3/7] Return to previous donate-troop behavior --- src/client/Transport.ts | 6 ++---- src/client/hud/layers/PlayerActionHandler.ts | 14 ++++++------- src/client/hud/layers/RadialMenuElements.ts | 22 ++++++++------------ src/core/Schemas.ts | 3 +-- src/core/configuration/Config.ts | 4 ++-- src/core/execution/DonateTroopExecution.ts | 3 ++- src/core/execution/ExecutionManager.ts | 5 ++--- 7 files changed, 24 insertions(+), 33 deletions(-) diff --git a/src/client/Transport.ts b/src/client/Transport.ts index fcadf33bbc..044bc927c2 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -114,8 +114,7 @@ export class SendDonateGoldIntentEvent implements GameEvent { export class SendDonateTroopsIntentEvent implements GameEvent { constructor( public readonly recipient: PlayerView, - public readonly troopRatio: number, - public readonly troopCount: number, + public readonly troops: number | null, ) {} } @@ -538,8 +537,7 @@ export class Transport { this.sendIntent({ type: "donate_troops", recipient: event.recipient.id(), - troopRatio: event.troopRatio, - troopCount: event.troopCount, + troops: event.troops, }); } diff --git a/src/client/hud/layers/PlayerActionHandler.ts b/src/client/hud/layers/PlayerActionHandler.ts index 982cdc5340..d771f84cee 100644 --- a/src/client/hud/layers/PlayerActionHandler.ts +++ b/src/client/hud/layers/PlayerActionHandler.ts @@ -76,14 +76,12 @@ export class PlayerActionHandler { this.eventBus.emit(new SendDonateGoldIntentEvent(recipient, null)); } - handleDonateTroops( - recipient: PlayerView, - troopRatio: number, - troopsCount: number, - ) { - this.eventBus.emit( - new SendDonateTroopsIntentEvent(recipient, troopRatio, troopsCount), - ); + handleDonateTroops(recipient: PlayerView, troops?: number) { + const amount = troops ?? null; + if (amount !== null && amount <= 0) { + return; + } + this.eventBus.emit(new SendDonateTroopsIntentEvent(recipient, amount)); } handleEmbargo(recipient: PlayerView, action: "start" | "stop") { diff --git a/src/client/hud/layers/RadialMenuElements.ts b/src/client/hud/layers/RadialMenuElements.ts index f2e716ae1e..c27ff668df 100644 --- a/src/client/hud/layers/RadialMenuElements.ts +++ b/src/client/hud/layers/RadialMenuElements.ts @@ -296,11 +296,7 @@ const allyDonateTroopsElement: MenuElement = { color: COLORS.ally, icon: donateTroopIcon, action: (params: MenuElementParams) => { - params.playerActionHandler.handleDonateTroops( - params.selected!, - params.game.config().defaultDonationRatio(), - params.myPlayer.troops(), - ); + params.playerActionHandler.handleDonateTroops(params.selected!); params.closeMenu(); }, }; @@ -630,14 +626,14 @@ export const centerButtonElement: CenterButtonElement = { } else { if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { const selectedPlayer = params.selected as PlayerView; - const ratio = - params.uiState?.attackRatio ?? - params.game.config().defaultDonationRatio(); - params.playerActionHandler.handleDonateTroops( - selectedPlayer, - ratio, - params.myPlayer.troops(), - ); + const ratio = params.uiState?.attackRatio ?? 1; + const troopsToDonate = Math.floor(ratio * params.myPlayer.troops()); + if (troopsToDonate > 0) { + params.playerActionHandler.handleDonateTroops( + selectedPlayer, + troopsToDonate, + ); + } } else { params.playerActionHandler.handleAttack( params.myPlayer, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index c8564f99a2..8f23f987ad 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -423,8 +423,7 @@ export const DonateGoldIntentSchema = z.object({ export const DonateTroopIntentSchema = z.object({ type: z.literal("donate_troops"), recipient: ID, - troopCount: z.number().nonnegative(), - troopRatio: z.number().gt(0).max(1), + troops: z.number().nonnegative().nullable(), }); export const BuildUnitIntentSchema = z.object({ diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index b7c656e751..16d7ebee62 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -486,8 +486,8 @@ export class Config { }; } - defaultDonationRatio(): number { - return 1 / 3; + defaultDonationAmount(sender: Player): number { + return Math.floor(sender.troops() / 3); } donateCooldown(): Tick { return 10 * 10; diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index 0d98e71d61..cf06a041c1 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -25,7 +25,7 @@ export class DonateTroopsExecution implements Execution { constructor( private sender: Player, private recipientID: PlayerID, - private troops: number, + private troops: number | null, ) {} init(mg: Game, ticks: number): void { @@ -41,6 +41,7 @@ export class DonateTroopsExecution implements Execution { } this.recipient = mg.player(this.recipientID); + this.troops ??= mg.config().defaultDonationAmount(this.sender); const maxDonation = mg.config().maxTroops(this.recipient) - this.recipient.troops(); this.troops = Math.min(this.troops, maxDonation); diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index bdd3937f54..8f48738911 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -53,8 +53,7 @@ export class Executor { for (const intent of turn.intents) { switch (intent.type) { case "boat": - case "attack": - case "donate_troops": { + case "attack": { remainingTroopRatio_perClientID.set( intent.clientID, (remainingTroopRatio_perClientID.get(intent.clientID) ?? 1) * @@ -132,7 +131,7 @@ export class Executor { return new DonateTroopsExecution( player, intent.recipient, - Math.floor(troopRatioFactor! * intent.troopRatio * intent.troopCount), + intent.troops, ); case "donate_gold": return new DonateGoldExecution(player, intent.recipient, intent.gold); From 4275211b2cf68aa12a51e99a543290ae6a443ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20B=C3=A9lisle?= <79760461+Katokoda@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:47:03 +0200 Subject: [PATCH 4/7] Lowers quantity of maps check and separates the ratio computation in function --- src/core/execution/ExecutionManager.ts | 31 ++++++++++++++++++-------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 8f48738911..92de50934b 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -42,6 +42,13 @@ 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[] { // In the rare case a client sends multiple troopRatio-orders, // we need to "merge" their orders instead of executing them in parallel. @@ -70,15 +77,21 @@ export class Executor { } } - return turn.intents.map((intent) => - this.createExec( - intent, - remainingTroopRatio_perClientID.has(intent.clientID) - ? (1 - remainingTroopRatio_perClientID.get(intent.clientID)!) / - totalRatioUsage_perClientID.get(intent.clientID)! - : undefined, - ), - ); + 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, troopRatioFactor?: number): Execution { From 100aa73a8135e61fd7bdae057c078e188b9c4f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20B=C3=A9lisle?= <79760461+Katokoda@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:22:57 +0200 Subject: [PATCH 5/7] lint --- src/core/execution/ExecutionManager.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 92de50934b..9e07538162 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -55,8 +55,8 @@ export class Executor { // (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). - let remainingTroopRatio_perClientID = new Map(); - var totalRatioUsage_perClientID = new Map(); + const remainingTroopRatio_perClientID = new Map(); + const totalRatioUsage_perClientID = new Map(); for (const intent of turn.intents) { switch (intent.type) { case "boat": @@ -72,8 +72,6 @@ export class Executor { intent.troopRatio, ); } - default: - break; } } From 90f23754900c256f52be6f7c1f0478144de31064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20B=C3=A9lisle?= <79760461+Katokoda@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:23:42 +0200 Subject: [PATCH 6/7] Adds tests for the new Execution Manager Merging Behavior --- tests/core/execution/ExecutionManager.test.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tests/core/execution/ExecutionManager.test.ts diff --git a/tests/core/execution/ExecutionManager.test.ts b/tests/core/execution/ExecutionManager.test.ts new file mode 100644 index 0000000000..d3f7e80af6 --- /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); + }); +}); From 2ef3c0507de21d0f5be0771f029cbd4ca68e192a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20B=C3=A9lisle?= <79760461+Katokoda@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:26:31 +0200 Subject: [PATCH 7/7] format --- tests/core/execution/ExecutionManager.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/execution/ExecutionManager.test.ts b/tests/core/execution/ExecutionManager.test.ts index d3f7e80af6..27af037ddd 100644 --- a/tests/core/execution/ExecutionManager.test.ts +++ b/tests/core/execution/ExecutionManager.test.ts @@ -12,7 +12,7 @@ import { Executor } from "../../../src/core/execution/ExecutionManager"; import { AllianceExtensionExecution } from "../../../src/core/execution/alliance/AllianceExtensionExecution"; describe("Executor", () => { - const game: Game = (undefined as any); + const game: Game = undefined as any; let executor: Executor; const gameID: GameID = "test_game"; const clientID: ClientID = "test_client";