diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f541828..0a5130c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -93,7 +93,7 @@ invitation — so a player still sees and plays existing games of any language. **Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**. With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting, -chat and nudge are off, add-friend is never shown, and the word-check tool still works; instead of a +there is no chat or nudge — only the word-check tool — add-friend is never shown; instead of a per-move clock you lose only after **7 days without a move** (so you can abandon an AI game freely, and the AI itself never runs out of time). Choosing a **random player** is auto-match (always 2 players), which drops you **straight into the game and you wait inside it**: if it is your turn you @@ -211,7 +211,9 @@ is awaited at most once per hour (the nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered via the platform) **name the nudger** (": waiting for your move"). Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary** -tabs, reached from the 💬 in the move-history header (with a back to the game). An unread chat +tabs, reached from the 💬 in the move-history header (with a back to the game). An AI game has no +chat, so its comms screen is the word-check alone — and once an AI game is finished (no chat, the +dictionary closed) the 💬 entry disappears entirely. An unread chat entry — a message **or a nudge** from an opponent — raises a small **red dot** next to the game in the **lobby** and on the game's **score bar**. Opening the **move history** counts as reading the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 6e633ad..cc7ae87 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -96,7 +96,7 @@ nudge) приходят от бота **этой партии** — по язы **Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ** вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge -выключены, «добавить в друзья» не показывается, а проверка слова работает; вместо лимита на ход вы +нет — только проверка слова, — «добавить в друзья» не показывается; вместо лимита на ход вы проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который сразу **помещает вас в игру, и вы ждёте соперника прямо @@ -217,7 +217,9 @@ nudge) приходят от бота **этой партии** — по язы и внеприложенческий push (доставляется через платформу) **называют отправителя** («<соперник>: жду вашего хода»). Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**, -открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). Непрочитанная запись чата — +открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). В ИИ-партии чата нет, поэтому её +экран связи — только проверка слова; а после завершения ИИ-партии (чата нет, словарь закрыт) сам вход +💬 исчезает. Непрочитанная запись чата — сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в **лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 1443cc6..30ad8e7 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -32,7 +32,8 @@ except Login uses `Screen`. bottom tab bar whose tabs switch its body **in place** (state, not navigation), so the back control always returns to the hub's parent (Settings → lobby, comms → game). Settings hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests); - comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active). The + comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active; an + honest-AI game has no Chat, so it shows the Dictionary alone). The routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The **Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so @@ -110,8 +111,9 @@ except Login uses `Screen`. dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a **double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed - the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a - recalled tile returns to its original rack slot. + the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger). A + **double-tap** returns the tile to its original rack slot; **dragging it back** drops it at the + rack slot the pointer is over (a gap opens there), the same drag-to-position as a rack reorder. - **Players plaque & history** (`Game.svelte`): the seats above the board share the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides) while the others read **sunk in** (an inset shadow). A tap on the plaque toggles @@ -284,8 +286,9 @@ enabled on the first, uncached load) and flip in place when an event refreshes t under Start game notes the wait can take a while (the app may be closed meanwhile). With **AI** selected there is no wait: the move-clock line instead reads **"Loss after 7 days of inactivity"** and the "searching" hint is hidden; the game starts already seated, the opponent shows as **🤖** - everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and the chat's - send field and 🛎️ nudge stay **disabled** while the 🔎 dictionary word-check keeps working. + everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and there is + **no chat at all**: the comms hub drops the 💬 Chat tab and offers only the 🔎 dictionary + word-check (the 🛎️ nudge is likewise gone). - **Statistics** (`screens/Stats.svelte`, the lobby ✏️ tab): a 2-column grid of stat cards (games / wins / draws / losses / moves / hint-share / best game / win-rate) — pure numbers, no charts; hint-share is a one-decimal percentage in the active locale's @@ -318,7 +321,7 @@ enabled on the first, uncached load) and flip in place when an event refreshes t never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board: it closes the history drawer (portrait) and zooms the board out. - **Finished game**: the board keeps no last-word highlight and no zoom; the history header - offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab; and + offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input row is turn-driven: on your turn the message field shows until your one allowed message is diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 310be23..e49c028 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -284,6 +284,28 @@ test('a placed tile drags from one board cell to another (relocation)', async ({ expect(to).not.toBe(from); }); +test('a placed tile drags back to a chosen rack slot (recall-to-position)', async ({ page }) => { + await openGame(page); + // Place the first rack tile (slot 0) on the board, remembering its letter. + await page.locator('.rack .tile').first().click(); + const placed = (await page.locator('.rack .tile .letter').first().textContent()) ?? ''; + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + const pending = page.locator('[data-cell].pending'); + await expect(pending).toHaveCount(1); + + // Drag it from the board back onto a later rack slot (the 4th visible tile), not its origin. + const fb = await pending.first().boundingBox(); + const slot = await page.locator('[data-rack] .tile').nth(3).boundingBox(); + await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2); + await page.mouse.down(); + await page.mouse.move(slot!.x + 2, slot!.y + slot!.height / 2, { steps: 10 }); + await page.mouse.up(); + + // Recalled (no pending) and reinserted at the drop slot — slot 3, not its origin slot 0. + await expect(pending).toHaveCount(0); + await expect(page.locator('[data-rack] .tile .letter').nth(3)).toHaveText(placed); +}); + test('comms hub: chat and dictionary share a screen, back returns to the game', async ({ page }) => { await openGame(page); await page.locator('.scoreboard').click(); // open the history diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 59d9a3a..3561745 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -50,18 +50,20 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible(); await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); - // Chat is disabled (the message field), while the dictionary word-check stays usable. + // An AI game has no chat at all: the comms hub drops the Chat tab and lands on the Dictionary + // alone, which stays usable. await page.locator('.scoreboard').click(); // open the history - await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub + await page.getByRole('button', { name: 'Chat' }).click(); // 💬 (the history-header entry) -> comms hub await expect(page).toHaveURL(/\/game\/.+\/chat$/); - await expect(page.locator('.chat input')).toBeDisabled(); - await page.getByRole('button', { name: 'Dictionary' }).click(); - await expect(page.locator('.check input')).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); // no Chat tab + await expect(page.locator('.chat input')).toHaveCount(0); // the chat body never mounts + await expect(page.locator('.check input')).toBeEnabled(); // the Dictionary is the only surface }); -// GCG export is pointless for a practice AI game, so the finished AI game hides the 📤 export -// (the comms hub stays). Start an AI game, resign it, reopen the history and check the header. -test('AI game: no GCG export offered after it ends', async ({ page }) => { +// GCG export is pointless for a practice AI game, and a finished AI game has no comms at all +// (no chat, and the dictionary closes with the game), so both the 📤 export and the 💬 comms +// entry are gone. Start an AI game, resign it, reopen the history and check the header. +test('AI game: after it ends, no GCG export and no comms entry', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /🎲/ }).click(); @@ -75,11 +77,11 @@ test('AI game: no GCG export offered after it ends', async ({ page }) => { await page.locator('button.danger').click(); await expect(page.locator('.status .over')).toBeVisible(); - // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export, - // while the comms hub stays reachable. + // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and + // no comms entry at all. await page.locator('.scoreboard').click(); await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); - await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); }); // The opponent_joined push is best-effort and never replayed, so a join that lands while the live diff --git a/ui/src/game/CommsHub.svelte b/ui/src/game/CommsHub.svelte index 6ee7f9e..0cac7fe 100644 --- a/ui/src/game/CommsHub.svelte +++ b/ui/src/game/CommsHub.svelte @@ -8,19 +8,24 @@ // The in-game comms hub: a single nav bar + bottom tab bar hosting Chat and the word // Dictionary. Tabs switch in place, so the back control always returns to the game. The - // Dictionary tab is offered only while the game is active (mirrors the old "check word"). + // Dictionary tab is offered only while the game is active (mirrors the old "check word"); an + // honest-AI game has no Chat, so it shows the Dictionary alone. type CommsTab = 'chat' | 'dictionary'; let { id, initialTab = 'chat' }: { id: string; initialTab?: CommsTab } = $props(); // The game is rendered (and cached) before its comms open, so the cache tells us whether // it is still active without another fetch; an unknown game keeps the Dictionary offered. const active = $derived(getCachedGame(id)?.view.game.status !== 'finished'); - // Seeded once from the entry route's tab and then owned locally; the effect below - // corrects a Dictionary deep-link into a finished game back to Chat. + // An honest-AI game has no chat at all, so its hub is Dictionary-only. + const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false); + // Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid: + // an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary + // deep-link falls back to Chat). // svelte-ignore state_referenced_locally let tab = $state(initialTab); $effect(() => { - if (tab === 'dictionary' && !active) tab = 'chat'; + if (vsAi) tab = 'dictionary'; + else if (tab === 'dictionary' && !active) tab = 'chat'; }); @@ -38,9 +43,11 @@ {#snippet tabbar()} - + {#if !vsAi} + + {/if} {#if active} {/if} {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} - + + {#if !(gameOver && view.game.vsAi)} + + {/if}
@@ -1456,9 +1473,9 @@ white-space: nowrap; } .rack-row { + position: relative; display: flex; flex: none; - gap: 8px; align-items: stretch; padding: 0 var(--pad) 6px; } @@ -1471,11 +1488,17 @@ min-width: 0; } /* A borderless icon button (like the tab bar), not a filled accent button — and disabled - while the pending word is known to be illegal. The glyph is right-aligned within its box so - its right edge lands under the right edge of the preview caption above it (both sit at - var(--pad) from the screen edge — .status and .rack-row share that padding). */ + while the pending word is known to be illegal. It is an overlay, NOT a flex sibling of the + rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize + when a tile is staged), and the button is absolutely pinned over the slots a staged tile + frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the + preview caption above (.status shares that padding). */ .make { - min-width: 56px; + position: absolute; + right: var(--pad); + top: 0; + bottom: 6px; + width: 56px; background: none; color: var(--text); border: none; diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index e820771..9f2154c 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -83,12 +83,7 @@ } .tile { position: relative; - /* Sit at the natural tile width, but shrink to fit when the row is crowded so a full - rack never overflows its wrapper onto the MakeMove control to its right (the confirm - button stays pinned at the right screen edge). flex-grow stays 0 so a sparse rack does - not stretch its tiles. */ - flex: 0 1 auto; - min-width: 0; + flex: 0 0 auto; width: min(12.5vw, 46px); aspect-ratio: 1; background: var(--tile-bg); diff --git a/ui/src/lib/placement.test.ts b/ui/src/lib/placement.test.ts index 0f388d9..241b9e6 100644 --- a/ui/src/lib/placement.test.ts +++ b/ui/src/lib/placement.test.ts @@ -9,6 +9,7 @@ import { rackView, recallAt, recallIndex, + recallToSlot, reorderIndices, reset, toSubmit, @@ -112,3 +113,35 @@ describe('reorderIndices', () => { expect(reorderIndices(3, 0, 99)).toEqual([1, 2, 0]); // slot clamped to the end }); }); + +describe('recallToSlot', () => { + it('reinserts the recalled tile at the chosen visible slot, reordering the rack', () => { + // Lift Q (slot 1) onto the board, then recall it to visible slot 4. + const p = place(newPlacement(rack), 1, 7, 7); + const { placement, order } = recallToSlot(p, 7, 7, 4); + expect(placement.pending).toHaveLength(0); + expect(placement.rack).toEqual(['A', BLANK, 'N', 'I', 'Q', 'W', 'E']); + expect(order).toEqual([0, 2, 3, 4, 1, 5, 6]); + }); + + it('counts only still-visible slots and remaps the remaining pending tiles', () => { + // A (slot 0) and N (slot 3) are on the board; recall A to visible slot 2 — the placed N is + // skipped, and N's rackIndex follows the rack permutation. + let p = place(newPlacement(rack), 0, 7, 7); + p = place(p, 3, 7, 8); + const { placement, order } = recallToSlot(p, 7, 7, 2); + expect(placement.rack).toEqual(['Q', BLANK, 'N', 'A', 'I', 'W', 'E']); + expect(order).toEqual([1, 2, 3, 0, 4, 5, 6]); + expect(placement.pending).toHaveLength(1); + expect(placement.pending[0].rackIndex).toBe(2); + expect(placement.rack[placement.pending[0].rackIndex]).toBe('N'); + }); + + it('clamps the slot to the end and is an identity no-op when no tile sits at the cell', () => { + const p = place(newPlacement(rack), 1, 7, 7); + expect(recallToSlot(p, 7, 7, 99).placement.rack).toEqual(['A', BLANK, 'N', 'I', 'W', 'E', 'Q']); + const none = recallToSlot(p, 0, 0, 0); + expect(none.placement).toBe(p); + expect(none.order).toEqual([0, 1, 2, 3, 4, 5, 6]); + }); +}); diff --git a/ui/src/lib/placement.ts b/ui/src/lib/placement.ts index 28727e8..8c551d6 100644 --- a/ui/src/lib/placement.ts +++ b/ui/src/lib/placement.ts @@ -102,6 +102,51 @@ export function recallIndex(p: Placement, rackIndex: number): Placement { return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) }; } +/** + * recallToSlot recalls the pending tile at (row, col) and reinserts its rack letter at the + * given visible slot, reordering the rack the way a drag-to-reorder does (the drop position the + * player chose) instead of snapping the tile back to its original slot. It returns the new + * placement together with the index permutation applied to the rack, so the caller can permute + * its parallel stable ids in lockstep. toSlot counts the slots that stay visible after the + * recall (those still on the board are skipped); it is clamped to a valid position. The result + * is the unchanged placement and an identity permutation when no pending tile sits at (row, col). + */ +export function recallToSlot( + p: Placement, + row: number, + col: number, + toSlot: number, +): { placement: Placement; order: number[] } { + const identity = p.rack.map((_, i) => i); + const tile = p.pending.find((t) => t.row === row && t.col === col); + if (!tile) return { placement: p, order: identity }; + const ri = tile.rackIndex; + const rest = p.pending.filter((t) => t !== tile); + const usedByRest = new Set(rest.map((t) => t.rackIndex)); + // Reinsert ri just before the toSlot-th slot that stays visible after the recall. + const others = identity.filter((i) => i !== ri); + let visible = 0; + let insertPos = others.length; + for (let pos = 0; pos < others.length; pos++) { + if (usedByRest.has(others[pos])) continue; + if (visible === toSlot) { + insertPos = pos; + break; + } + visible++; + } + const order = [...others]; + order.splice(insertPos, 0, ri); + const newIndex = new Map(order.map((oldIdx, idx) => [oldIdx, idx])); + return { + placement: { + rack: order.map((i) => p.rack[i]), + pending: rest.map((t) => ({ ...t, rackIndex: newIndex.get(t.rackIndex)! })), + }, + order, + }; +} + export function reset(p: Placement): Placement { return { ...p, pending: [] }; }