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
88 changes: 80 additions & 8 deletions src/core/execution/WinCheckExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ 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. 150 ticks = 15 seconds at 100ms/tick.
// Combined with the server's 30s ping-timeout detection, the total
// effective delay from actual disconnect to winner declaration is ~45s.
private static readonly DISCONNECT_GRACE_TICKS = 150;

// 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 +62,76 @@ 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;
return;
let numHumans = 0;
let numConnected = 0;
let firstHuman: Player | null = null;
let connectedHuman: Player | null = null;

for (const p of sorted) {
if (p.type() === PlayerType.Human) {
numHumans++;
firstHuman ??= p;
if (!p.isDisconnected()) {
numConnected++;
connectedHuman ??= p;
}
}
}

if (numHumans === 2) {
// Hard time limit guard -- even during a grace period the game
// must not exceed HARD_TIME_LIMIT_SECONDS (170 min).
const timeElapsed = this.mg.elapsedGameSeconds();
if (timeElapsed >= WinCheckExecution.HARD_TIME_LIMIT_SECONDS) {
const winner = connectedHuman ?? firstHuman!;
this.mg.setWinner(winner, this.mg.stats().stats());
console.log(
`${winner.name()} has won the game (hard time limit during 1v1 grace)`,
);
this.active = false;
return;
}

if (numConnected === 1) {
// 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) {
this.mg.setWinner(connectedHuman!, this.mg.stats().stats());
console.log(
`${connectedHuman!.name()} has won the game (opponent disconnected for ${elapsed} ticks)`,
);
this.active = false;
return;
}
return;
} else if (numConnected === 0) {
// Both disconnected -- keep/start grace timer
this.disconnectGraceTick ??= this.mg.ticks();
const elapsed = this.mg.ticks() - this.disconnectGraceTick;
if (elapsed >= WinCheckExecution.DISCONNECT_GRACE_TICKS) {
this.mg.setWinner(firstHuman!, this.mg.stats().stats());
console.log(
`${firstHuman!.name()} has won the game (both disconnected, most tiles)`,
);
this.active = false;
return;
}
return;
} else {
// Both connected -- reset grace timer
if (this.disconnectGraceTick !== null) {
console.log(
`1v1 disconnect grace period reset (player reconnected)`,
);
this.disconnectGraceTick = null;
}
}
}
}

Expand Down
6 changes: 2 additions & 4 deletions src/server/GameManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,9 @@ export class GameManager {
}

if (phase === GamePhase.Finished) {
try {
game.end();
} catch (error) {
game.end().catch((error: any) => {
this.log.error(`error ending game ${id}: ${error}`);
}
});
} else {
active.set(id, game);
}
Expand Down
34 changes: 31 additions & 3 deletions src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,9 @@ export class GameServer {
}
this._hasEnded = true;
}
} else {
// Check if remaining clients have reached a winner consensus
this.checkWinnerConsensus();
}
});
client.ws.on("error", (error: Error) => {
Expand Down Expand Up @@ -1187,8 +1190,8 @@ export class GameServer {
}
}

// If half clients out of sync assume all are out of sync.
if (outOfSyncClients.length >= Math.floor(this.activeClients.length / 2)) {
// If strict majority clients out of sync assume all are out of sync.
if (outOfSyncClients.length > Math.floor(this.activeClients.length / 2)) {
outOfSyncClients = this.activeClients;
}

Expand Down Expand Up @@ -1227,7 +1230,7 @@ export class GameServer {
},
);

if (potentialWinner.ips.size * 2 < activeUniqueIPs.size) {
if (potentialWinner.ips.size * 2 <= activeUniqueIPs.size) {
return;
}

Expand All @@ -1241,4 +1244,29 @@ export class GameServer {
);
this.archiveGame();
}

private checkWinnerConsensus() {
if (this.winner !== null) {
return;
}

const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip));
if (activeUniqueIPs.size === 0) {
return;
}

for (const [winnerKey, potentialWinner] of this.winnerVotes.entries()) {
if (potentialWinner.ips.size * 2 > activeUniqueIPs.size) {
this.winner = potentialWinner.winner;
this.log.info(
`Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs after client disconnect`,
{
winnerKey: winnerKey,
},
);
this.archiveGame();
return;
}
}
}
}
28 changes: 28 additions & 0 deletions src/server/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,23 @@ export async function startWorker() {

app.post("/api/archive_singleplayer_game", async (req, res) => {
try {
let persistentID: string;
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.substring("Bearer ".length);
const tokenResult = await verifyClientToken(token);
if (tokenResult.type === "success") {
persistentID = tokenResult.persistentId;
} else {
log.warn(
`Invalid token for archive_singleplayer_game: ${tokenResult.message}`,
);
return res.status(401).json({ error: "Invalid token" });
}
} else {
return res.status(401).json({ error: "Authorization header required" });
}

const record = req.body;

const result = PartialGameRecordSchema.safeParse(record);
Expand Down Expand Up @@ -260,6 +277,17 @@ export async function startWorker() {
return res.status(400).json({ error: "Invalid request" });
}

const player = result.data.info.players[0];
if (player.persistentID !== persistentID) {
log.warn("Authenticated user does not match record persistentID", {
tokenUser: persistentID,
recordUser: player.persistentID,
});
return res
.status(403)
.json({ error: "Unauthorized user for this record" });
}

log.info("archiving singleplayer game", {
gameID: gameRecord.info.gameID,
});
Expand Down
Loading
Loading