2d1fadb50c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs. Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match. Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds. Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
65 lines
3.4 KiB
TypeScript
65 lines
3.4 KiB
TypeScript
// Pure mapping from a game view (for the viewer) to a status/result badge: a label key
|
|
// and a place-based emoji. Used by the lobby lists.
|
|
|
|
import type { GameView } from './model';
|
|
import type { MessageKey } from './i18n/catalog';
|
|
|
|
export interface ResultBadge {
|
|
key: MessageKey;
|
|
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, myIds: readonly string[]): ResultBadge {
|
|
const me = game.seats.find((s) => myIds.includes(s.accountId));
|
|
|
|
if (game.status === 'active' || game.status === 'open') {
|
|
return game.toMove === me?.seat
|
|
? { key: 'result.yourMove', emoji: '🟢' }
|
|
: { key: 'result.oppMove', emoji: '⏳' };
|
|
}
|
|
|
|
if (me?.isWinner) return { key: 'result.victory', 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 && !myIds.includes(s.accountId) && s.score > (me?.score ?? 0)).length;
|
|
return placeBadge(2 + ahead, game.players);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* "you won/lost" (which is meaningless with 2-4 local players). It ranks by the FINAL score with
|
|
* competition ranking (equal scores share a place), so a tie for the lead SHARES the trophy rather
|
|
* 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 {
|
|
if (game.status !== 'finished') return '';
|
|
const s = game.seats.find((x) => x.seat === seat);
|
|
if (!s || s.resigned) return '';
|
|
const rank = 1 + game.seats.filter((x) => !x.resigned && x.score > s.score).length;
|
|
return rank === 1 ? '🏆' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '🏅';
|
|
}
|