Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f4c07cb
Makes tests self-contained
Katokoda Jun 17, 2026
799ee08
The execution Manager now checks for multiple troop-ratio-intents for…
Katokoda Jun 18, 2026
18ffe46
Return to previous donate-troop behavior
Katokoda Jun 19, 2026
4275211
Lowers quantity of maps check and separates the ratio computation in …
Katokoda Jun 23, 2026
100aa73
lint
Katokoda Jun 24, 2026
90f2375
Adds tests for the new Execution Manager Merging Behavior
Katokoda Jun 24, 2026
2ef3c05
format
Katokoda Jun 24, 2026
7186d77
Follow changes from CodeRabbit (and fixes a bug that would have been …
Katokoda Jun 24, 2026
6319ee0
Applies advices from codeRabbit, mostly switching to integer-based co…
Katokoda Jun 24, 2026
97201e7
Small fixes making things uniform
Katokoda Jun 24, 2026
7425af5
Ceils instead of flooring when retaliation - make sure there will be …
Katokoda Jun 24, 2026
b39102a
Merge branch 'main' into fix/double-tap-full-send
ryanbarlow97 Jun 24, 2026
4e76c08
Adds a guard for division per zero
Katokoda Jul 5, 2026
ee6235b
Merge branch 'fix/double-tap-full-send' of github.com:Katokoda/OpenFr…
Katokoda Jul 5, 2026
622a64c
Fixes formatting
Katokoda Jul 5, 2026
5ae4b7b
Clamp attackRatio
Katokoda Jul 5, 2026
fea0b1d
Regroups troop ratio factor into a private function
Katokoda Jul 5, 2026
2d54fb5
Add guard against division per zero
Katokoda Jul 5, 2026
5ad9c71
Adds guards against division per zero
Katokoda Jul 5, 2026
1e31109
Fix min instead of max, guarding against zero value
Katokoda Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if (this.canAutoBoat(actions.buildableUnits, tile)) {
Expand Down Expand Up @@ -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(),
),
);
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
),
);
}
Expand Down
12 changes: 8 additions & 4 deletions src/client/Transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}
}

Expand Down Expand Up @@ -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,
});
}
Expand Down
16 changes: 11 additions & 5 deletions src/client/hud/layers/AttacksDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
Comment thread
Katokoda marked this conversation as resolved.
this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops));
}

private renderIncomingAttacks() {
Expand Down
6 changes: 4 additions & 2 deletions src/client/hud/layers/PlayerActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
),
);
}
Expand All @@ -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(),
),
);
}
Expand Down
6 changes: 4 additions & 2 deletions src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(),
});

Expand Down
77 changes: 73 additions & 4 deletions src/core/execution/ExecutionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<ClientID, number>();
const remainingTroopRatio_perClientID = new Map<ClientID, number>();
const totalRatioUsage_perClientID = new Map<ClientID, number>();
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,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}

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`);
Expand All @@ -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,
Expand All @@ -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":
Expand Down
12 changes: 6 additions & 6 deletions tests/Attack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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();

Expand Down
Loading
Loading