fix(offline): hotseat finished-game medals by final score (fix all-last-place on a tie)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
Reported: a hotseat game ended by 6 scoreless passes deducts each rack (correct rule, same as online: -8/-14/-8 here), which tied two seats for the lead. The engine's winner() returns -1 on a tie, so seatMedal read it as a full draw and gave EVERY seat the last-place medal. - result.ts: seatMedal now ranks by the FINAL score (competition ranking — a tie for the lead shares the trophy), not the single-winner flag. A resigned / host-excluded seat places last with no medal and does not push the others down. - model.ts: Seat.resigned (offline-only); engine.resignedOf getter; the local source surfaces it on the game view. - The scoreless rack deduction is unchanged (standard rules, owner-confirmed).
This commit is contained in:
@@ -240,6 +240,10 @@ export class LocalGame {
|
|||||||
handOf(player: number): number[] {
|
handOf(player: number): number[] {
|
||||||
return [...this.hands[player]];
|
return [...this.hands[player]];
|
||||||
}
|
}
|
||||||
|
/** resignedOf reports whether a seat has resigned or been excluded by the host. */
|
||||||
|
resignedOf(player: number): boolean {
|
||||||
|
return this.resigned[player] ?? false;
|
||||||
|
}
|
||||||
/** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */
|
/** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */
|
||||||
get winnerIndex(): number {
|
get winnerIndex(): number {
|
||||||
return winner(this.over, this.reason, this.scores, this.resigned);
|
return winner(this.over, this.reason, this.scores, this.resigned);
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ describe('LocalSource hotseat', () => {
|
|||||||
expect((st as StateView).game.status).toBe('finished');
|
expect((st as StateView).game.status).toBe('finished');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes a host-excluded seat as resigned in the view (for the medal ranking)', async () => {
|
||||||
|
const src = await hotseat('local:hR', [
|
||||||
|
{ kind: 'human', name: 'A' },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
{ kind: 'human', name: 'C' },
|
||||||
|
], '4321');
|
||||||
|
await src.hostAction('local:hR', '4321', 'resign', 1); // exclude seat 1 (game continues: 2 active)
|
||||||
|
const st = await src.gameState('local:hR');
|
||||||
|
expect(st.game.seats[1].resigned).toBe(true);
|
||||||
|
expect(st.game.seats[0].resigned).toBe(false);
|
||||||
|
expect(st.game.seats[2].resigned).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('lets the host terminate the game, deleting it', async () => {
|
it('lets the host terminate the game, deleting it', async () => {
|
||||||
const src = await hotseat('local:h6', [
|
const src = await hotseat('local:h6', [
|
||||||
{ kind: 'human', name: 'A' },
|
{ kind: 'human', name: 'A' },
|
||||||
|
|||||||
@@ -464,6 +464,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
score: game.scoreOf(i),
|
score: game.scoreOf(i),
|
||||||
hintsUsed: 0,
|
hintsUsed: 0,
|
||||||
isWinner: game.isOver && i === game.winnerIndex,
|
isWinner: game.isOver && i === game.winnerIndex,
|
||||||
|
resigned: game.resignedOf(i),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface Seat {
|
|||||||
score: number;
|
score: number;
|
||||||
hintsUsed: number;
|
hintsUsed: number;
|
||||||
isWinner: boolean;
|
isWinner: boolean;
|
||||||
|
/** Offline hotseat only: the seat resigned or was excluded by the host. Set by the local source so
|
||||||
|
* the finished-game medal ranking can place it last (no medal); undefined for online seats. */
|
||||||
|
resigned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameView {
|
export interface GameView {
|
||||||
|
|||||||
+23
-18
@@ -2,13 +2,14 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { resultBadge, seatMedal } from './result';
|
import { resultBadge, seatMedal } from './result';
|
||||||
import type { GameView, Seat } from './model';
|
import type { GameView, Seat } from './model';
|
||||||
|
|
||||||
const seat = (s: number, accountId: string, score: number, isWinner = false): Seat => ({
|
const seat = (s: number, accountId: string, score: number, isWinner = false, resigned = false): Seat => ({
|
||||||
seat: s,
|
seat: s,
|
||||||
accountId,
|
accountId,
|
||||||
displayName: accountId,
|
displayName: accountId,
|
||||||
score,
|
score,
|
||||||
hintsUsed: 0,
|
hintsUsed: 0,
|
||||||
isWinner,
|
isWinner,
|
||||||
|
resigned,
|
||||||
});
|
});
|
||||||
|
|
||||||
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
||||||
@@ -81,23 +82,27 @@ describe('seatMedal', () => {
|
|||||||
expect(seatMedal(game([seat(0, 'a', 5), seat(1, 'b', 3)], 'active'), 0)).toBe('');
|
expect(seatMedal(game([seat(0, 'a', 5), seat(1, 'b', 3)], 'active'), 0)).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('finished two-player: winner gets the trophy, the other second place', () => {
|
it('finished: places each seat by final score', () => {
|
||||||
const g = game([seat(0, 'a', 300, true), seat(1, 'b', 200)]);
|
|
||||||
expect(seatMedal(g, 0)).toBe('🏆');
|
|
||||||
expect(seatMedal(g, 1)).toBe('🥈');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finished draw (no winner): every seat gets the medal', () => {
|
|
||||||
const g = game([seat(0, 'a', 200), seat(1, 'b', 200)]);
|
|
||||||
expect(seatMedal(g, 0)).toBe('🏅');
|
|
||||||
expect(seatMedal(g, 1)).toBe('🏅');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finished four-player: places each seat by score', () => {
|
|
||||||
const g = game([seat(0, 'a', 100), seat(1, 'b', 400, true), seat(2, 'c', 300), seat(3, 'd', 200)]);
|
const g = game([seat(0, 'a', 100), seat(1, 'b', 400, true), seat(2, 'c', 300), seat(3, 'd', 200)]);
|
||||||
expect(seatMedal(g, 1)).toBe('🏆'); // winner
|
expect(seatMedal(g, 1)).toBe('🏆'); // 400 → 1st
|
||||||
expect(seatMedal(g, 2)).toBe('🥈'); // 2nd by score
|
expect(seatMedal(g, 2)).toBe('🥈'); // 300 → 2nd
|
||||||
expect(seatMedal(g, 3)).toBe('🥉'); // 3rd
|
expect(seatMedal(g, 3)).toBe('🥉'); // 200 → 3rd
|
||||||
expect(seatMedal(g, 0)).toBe('🏅'); // 4th
|
expect(seatMedal(g, 0)).toBe('🏅'); // 100 → 4th
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tie for the lead shares first place; the rest place below (competition ranking)', () => {
|
||||||
|
// The owner's case: a scoreless end deducted racks to -8 / -14 / -8, tying seats 0 and 2 for the
|
||||||
|
// lead — they must SHARE the trophy, not all show the last-place medal (the reported bug).
|
||||||
|
const g = game([seat(0, 'a', -8), seat(1, 'b', -14), seat(2, 'c', -8)]);
|
||||||
|
expect(seatMedal(g, 0)).toBe('🏆');
|
||||||
|
expect(seatMedal(g, 2)).toBe('🏆');
|
||||||
|
expect(seatMedal(g, 1)).toBe('🥉'); // two seats rank above it → 3rd
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an excluded / resigned seat gets no medal and does not push the others down', () => {
|
||||||
|
const g = game([seat(0, 'a', 50), seat(1, 'b', 900, false, true), seat(2, 'c', 30)]);
|
||||||
|
expect(seatMedal(g, 1)).toBe(''); // resigned: no medal, though its frozen score is the highest
|
||||||
|
expect(seatMedal(g, 0)).toBe('🏆'); // ranked among the rest: highest → 1st
|
||||||
|
expect(seatMedal(g, 2)).toBe('🥈');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,17 +34,16 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
|
|||||||
/**
|
/**
|
||||||
* seatMedal returns a per-SEAT place emoji for a finished game (empty while it is still in progress),
|
* seatMedal returns a per-SEAT place emoji for a finished game (empty while it is still in progress),
|
||||||
* so a hotseat game can show each player's medal on their plaque instead of a single viewer-centric
|
* so a hotseat game can show each player's medal on their plaque instead of a single viewer-centric
|
||||||
* "you won/lost" (which is meaningless with 2-4 local players). The winner takes the trophy; the
|
* "you won/lost" (which is meaningless with 2-4 local players). It ranks by the FINAL score with
|
||||||
* rest place by score (a draw — no
|
* competition ranking (equal scores share a place), so a tie for the lead SHARES the trophy rather
|
||||||
* winner — gives every seat the medal). Mirrors the ranking in resultBadge, keyed by seat not viewer.
|
* than reading as a full draw. A resigned / host-excluded seat places last, with no medal, and does
|
||||||
|
* not push the remaining players down. Ranked by score, not by the engine's single-winner flag
|
||||||
|
* (which is -1 on any tie for the lead — the reported all-last-place bug).
|
||||||
*/
|
*/
|
||||||
export function seatMedal(game: GameView, seat: number): string {
|
export function seatMedal(game: GameView, seat: number): string {
|
||||||
if (game.status !== 'finished') return '';
|
if (game.status !== 'finished') return '';
|
||||||
const s = game.seats.find((x) => x.seat === seat);
|
const s = game.seats.find((x) => x.seat === seat);
|
||||||
if (!s) return '';
|
if (!s || s.resigned) return '';
|
||||||
if (s.isWinner) return '🏆';
|
const rank = 1 + game.seats.filter((x) => !x.resigned && x.score > s.score).length;
|
||||||
if (!game.seats.some((x) => x.isWinner)) return '🏅'; // a draw: no winner
|
return rank === 1 ? '🏆' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '🏅';
|
||||||
const ahead = game.seats.filter((x) => !x.isWinner && x.seat !== seat && x.score > s.score).length;
|
|
||||||
const rank = 2 + ahead;
|
|
||||||
return rank === 2 ? '🥈' : rank === 3 ? '🥉' : '🏅';
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user