feat(offline): local pass-and-play (hotseat) — 2-4 players, seat/host PINs #211
@@ -20,6 +20,7 @@
|
|||||||
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||||
import { lastMoveCells, replay } from '../lib/board';
|
import { lastMoveCells, replay } from '../lib/board';
|
||||||
import { badgeKind } from '../lib/unread';
|
import { badgeKind } from '../lib/unread';
|
||||||
|
import { seatMedal } from '../lib/result';
|
||||||
import { historyGrid } from '../lib/history';
|
import { historyGrid } from '../lib/history';
|
||||||
import { centre, premiumGrid } from '../lib/premiums';
|
import { centre, premiumGrid } from '../lib/premiums';
|
||||||
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||||
@@ -1496,7 +1497,7 @@
|
|||||||
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
||||||
{#each view.game.seats as s (s.seat)}
|
{#each view.game.seats as s (s.seat)}
|
||||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||||
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div>
|
<div class="nm" class:struck={seatBlocked(s)}>{#if gameOver && isLocalGameId(id)}<span class="medal" aria-hidden="true">{seatMedal(view.game, s.seat)}</span>{/if}{seatName(s)}</div>
|
||||||
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
|
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
|
||||||
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
|
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -1622,7 +1623,9 @@
|
|||||||
<div class="status">
|
<div class="status">
|
||||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<strong class="over">{resultText()}</strong>
|
<!-- A local (offline) game shows its result as per-seat medals on the plaques (below), not a
|
||||||
|
viewer-centric "you won/lost" — the outcome is not always about a single "you". -->
|
||||||
|
{#if !isLocalGameId(id)}<strong class="over">{resultText()}</strong>{/if}
|
||||||
{:else if placement.pending.length === 0}
|
{:else if placement.pending.length === 0}
|
||||||
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1865,6 +1868,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
/* The finished-game place medal on a local (offline) seat plaque, left of the name. */
|
||||||
|
.medal {
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
.sc {
|
.sc {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { resultBadge } 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): Seat => ({
|
||||||
@@ -75,3 +75,29 @@ describe('resultBadge', () => {
|
|||||||
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
|
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('seatMedal', () => {
|
||||||
|
it('is empty for a game still in progress', () => {
|
||||||
|
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', () => {
|
||||||
|
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)]);
|
||||||
|
expect(seatMedal(g, 1)).toBe('🏆'); // winner
|
||||||
|
expect(seatMedal(g, 2)).toBe('🥈'); // 2nd by score
|
||||||
|
expect(seatMedal(g, 3)).toBe('🥉'); // 3rd
|
||||||
|
expect(seatMedal(g, 0)).toBe('🏅'); // 4th
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,3 +30,20 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
|
|||||||
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
|
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
|
||||||
return { key: 'result.place4', emoji: '🏅' };
|
return { key: 'result.place4', emoji: '🏅' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* seatMedal returns a per-SEAT place emoji for a finished game (empty while it is still in progress),
|
||||||
|
* so a local (offline) game can show each player's medal on their plaque instead of a single
|
||||||
|
* viewer-centric "you won/lost". The winner takes the trophy; the rest place by score (a draw — no
|
||||||
|
* winner — gives every seat the medal). Mirrors the ranking in resultBadge, keyed by seat not viewer.
|
||||||
|
*/
|
||||||
|
export function seatMedal(game: GameView, seat: number): string {
|
||||||
|
if (game.status !== 'finished') return '';
|
||||||
|
const s = game.seats.find((x) => x.seat === seat);
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.isWinner) return '🏆';
|
||||||
|
if (!game.seats.some((x) => x.isWinner)) return '🏅'; // a draw: no winner
|
||||||
|
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 ? '🥉' : '🏅';
|
||||||
|
}
|
||||||
|
|||||||
@@ -353,9 +353,11 @@
|
|||||||
>{/each}</span
|
>{/each}</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. -->
|
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. A
|
||||||
|
local (offline) game shows no lobby medal — its result lives on the in-game
|
||||||
|
seat plaques, and resultBadge is viewer-centric (no seat matches the account). -->
|
||||||
{#key blinkNonce.get(g.id) ?? 0}
|
{#key blinkNonce.get(g.id) ?? 0}
|
||||||
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span>
|
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{isLocalGameId(g.id) ? '' : resultBadge(g, myId).emoji}</span>
|
||||||
{/key}
|
{/key}
|
||||||
</button>
|
</button>
|
||||||
{#if group.finished || g.hotseat}
|
{#if group.finished || g.hotseat}
|
||||||
|
|||||||
Reference in New Issue
Block a user