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 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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"; From 7186d7700bbafb4f25e291891709a22406f5be10 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 03:17:46 +0200 Subject: [PATCH 08/11] Follow changes from CodeRabbit (and fixes a bug that would have been introduced) --- src/client/Transport.ts | 1 + tests/core/execution/ExecutionManager.test.ts | 20 +++++++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 044bc927c2..7680f79059 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -489,6 +489,7 @@ export class Transport { targetID: event.targetID, troopRatio: event.troopRatio, troopCount: event.troopCount, + maxTroopSent: event.maxTroopCount ?? undefined, }); } diff --git a/tests/core/execution/ExecutionManager.test.ts b/tests/core/execution/ExecutionManager.test.ts index 27af037ddd..fa2d6c15b3 100644 --- a/tests/core/execution/ExecutionManager.test.ts +++ b/tests/core/execution/ExecutionManager.test.ts @@ -1,18 +1,14 @@ import { TransportShipExecution } from "src/core/execution/TransportShipExecution"; import { Game } from "../../..//src/core/game/Game"; -import { - ClientID, - GameID, - StampedIntent, - Turn, -} from "../../../src/core/Schemas"; +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", () => { - const game: Game = undefined as any; + let game: Game; let executor: Executor; const gameID: GameID = "test_game"; const clientID: ClientID = "test_client"; @@ -20,6 +16,9 @@ describe("Executor", () => { beforeEach(() => { executor = new Executor(game, gameID, clientID); + beforeEach(async () => { + game = await setup("plains", {}); + }); }); test("createExecs merges attack-ratio-based intents from same client ID", () => { @@ -40,12 +39,13 @@ describe("Executor", () => { }, { type: "delete_unit", + clientID: "client3", unitId: 1001, }, { type: "allianceExtension", clientID: "client3", - allianceID: "alliance1", + recipient: "alliance1", }, { type: "attack", @@ -73,7 +73,7 @@ describe("Executor", () => { clientID: "client3", troopRatio: 0.1, troopCount: 1000, - targetID: "target2", + dst: 42, }, { type: "attack", @@ -82,7 +82,7 @@ describe("Executor", () => { troopCount: 1000, targetID: "target3", }, - ] as StampedIntent[], + ], }; const executions = executor.createExecs(turn); From 6319ee0eefe12924bb90b60a98c8ad72e5a66faa 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 17:09:34 +0200 Subject: [PATCH 09/11] Applies advices from codeRabbit, mostly switching to integer-based computation (for deterministic behavior) and removing the maxTroop value for retalition, because capping the troopRatio is more versatile during aggregation --- src/client/ClientGameRunner.ts | 17 +++++++--- src/client/Transport.ts | 1 - src/client/hud/layers/AttacksDisplay.ts | 13 ++++++-- src/client/hud/layers/PlayerActionHandler.ts | 4 +-- src/core/Schemas.ts | 10 +++--- src/core/execution/ExecutionManager.ts | 25 ++++++++++----- tests/core/execution/ExecutionManager.test.ts | 31 +++++++++---------- 7 files changed, 62 insertions(+), 39 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 1a7df2240a..c555eeaed4 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -943,7 +943,7 @@ export class ClientGameRunner { this.eventBus.emit( new SendAttackIntentEvent( this.gameView.owner(tile).id(), - this.renderer.uiState.attackRatio, + Math.floor(100 * this.renderer.uiState.attackRatio), this.myPlayer!.troops(), ), ); @@ -1103,7 +1103,7 @@ export class ClientGameRunner { this.eventBus.emit( new SendAttackIntentEvent( this.gameView.owner(tile).id(), - this.renderer.uiState.attackRatio, + Math.floor(100 * this.renderer.uiState.attackRatio), this.myPlayer!.troops(), ), ); @@ -1141,9 +1141,18 @@ export class ClientGameRunner { this.eventBus.emit( new SendAttackIntentEvent( attacker.id(), - this.renderer.uiState.attackRatio, + Math.floor( + // Ensures the attackRatio is between 1% and the required percentage to defend fully. + Math.max( + 100 * + Math.min( + this.renderer.uiState.attackRatio, + mostRecentAttack.troops / this.myPlayer.troops(), + ), + 1, + ), + ), this.myPlayer.troops(), - mostRecentAttack.troops, ), ); } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 7680f79059..044bc927c2 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -489,7 +489,6 @@ export class Transport { targetID: event.targetID, troopRatio: event.troopRatio, troopCount: event.troopCount, - maxTroopSent: event.maxTroopCount ?? undefined, }); } diff --git a/src/client/hud/layers/AttacksDisplay.ts b/src/client/hud/layers/AttacksDisplay.ts index ee86b6dff0..05d77355fd 100644 --- a/src/client/hud/layers/AttacksDisplay.ts +++ b/src/client/hud/layers/AttacksDisplay.ts @@ -203,9 +203,18 @@ export class AttacksDisplay extends LitElement implements Controller { this.eventBus.emit( new SendAttackIntentEvent( attacker.id(), - this.uiState.attackRatio, + Math.floor( + // Ensures the attackRatio is between 1% and the required percentage to defend fully. + Math.max( + 100 * + Math.min( + this.uiState.attackRatio, + attack.troops / myPlayer.troops(), + ), + 1, + ), + ), myPlayer.troops(), - attack.troops, ), ); } diff --git a/src/client/hud/layers/PlayerActionHandler.ts b/src/client/hud/layers/PlayerActionHandler.ts index d771f84cee..2fbe70a486 100644 --- a/src/client/hud/layers/PlayerActionHandler.ts +++ b/src/client/hud/layers/PlayerActionHandler.ts @@ -27,7 +27,7 @@ export class PlayerActionHandler { this.eventBus.emit( new SendAttackIntentEvent( targetId, - this.uiState.attackRatio, + Math.floor(100 * this.uiState.attackRatio), player.troops(), ), ); @@ -37,7 +37,7 @@ export class PlayerActionHandler { this.eventBus.emit( new SendBoatAttackIntentEvent( targetTile, - this.uiState.attackRatio, + Math.floor(100 * this.uiState.attackRatio), player.troops(), ), ); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 8f23f987ad..8e28a0358b 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -359,10 +359,8 @@ export const AllianceExtensionIntentSchema = z.object({ export const AttackIntentSchema = z.object({ type: z.literal("attack"), targetID: ID.nullable(), - troopCount: z.number().nonnegative(), - troopRatio: z.number().gt(0).max(1), - // maxTroopSent present only for retaliation. - maxTroopSent: z.number().nonnegative().optional(), + troopCount: z.number().int().nonnegative(), + troopRatio: z.number().int().gt(0).max(100), }); export const SpawnIntentSchema = z.object({ @@ -372,8 +370,8 @@ export const SpawnIntentSchema = z.object({ export const BoatAttackIntentSchema = z.object({ type: z.literal("boat"), - troopCount: z.number().nonnegative(), - troopRatio: z.number().gt(0).max(1), + 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 9e07538162..f508a95a96 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -43,10 +43,14 @@ export class Executor { } private computeRatio( + counterTroopRatio: number, remainingTroopRatio: number, totalRatioUsage: number, ): number { - return (1 - remainingTroopRatio) / totalRatioUsage; + const factor = 100 ** (counterTroopRatio - 1); + return Math.floor( + (100 * (100 * factor - remainingTroopRatio)) / factor / totalRatioUsage, + ); } createExecs(turn: Turn): Execution[] { @@ -55,16 +59,21 @@ 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). + 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) * - (1 - intent.troopRatio), + (100 - intent.troopRatio), ); totalRatioUsage_perClientID.set( intent.clientID, @@ -82,6 +91,7 @@ export class Executor { return this.createExec( intent, this.computeRatio( + counterTroopRatio_perClientID.get(intent.clientID)!, remainingTroopRatio_perClientID.get(intent.clientID)!, totalRatioUsage_perClientID.get(intent.clientID)!, ), @@ -92,7 +102,7 @@ export class Executor { }); } - createExec(intent: StampedIntent, troopRatioFactor?: number): Execution { + createExec(intent: StampedIntent, troopRatioFactor = 100): Execution { const player = this.mg.playerByClientID(intent.clientID); if (!player) { console.warn(`player with clientID ${intent.clientID} not found`); @@ -104,10 +114,7 @@ export class Executor { case "attack": { return new AttackExecution( Math.floor( - Math.min( - troopRatioFactor! * intent.troopRatio * intent.troopCount, - intent.maxTroopSent ?? intent.troopCount, - ), + (troopRatioFactor * intent.troopRatio * intent.troopCount) / 10000, ), player, intent.targetID, @@ -126,7 +133,9 @@ export class Executor { return new TransportShipExecution( player, intent.dst, - Math.floor(troopRatioFactor! * intent.troopRatio * intent.troopCount), + Math.floor( + (troopRatioFactor * intent.troopRatio * intent.troopCount) / 10000, + ), ); case "allianceRequest": return new AllianceRequestExecution(player, intent.recipient); diff --git a/tests/core/execution/ExecutionManager.test.ts b/tests/core/execution/ExecutionManager.test.ts index fa2d6c15b3..1df9507a1b 100644 --- a/tests/core/execution/ExecutionManager.test.ts +++ b/tests/core/execution/ExecutionManager.test.ts @@ -14,11 +14,9 @@ describe("Executor", () => { const clientID: ClientID = "test_client"; const mockPlayer: any = 7; - beforeEach(() => { + beforeEach(async () => { + game = await setup("plains", {}); executor = new Executor(game, gameID, clientID); - beforeEach(async () => { - game = await setup("plains", {}); - }); }); test("createExecs merges attack-ratio-based intents from same client ID", () => { @@ -33,7 +31,7 @@ describe("Executor", () => { { type: "attack", clientID: "client1", - troopRatio: 0.6, + troopRatio: 60, troopCount: 100, targetID: "target1", }, @@ -50,35 +48,35 @@ describe("Executor", () => { { type: "attack", clientID: "client1", - troopRatio: 0.6, + troopRatio: 60, troopCount: 100, targetID: "target2", }, { type: "attack", clientID: "client2", - troopRatio: 0.9, + troopRatio: 90, troopCount: 200, targetID: "target2", }, { type: "attack", clientID: "client3", - troopRatio: 0.5, + troopRatio: 50, troopCount: 1000, targetID: "target1", }, { type: "boat", clientID: "client3", - troopRatio: 0.1, + troopRatio: 10, troopCount: 1000, dst: 42, }, { type: "attack", clientID: "client3", - troopRatio: 0.5, + troopRatio: 50, troopCount: 1000, targetID: "target3", }, @@ -97,7 +95,7 @@ describe("Executor", () => { expect(executions[7]).toBeInstanceOf(AttackExecution); // Mock the computeRatio method to previous, buggy, version. - (executor as any).computeRatio = (a: number, b: number) => 1; + (executor as any).computeRatio = (a: number, b: number) => 100; const executionsBuggy = executor.createExecs(turn); expect(executionsBuggy).toHaveLength(8); @@ -110,25 +108,26 @@ describe("Executor", () => { expect( (executionsBuggy[0] as any).startTroops + (executionsBuggy[3] as any).startTroops, - ).toBe(0.6 * 100 + 0.6 * 100); + ).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, - ).toBe(0.6 * 100 + 0.6 * (100 - 0.6 * 100)); + // 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 * 1000 + 0.1 * 1000 + 0.5 * 1000); + ).toBe((0.5 + 0.1 + 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); + // 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); }); }); From 97201e7a9a42bf7b8f9d4b01cb7bf95338514949 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 17:24:27 +0200 Subject: [PATCH 10/11] Small fixes making things uniform --- src/client/ClientGameRunner.ts | 2 +- src/client/Transport.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index c555eeaed4..f5cd42a773 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -1239,7 +1239,7 @@ export class ClientGameRunner { this.eventBus.emit( new SendBoatAttackIntentEvent( tile, - this.renderer.uiState.attackRatio, + Math.floor(100 * this.renderer.uiState.attackRatio), this.myPlayer.troops(), ), ); diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 044bc927c2..264a1648fa 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -73,7 +73,6 @@ export class SendAttackIntentEvent implements GameEvent { public readonly targetID: PlayerID | null, public readonly troopRatio: number, public readonly troopCount: number, - public readonly maxTroopCount: number | null = null, ) {} } From 7425af5d32c62156b7b41105d8306d8ebef78004 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 18:28:52 +0200 Subject: [PATCH 11/11] Ceils instead of flooring when retaliation - make sure there will be an attack --- src/client/ClientGameRunner.ts | 2 +- src/client/hud/layers/AttacksDisplay.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index f5cd42a773..5f1dfb1c4d 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -1141,7 +1141,7 @@ export class ClientGameRunner { this.eventBus.emit( new SendAttackIntentEvent( attacker.id(), - Math.floor( + Math.ceil( // Ensures the attackRatio is between 1% and the required percentage to defend fully. Math.max( 100 * diff --git a/src/client/hud/layers/AttacksDisplay.ts b/src/client/hud/layers/AttacksDisplay.ts index 05d77355fd..f0167605a5 100644 --- a/src/client/hud/layers/AttacksDisplay.ts +++ b/src/client/hud/layers/AttacksDisplay.ts @@ -203,7 +203,7 @@ export class AttacksDisplay extends LitElement implements Controller { this.eventBus.emit( new SendAttackIntentEvent( attacker.id(), - Math.floor( + Math.ceil( // Ensures the attackRatio is between 1% and the required percentage to defend fully. Math.max( 100 *