Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 52 additions & 7 deletions src/core/execution/WinCheckExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export class WinCheckExecution implements Execution {
// maxGameDuration hard kill. 170mins (10 mins before 3hrs)
private static readonly HARD_TIME_LIMIT_SECONDS = 170 * 60;

// Grace period (in ticks) before declaring a winner due to disconnect
// in 1v1 ranked. 300 ticks = 30 seconds at 100ms/tick.
private static readonly DISCONNECT_GRACE_TICKS = 300;

// The tick at which we first detected only one connected human in 1v1.
// null means both players are currently connected (or grace not started).
private disconnectGraceTick: number | null = null;

constructor() {}

init(mg: Game, ticks: number) {
Expand Down Expand Up @@ -52,14 +60,51 @@ export class WinCheckExecution implements Execution {
}

if (this.mg.config().gameConfig().rankedType === RankedType.OneVOne) {
const humans = sorted.filter(
(p) => p.type() === PlayerType.Human && !p.isDisconnected(),
);
if (humans.length === 1) {
this.mg.setWinner(humans[0], this.mg.stats().stats());
console.log(`${humans[0].name()} has won the game`);
this.active = false;
const allHumans = sorted.filter((p) => p.type() === PlayerType.Human);
const connectedHumans = allHumans.filter((p) => !p.isDisconnected());

if (connectedHumans.length === 1 && allHumans.length === 2) {
// One player is disconnected — start or continue grace period
if (this.disconnectGraceTick === null) {
this.disconnectGraceTick = this.mg.ticks();
console.log(
`1v1 disconnect grace period started at tick ${this.disconnectGraceTick}`,
);
}

const elapsed = this.mg.ticks() - this.disconnectGraceTick;
if (elapsed >= WinCheckExecution.DISCONNECT_GRACE_TICKS) {
// Grace period expired — declare the connected player as winner
this.mg.setWinner(connectedHumans[0], this.mg.stats().stats());
console.log(
`${connectedHumans[0].name()} has won the game (opponent disconnected for ${elapsed} ticks)`,
);
this.active = false;
return;
}
// Still within grace period — wait for reconnect
return;
} else if (connectedHumans.length === 0 && allHumans.length === 2) {
// Both players disconnected — keep/start grace timer
this.disconnectGraceTick ??= this.mg.ticks();
const elapsed = this.mg.ticks() - this.disconnectGraceTick;
if (elapsed >= WinCheckExecution.DISCONNECT_GRACE_TICKS) {
// Both disconnected past grace — pick the one with more tiles
const winner = allHumans[0]; // already sorted by tiles desc
this.mg.setWinner(winner, this.mg.stats().stats());
console.log(
`${winner.name()} has won the game (both disconnected, most tiles)`,
);
this.active = false;
return;
}
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
// Both players are connected — reset grace timer
if (this.disconnectGraceTick !== null) {
console.log(`1v1 disconnect grace period reset (player reconnected)`);
this.disconnectGraceTick = null;
}
}
}

Expand Down
185 changes: 155 additions & 30 deletions tests/core/executions/WinCheckExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ describe("WinCheckExecution - Nation Winners", () => {
});

describe("WinCheckExecution - 1v1 Ranked Mode", () => {
test("should set winner when only one human remains connected", async () => {
test("should NOT set winner immediately when one human disconnects (grace period)", async () => {
// Setup game with 1v1 ranked mode and two human players
const game = await setup(
"big_plains",
Expand All @@ -373,8 +373,6 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => {
const human1 = game.player("Player1");
const human2 = game.player("Player2");

// Skip spawn phase

// Assign some territory to both players
let human1Count = 0;
let human2Count = 0;
Expand All @@ -396,18 +394,72 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => {
const setWinnerSpy = vi.fn();
game.setWinner = setWinnerSpy;

// Initialize and run win check
// Initialize and run win check — should NOT declare winner yet
const winCheck = new WinCheckExecution();
winCheck.init(game, 0);
winCheck.checkWinnerFFA();

// Verify the remaining connected human is declared winner
// Grace period just started — no winner yet
expect(setWinnerSpy).not.toHaveBeenCalled();
expect(winCheck.isActive()).toBe(true);
});

test("should set winner after grace period expires", async () => {
const game = await setup(
"big_plains",
{
infiniteGold: true,
gameMode: GameMode.FFA,
instantBuild: true,
rankedType: RankedType.OneVOne,
},
[
playerInfo("Player1", PlayerType.Human),
playerInfo("Player2", PlayerType.Human),
],
);

const human1 = game.player("Player1");
const human2 = game.player("Player2");

let human1Count = 0;
let human2Count = 0;
game.map().forEachTile((tile) => {
if (!game.map().isLand(tile)) return;
if (human1Count < 10) {
human1.conquer(tile);
human1Count++;
} else if (human2Count < 10) {
human2.conquer(tile);
human2Count++;
}
});

human2.markDisconnected(true);

const setWinnerSpy = vi.fn();
game.setWinner = setWinnerSpy;

const winCheck = new WinCheckExecution();
winCheck.init(game, 0);

// First call starts grace period
winCheck.checkWinnerFFA();
expect(setWinnerSpy).not.toHaveBeenCalled();

// Advance ticks past grace period (300 ticks)
game.endSpawnPhase();
for (let i = 0; i < 310; i++) {
game.executeNextTick();
}

// Now check again — grace period should have expired
winCheck.checkWinnerFFA();
expect(setWinnerSpy).toHaveBeenCalledWith(human1, expect.anything());
expect(winCheck.isActive()).toBe(false);
});

test("should not set winner when multiple humans are still connected", async () => {
// Setup game with 1v1 ranked mode and two human players
test("should reset grace period if disconnected player reconnects", async () => {
const game = await setup(
"big_plains",
{
Expand All @@ -425,9 +477,71 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => {
const human1 = game.player("Player1");
const human2 = game.player("Player2");

// Skip spawn phase
let human1Count = 0;
let human2Count = 0;
game.map().forEachTile((tile) => {
if (!game.map().isLand(tile)) return;
if (human1Count < 10) {
human1.conquer(tile);
human1Count++;
} else if (human2Count < 10) {
human2.conquer(tile);
human2Count++;
}
});

human2.markDisconnected(true);

const setWinnerSpy = vi.fn();
game.setWinner = setWinnerSpy;

const winCheck = new WinCheckExecution();
winCheck.init(game, 0);

// Start grace period
winCheck.checkWinnerFFA();
expect(setWinnerSpy).not.toHaveBeenCalled();

// Advance some ticks (but not past grace period)
game.endSpawnPhase();
for (let i = 0; i < 100; i++) {
game.executeNextTick();
}

// Player reconnects
human2.markDisconnected(false);
winCheck.checkWinnerFFA();

// Advance ticks past what would have been the grace period
for (let i = 0; i < 250; i++) {
game.executeNextTick();
}

winCheck.checkWinnerFFA();

// Should NOT have declared a winner because the player reconnected
expect(setWinnerSpy).not.toHaveBeenCalled();
expect(winCheck.isActive()).toBe(true);
});

test("should not set winner when multiple humans are still connected", async () => {
const game = await setup(
"big_plains",
{
infiniteGold: true,
gameMode: GameMode.FFA,
instantBuild: true,
rankedType: RankedType.OneVOne,
},
[
playerInfo("Player1", PlayerType.Human),
playerInfo("Player2", PlayerType.Human),
],
);

const human1 = game.player("Player1");
const human2 = game.player("Player2");

// Assign territory to both players
let human1Count = 0;
let human2Count = 0;
game.map().forEachTile((tile) => {
Expand All @@ -441,26 +555,21 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => {
}
});

// Both players remain connected
expect(human1.isDisconnected()).toBe(false);
expect(human2.isDisconnected()).toBe(false);

// Mock setWinner to capture calls
const setWinnerSpy = vi.fn();
game.setWinner = setWinnerSpy;

// Initialize and run win check
const winCheck = new WinCheckExecution();
winCheck.init(game, 0);
winCheck.checkWinnerFFA();

// Verify no winner declared yet (both players still connected)
expect(setWinnerSpy).not.toHaveBeenCalled();
expect(winCheck.isActive()).toBe(true);
});

test("should not set winner when no humans remain connected", async () => {
// Setup game with 1v1 ranked mode and two human players
test("should not set winner immediately when both players disconnect, but should after grace", async () => {
const game = await setup(
"big_plains",
{
Expand All @@ -478,28 +587,48 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => {
const human1 = game.player("Player1");
const human2 = game.player("Player2");

// Skip spawn phase
// Give both players tiles so both are alive (isAlive requires tiles > 0)
// human1 gets more tiles so winner is deterministic
let h1Count = 0;
let h2Count = 0;
game.map().forEachTile((tile) => {
if (!game.map().isLand(tile)) return;
if (h1Count < 10) {
human1.conquer(tile);
h1Count++;
} else if (h2Count < 5) {
human2.conquer(tile);
h2Count++;
}
});

// Both players disconnect
human1.markDisconnected(true);
human2.markDisconnected(true);

// Mock setWinner to capture calls
const setWinnerSpy = vi.fn();
game.setWinner = setWinnerSpy;

// Initialize and run win check
const winCheck = new WinCheckExecution();
winCheck.init(game, 0);
winCheck.checkWinnerFFA();

// Verify no winner declared (no connected humans)
// Grace timer should have started — no immediate winner
expect(setWinnerSpy).not.toHaveBeenCalled();
expect(winCheck.isActive()).toBe(true);

// Advance past grace period (300 ticks)
game.endSpawnPhase();
for (let i = 0; i < 310; i++) {
game.executeNextTick();
}

// Now check again — grace period expired, winner by tiles
winCheck.checkWinnerFFA();
expect(setWinnerSpy).toHaveBeenCalledWith(human1, expect.anything());
expect(winCheck.isActive()).toBe(false);
});

test("should ignore bots and nations in 1v1 ranked mode", async () => {
// Setup game with 1v1 ranked mode, one human, one bot, and one nation
test("should ignore bots and nations in 1v1 ranked mode (only 1 human = no opponent)", async () => {
const game = await setup(
"big_plains",
{
Expand All @@ -519,9 +648,6 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => {
const bot = game.player("BotPlayer");
const nation = game.player("NationPlayer");

// Skip spawn phase

// Assign territory to all players
let humanCount = 0;
let botCount = 0;
let nationCount = 0;
Expand All @@ -539,17 +665,16 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => {
}
});

// Mock setWinner to capture calls
const setWinnerSpy = vi.fn();
game.setWinner = setWinnerSpy;

// Initialize and run win check
const winCheck = new WinCheckExecution();
winCheck.init(game, 0);
winCheck.checkWinnerFFA();

// Verify human is declared winner (only one human player)
expect(setWinnerSpy).toHaveBeenCalledWith(human, expect.anything());
expect(winCheck.isActive()).toBe(false);
// Only 1 human total (allHumans.length !== 2), so 1v1 disconnect logic
// does NOT apply. Falls through to normal FFA win check.
// Whether winner is set depends on tile % threshold (won't be met with 10 tiles).
expect(winCheck.isActive()).toBe(true);
});
});
Loading