Stage 17 round 5 — backend/correctness bug fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Failing after 12s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped

- Resign on the opponent's turn: engine ResignSeat(seat) resigns a specific seat
  (not just toMove); game.Resign bypasses the turn check and forfeits the actor's seat.
- Quick-match cancel was a UI no-op (only stopped polling): add the full path
  (REST /lobby/cancel -> gateway lobby.cancel -> client) and clear the matchmaker's
  pending result on Cancel, so a cancelled search is dequeued (no 'already queued', no
  later robot-substituted game). NewGame dequeues on cancel and on abandon.
- Lobby win/loss: result.ts ranked by score, so a 0-0 resignation read as a win.
  The winner now takes rank 1 and the viewer is placed from rank 2 — matching the
  game-detail screen.
- Friend request to a robot: robots no longer block requests; the request stays
  pending and expires (friendRequestTTL), mirroring a human who ignores it.
- Nudge cooldown: ErrNudgeTooSoon now maps to a distinct nudge_too_soon code with a
  correct message; the chat nudge button disables during the hourly cooldown; the
  nudge note reads 'Waiting for your move!' (button keeps the Nudge action label).
Tests: engine/service off-turn resign, matchmaker cancel-clears-result, friend-to-robot
inttest, result.ts 0-0 resignation, nudge_too_soon mapping.
This commit is contained in:
Ilia Denisov
2026-06-07 09:17:35 +02:00
parent 3856b34f8a
commit 10412fee8e
23 changed files with 301 additions and 29 deletions
+1 -1
View File
@@ -51,7 +51,7 @@
onkeydown={(e) => e.key === 'Enter' && send()}
/>
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy || !canNudge} aria-label={t('chat.nudge')}>🛎️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy || !canNudge} aria-label={t('chat.nudgeAction')}>🛎️</button>
</div>
</div>
+22 -1
View File
@@ -89,6 +89,20 @@
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status !== 'active');
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
// Nudge cooldown (one per hour per game, mirrored from the backend): the control is
// disabled for an hour after the player's own last nudge. nudgeTick re-evaluates it on a
// timer while the chat is open, so it re-enables without waiting for a new message.
const nudgeCooldownSecs = 3600;
let nudgeTick = $state(0);
const nudgeOnCooldown = $derived.by(() => {
void nudgeTick;
const mine = app.session?.userId ?? '';
const last = messages.reduce(
(mx, m) => (m.kind === 'nudge' && m.senderId === mine ? Math.max(mx, m.createdAtUnix) : mx),
0,
);
return last > 0 && Date.now() / 1000 - last < nudgeCooldownSecs;
});
async function load() {
try {
@@ -145,6 +159,13 @@
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
});
// Tick the nudge cooldown while the chat is open so the control re-enables on time.
$effect(() => {
if (panel !== 'chat') return;
const h = setInterval(() => (nudgeTick += 1), 20000);
return () => clearInterval(h);
});
function isCoarse(): boolean {
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
}
@@ -708,7 +729,7 @@
{#if panel === 'chat'}
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
<Chat {messages} myId={app.session?.userId ?? ''} {busy} canNudge={!isMyTurn} onsend={sendChat} onnudge={nudge} />
<Chat {messages} myId={app.session?.userId ?? ''} {busy} canNudge={!isMyTurn && !nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
</Modal>
{/if}
+2
View File
@@ -65,6 +65,8 @@ export interface GatewayClient {
// --- lobby ---
lobbyEnqueue(variant: Variant): Promise<MatchResult>;
lobbyPoll(): Promise<MatchResult>;
/** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */
lobbyCancel(): Promise<void>;
// --- game ---
// Stage 13: the play loop exchanges alphabet indices, so submit/evaluate/exchange/
+3 -1
View File
@@ -94,7 +94,8 @@ export const en = {
'chat.placeholder': 'Quick message…',
'chat.send': 'Send',
'chat.nudge': 'Nudge',
'chat.nudge': 'Waiting for your move!',
'chat.nudgeAction': 'Nudge',
'chat.empty': 'No messages yet.',
'chat.nudged': '{name} nudged you',
@@ -155,6 +156,7 @@ export const en = {
'error.hint_unavailable': 'No hints available.',
'error.no_hint_available': 'No options with your letters.',
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.nudge_too_soon': "Please don't rush your opponent so often.",
'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.',
'error.already_queued': 'You are already in the queue.',
+3 -1
View File
@@ -95,7 +95,8 @@ export const ru: Record<MessageKey, string> = {
'chat.placeholder': 'Короткое сообщение…',
'chat.send': 'Отправить',
'chat.nudge': 'Поторопить',
'chat.nudge': 'Жду вашего хода!',
'chat.nudgeAction': 'Поторопить',
'chat.empty': 'Сообщений пока нет.',
'chat.nudged': '{name} торопит вас',
@@ -156,6 +157,7 @@ export const ru: Record<MessageKey, string> = {
'error.hint_unavailable': 'Подсказки недоступны.',
'error.no_hint_available': 'Нет вариантов с вашим набором.',
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.',
'error.already_queued': 'Вы уже в очереди.',
+5
View File
@@ -180,6 +180,11 @@ export class MockGateway implements GatewayClient {
return { matched: false };
}
async lobbyCancel(): Promise<void> {
// Dequeue: drop the pending substitution so a cancelled quick-match never arrives.
this.pendingMatch = null;
}
// --- game ---
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
const g = this.game(gameId);
+9
View File
@@ -48,6 +48,15 @@ describe('resultBadge', () => {
});
});
it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => {
// The opponent won by resignation (isWinner) although neither side scored — the lobby
// must read this as a loss, matching the game-detail screen (Stage 17 regression).
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
});
it('finished four-player: places by score', () => {
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' });
+5 -3
View File
@@ -21,9 +21,11 @@ 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 — place the viewer by score (1 + number of higher scores).
const rank = 1 + game.seats.filter((s) => s.score > (me?.score ?? 0)).length;
if (rank <= 1) return { key: 'result.victory', 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: '🏅' };
+3
View File
@@ -80,6 +80,9 @@ export function createTransport(baseUrl: string): GatewayClient {
async lobbyPoll() {
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
},
async lobbyCancel() {
await exec('lobby.cancel', codec.empty());
},
async gameState(id, includeAlphabet) {
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id, includeAlphabet)));
+17 -2
View File
@@ -31,12 +31,22 @@
poll = null;
}
}
// cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match
// is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the
// reaper later substitutes a robot for a game the player abandoned (Stage 17 fix).
function cancelSearch() {
stop();
searching = false;
void gateway.lobbyCancel().catch(() => {});
navigate('/');
}
async function find(v: Variant) {
searching = true;
try {
const r = await gateway.lobbyEnqueue(v);
if (r.matched && r.game) {
searching = false;
navigate(`/game/${r.game.id}`);
return;
}
@@ -45,6 +55,7 @@
const p = await gateway.lobbyPoll();
if (p.matched && p.game) {
stop();
searching = false;
navigate(`/game/${p.game.id}`);
}
} catch (e) {
@@ -103,7 +114,11 @@
}
}
onDestroy(stop);
onDestroy(() => {
stop();
// Abandoned mid-search (navigated away without Cancel): dequeue so we don't linger.
if (searching) void gateway.lobbyCancel().catch(() => {});
});
</script>
<Screen title={t('new.title')} back="/">
@@ -112,7 +127,7 @@
<div class="searching">
<div class="spinner"></div>
<p>{t('new.searching')}</p>
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
<button class="cancel" onclick={cancelSearch}>{t('common.cancel')}</button>
</div>
{:else}
{#if !guest}