release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212

Merged
developer merged 79 commits from development into master 2026-07-07 14:40:43 +00:00
3 changed files with 45 additions and 10 deletions
Showing only changes of commit 80cc2a76e8 - Show all commits
+9 -1
View File
@@ -1107,7 +1107,15 @@
if (!view) return '';
const me = view.game.seats[view.seat];
if (me?.isWinner) return t('game.won');
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
if (view.game.endReason === 'aborted') return t('game.tied'); // an abort is a draw for everyone
const myScore = me?.score ?? 0;
// A declared winner (incl. by resignation) that is not me, or anyone who scored higher → a loss.
if (view.game.seats.some((s) => s.isWinner) || view.game.seats.some((s) => !s.resigned && s.score > myScore)) {
return t('game.lost');
}
// No one above me and no declared winner: a tie for the lead is a (shared) win; only an
// all-level finish is a draw.
return view.game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore) ? t('game.tied') : t('game.won');
}
// The finished-game export offers two formats — the GCG file and the server-rendered PNG
+12
View File
@@ -75,6 +75,18 @@ describe('resultBadge', () => {
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
});
it('tie for the lead (3-4p): a shared victory for the leaders, a placed finish for those below', () => {
// The reported case, viewer-side: two seats tie for the lead (-8), one is last (-14) — no single
// winner(), so this must NOT read as a full draw for everyone.
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.victory', emoji: '🏆' });
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.place3', emoji: '🥉' });
});
it('an aborted game reads as a draw for everyone, whatever the scores', () => {
const g = { ...game([seat(0, 'me', 50), seat(1, 'a', 30)]), endReason: 'aborted' };
expect(resultBadge(g, 'me')).toEqual({ key: 'result.draw', emoji: '🏅' });
});
});
describe('seatMedal', () => {
+24 -9
View File
@@ -9,6 +9,13 @@ export interface ResultBadge {
emoji: string;
}
/** placeBadge maps a 1-based finishing rank to its label + medal (2nd is a defeat in a duel). */
function placeBadge(rank: number, players: number): ResultBadge {
if (rank === 2) return players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
return { key: 'result.place4', emoji: '🏅' };
}
export function resultBadge(game: GameView, myId: string): ResultBadge {
const me = game.seats.find((s) => s.accountId === myId);
@@ -19,16 +26,24 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
}
if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' };
if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' };
// Someone else won and it is not me, so I did not win — even when scores are level (a
// win by resignation or timeout can leave the winner at or below my score). The winner
// takes rank 1; place me among the remaining seats by score, starting at rank 2.
const ahead = game.seats.filter((s) => !s.isWinner && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
const rank = 2 + ahead;
if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
return { key: 'result.place4', emoji: '🏅' };
// No single winner: a tie for the lead, a full draw, or an aborted game. An abort or an all-level
// finish is a draw; a tie for the lead is a SHARED victory for the top scorers, and a placed finish
// for those below. (winner() is -1 on ANY tie for the lead, so isWinner alone cannot tell a
// three-way "two share first, one is last" apart from a genuine draw — the reported bug.)
if (!game.seats.some((s) => s.isWinner)) {
const myScore = me?.score ?? 0;
const higher = game.seats.filter((s) => !s.resigned && s.score > myScore).length;
const allLevel = game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore);
if (game.endReason === 'aborted' || (higher === 0 && allLevel)) return { key: 'result.draw', emoji: '🏅' };
return higher === 0 ? { key: 'result.victory', emoji: '🏆' } : placeBadge(1 + higher, game.players);
}
// A winner exists but it is not me — even when scores are level (a win by resignation or timeout
// can leave the winner at or below my score). The winner takes rank 1; place me among the remaining
// (non-resigned) seats by score, starting at rank 2.
const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
return placeBadge(2 + ahead, game.players);
}
/**