Fix widow/play bugs, exit-anywhere; rework leaderboard + add game history

- Widow: preserve card selection across re-renders (no more deselecting)
- Play: reject a second card during trick resolution (fixes hand-count desync)
- Leave: exit available during bidding/widow/trump; a mid-game leave hands the
  seat to AI so the game continues; rooms with no humans are torn down
- Leaderboard: one-time flush + schema migration, win rate as the primary
  ranking metric, per-team shelem and per-seat bid tracking, avg score + bids won
- History: per-game round-by-round records persisted to history.json, a history
  screen, and a vertical round breakdown (with totals) shown to all players at
  hand/game end; result held for 15s

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-07-05 12:29:16 +00:00
parent e0b9dde93e
commit 7c602b8e95
5 changed files with 452 additions and 41 deletions
+181 -24
View File
@@ -34,9 +34,10 @@ const HTTPS_PORT = parseInt(process.env.HTTPS_PORT || '4443');
const DATA_DIR = path.join(__dirname, 'data');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
const USERS_FILE = path.join(DATA_DIR, 'users.json');
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
const HISTORY_FILE = path.join(DATA_DIR, 'history.json');
function readJSON(file, def) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return def; }
@@ -51,9 +52,25 @@ let users = readJSON(USERS_FILE, []);
let stats = readJSON(STATS_FILE, []);
let config = readJSON(CONFIG_FILE, { signupsOpen: true });
function saveUsers() { writeJSON(USERS_FILE, users); }
function saveStats() { writeJSON(STATS_FILE, stats); }
function saveConfig() { writeJSON(CONFIG_FILE, config); }
let history = readJSON(HISTORY_FILE, []);
function saveUsers() { writeJSON(USERS_FILE, users); }
function saveStats() { writeJSON(STATS_FILE, stats); }
function saveConfig() { writeJSON(CONFIG_FILE, config); }
function saveHistory() { writeJSON(HISTORY_FILE, history); }
// ─── Stats schema migration ───────────────────────────────────
// Bumping STATS_SCHEMA_VERSION flushes the leaderboard exactly once when a
// container running the new code starts for the first time. This is how an
// already-deployed instance "activates the patch" after pulling a new image.
const STATS_SCHEMA_VERSION = 2;
if ((config.statsSchemaVersion || 0) < STATS_SCHEMA_VERSION) {
stats = [];
saveStats();
config.statsSchemaVersion = STATS_SCHEMA_VERSION;
saveConfig();
console.log(`Leaderboard flushed and migrated to stats schema v${STATS_SCHEMA_VERSION}`);
}
function isAdmin(user) {
return ADMIN_USERNAME && user && user.username === ADMIN_USERNAME;
@@ -78,7 +95,7 @@ function findUser(username) {
function getStats(userId) {
let s = stats.find(s => s.userId === userId);
if (!s) {
s = { userId, games_played: 0, games_won: 0, shelemCount: 0, total_score: 0 };
s = { userId, games_played: 0, games_won: 0, shelemCount: 0, bids_won: 0, total_score: 0 };
stats.push(s);
saveStats();
}
@@ -90,6 +107,7 @@ function addStats(userId, delta) {
s.games_played = (s.games_played || 0) + (delta.games_played || 0);
s.games_won = (s.games_won || 0) + (delta.games_won || 0);
s.shelemCount = (s.shelemCount || 0) + (delta.shelemCount || 0);
s.bids_won = (s.bids_won || 0) + (delta.bids_won || 0);
s.total_score = (s.total_score || 0) + (delta.total_score || 0);
saveStats();
}
@@ -176,12 +194,39 @@ app.get('/api/profile/:username', (req, res) => {
games_played: s.games_played || 0,
games_won: s.games_won || 0,
shelemCount: s.shelemCount || 0,
bids_won: s.bids_won || 0,
total_score: s.total_score || 0,
win_rate: (s.games_played || 0) > 0 ? +(((s.games_won || 0) / s.games_played) * 100).toFixed(1) : 0,
avg_score: (s.games_played || 0) > 0 ? +(((s.total_score || 0) / s.games_played)).toFixed(1) : 0,
isAdmin: isAdmin(user),
signupsOpen: config.signupsOpen,
});
});
app.get('/api/history/:username', (req, res) => {
const user = findUser(req.params.username);
if (!user) return res.status(404).json({ error: 'User not found' });
const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id;
const games = history
.filter(g => g.players?.some(p => p.uid === effectiveId))
.sort((a, b) => b.endedAt - a.endedAt)
.map(g => {
const me = g.players.find(p => p.uid === effectiveId);
const myTeam = me ? me.team : -1;
return {
id: g.id,
endedAt: g.endedAt,
teamNames: g.teamNames,
finalScores: g.finalScores,
winnerTeams: g.winnerTeams,
won: myTeam >= 0 && g.winnerTeams.includes(myTeam),
myTeam,
rounds: g.rounds,
};
});
res.json(games);
});
app.get('/api/leaderboard', (_req, res) => {
const allUsers = [...users];
if (SHARED_USERS_FILE) {
@@ -194,24 +239,28 @@ app.get('/api/leaderboard', (_req, res) => {
} catch { /* ignore */ }
}
const rows = allUsers.map(u => {
const eid = u._fromShared ? `hokm_${u.id}` : u.id;
const s = getStats(eid);
const eid = u._fromShared ? `hokm_${u.id}` : u.id;
const s = getStats(eid);
const played = s.games_played || 0;
const won = s.games_won || 0;
return {
username: u.username,
games_played: played,
games_won: s.games_won || 0,
games_won: won,
shelemCount: s.shelemCount || 0,
bids_won: s.bids_won || 0,
total_score: s.total_score || 0,
score_per_game: played > 0 ? +((s.total_score || 0) / played).toFixed(1) : null,
// Win rate is the primary ranking metric (percentage of games won)
win_rate: played > 0 ? +((won / played) * 100).toFixed(1) : null,
avg_score: played > 0 ? +((s.total_score || 0) / played).toFixed(1) : null,
};
})
.filter(r => r.games_played > 0)
.sort((a, b) => {
if (a.score_per_game === null) return 1;
if (b.score_per_game === null) return -1;
return b.score_per_game - a.score_per_game || b.games_played - a.games_played;
})
.sort((a, b) =>
(b.win_rate - a.win_rate) || // primary: win rate
(b.avg_score - a.avg_score) || // tie-break: average score
(b.games_played - a.games_played)
)
.slice(0, 30);
res.json(rows);
});
@@ -386,6 +435,9 @@ function newRoom(id) {
handDeltas: null,
isShelemHand: false,
gameShelemsCount: 0,
shelemsByTeam: [0, 0], // shelems achieved per team this game
bidsWonBySeat: [0, 0, 0, 0], // hands where each seat was the declarer
roundHistory: [], // per-hand record for the end-of-game breakdown
// Accumulated game scores (per team)
scores: [0, 0],
gameWinner: null,
@@ -440,6 +492,11 @@ function publicInfo(room, seat) {
winScore: room.winScore,
isPublic: room.isPublic,
spectatorCount: room.spectators.size,
roundHistory: room.roundHistory,
teamNames: [
[room.names[0], room.names[2]].filter(Boolean).join(' & ') || 'Team 1',
[room.names[1], room.names[3]].filter(Boolean).join(' & ') || 'Team 2',
],
};
}
@@ -608,7 +665,11 @@ function onCardPlayed(room, player, card) {
return;
}
// Trick complete
// Trick complete — no one is "on turn" during the resolution window. This
// prevents the player who completed the trick from sneaking a second card in
// before the trick-advance timer fires (which would desync hand counts).
room.currentTurn = -1;
const winner = trickWinner(room.trick, room.trump);
const winnerTeam = teamOf(winner);
const trickPts = room.trick.reduce((s, t) => s + cardPoints(t.card), 0);
@@ -656,6 +717,7 @@ function finishHand(room) {
oDelta = 0;
room.isShelemHand = true;
room.gameShelemsCount++;
room.shelemsByTeam[dTeam]++;
} else if (dPts >= room.highBid) {
// Made the bid — score exactly what was bid, not actual points earned
dDelta = room.highBid;
@@ -666,12 +728,29 @@ function finishHand(room) {
oDelta = oPts;
}
// The declarer won this hand's bidding
if (room.declarer >= 0) room.bidsWonBySeat[room.declarer]++;
room.handDeltas = [0, 0];
room.handDeltas[dTeam] = dDelta;
room.handDeltas[oTeam] = oDelta;
room.scores[0] += room.handDeltas[0];
room.scores[1] += room.handDeltas[1];
// Record this round for the end-of-game / history breakdown
room.roundHistory.push({
hand: room.roundHistory.length + 1,
declarer: room.declarer,
declarerName: room.names[room.declarer] || '',
declarerTeam: dTeam,
bid: room.highBid,
trump: room.trump,
deltas: [room.handDeltas[0], room.handDeltas[1]],
scores: [room.scores[0], room.scores[1]],
shelem: room.isShelemHand,
made: dDelta > 0,
});
if (room.scores.some(s => s >= room.winScore)) {
finishGame(room);
return;
@@ -686,7 +765,7 @@ function finishHand(room) {
room.dealer = (room.dealer + 1) % 4; // counter-clockwise rotation
dealHand(room);
startBidding(room);
}, 4500);
}, 15000); // show the round result + running scores for 15s before the next hand
}
function finishGame(room) {
@@ -698,6 +777,8 @@ function finishGame(room) {
room.state = 'GAME_OVER';
broadcastState(room, 'gameOver');
// Games involving bots (incl. seats a human abandoned mid-game) are not
// ranked and are not written to history, to keep the leaderboard clean.
if (room.bots.some(Boolean)) return;
const winnerTeams = new Set(room.gameWinner);
@@ -708,10 +789,35 @@ function finishGame(room) {
addStats(uid, {
games_played: 1,
games_won: winnerTeams.has(team) ? 1 : 0,
shelemCount: room.gameShelemsCount,
shelemCount: room.shelemsByTeam[team], // only this player's team's shelems
bids_won: room.bidsWonBySeat[seat], // hands this player declared
total_score: room.scores[team],
});
}
saveGameHistory(room, winnerTeams);
}
// Persist a finished game so each participant can review it later.
function saveGameHistory(room, winnerTeams) {
const record = {
id: `${room.id}-${Date.now()}`,
endedAt: Date.now(),
winScore: room.winScore,
jokerMode: room.jokerMode,
finalScores: [room.scores[0], room.scores[1]],
winnerTeams: [...winnerTeams],
teamNames: [
[room.names[0], room.names[2]].filter(Boolean).join(' & ') || 'Team 1',
[room.names[1], room.names[3]].filter(Boolean).join(' & ') || 'Team 2',
],
players: room.userIds
.map((uid, seat) => ({ uid, seat, name: room.names[seat], team: teamOf(seat) }))
.filter(p => p.uid != null),
rounds: room.roundHistory,
};
history.push(record);
saveHistory();
}
// ─── AFK timer ────────────────────────────────────────────────
@@ -773,10 +879,38 @@ function scheduleAfkTimer(room) {
}, 60000);
}
// ─── Room teardown / leave recovery ───────────────────────────
function destroyRoom(room) {
if (room.trickTimer) clearTimeout(room.trickTimer);
if (room.afkTimer) clearTimeout(room.afkTimer);
rooms.delete(room.id);
}
// After a human leaves an in-progress game their seat becomes a bot; nudge the
// current phase so that bot acts if the game is now waiting on that seat.
function resumeAfterLeave(room, seat) {
if (room.state === 'BIDDING') {
scheduleBotBid(room);
} else if (room.state === 'WIDOW') {
if (room.declarer === seat && room.bots[seat]) {
setTimeout(() => botDiscard(room, seat), 900);
}
} else if (room.state === 'PLAYING') {
scheduleAfkTimer(room);
scheduleBotPlay(room);
}
}
// ─── Game init ────────────────────────────────────────────────
function tryStartGame(room) {
const filled = room.seats.map((s, i) => !!s || room.bots[i]);
if (!filled.every(Boolean)) return;
// Reset game-level accumulators for a fresh game
room.scores = [0, 0];
room.gameShelemsCount = 0;
room.shelemsByTeam = [0, 0];
room.bidsWonBySeat = [0, 0, 0, 0];
room.roundHistory = [];
dealHand(room);
startBidding(room);
}
@@ -1111,6 +1245,9 @@ io.on('connection', (socket) => {
if (!room) return;
if (room.state !== 'PLAYING') return;
if (room.tokens[seat] !== token) return;
// Reject if a trick is already complete and waiting to resolve, or if it is
// not this seat's live turn — guards against a double card being injected.
if (room.trick.length >= 4) return socket.emit('playError', 'Trick resolving');
if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn');
if (!legalCards(room, seat).includes(card)) return socket.emit('playError', 'Illegal card');
// Player acted themselves — release AI control
@@ -1151,12 +1288,32 @@ io.on('connection', (socket) => {
socket.on('leave', ({ roomId, seat, token } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
if (!room) return;
if (room.tokens[seat] === token) {
room.seats[seat] = null;
room.tokens[seat] = null;
room.userIds[seat] = null; // prevent hasActiveGame from pulling them back in
}
if (room.tokens[seat] !== token) { socket.leave(room.id); return; }
socket.leave(room.id);
// Vacate the seat
room.seats[seat] = null;
room.tokens[seat] = null;
room.userIds[seat] = null; // prevent hasActiveGame from pulling them back in
room.aiControlledSeats.delete(seat);
const inProgress = room.state !== 'WAITING' && room.state !== 'GAME_OVER';
// If no humans remain, tear the room down entirely
if (!room.seats.some(Boolean) && room.spectators.size === 0) {
destroyRoom(room);
return;
}
if (inProgress) {
// Hand the seat to AI so the game keeps going for everyone else
room.bots[seat] = true;
broadcastState(room, 'roomInfo');
resumeAfterLeave(room, seat);
} else {
broadcastState(room, 'roomInfo');
}
});
socket.on('disconnect', () => {