Chat + word-check as their own screens; in-game unread badge (review item 7)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 34s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s

- Chat and word-check are now routed screens (/game/:id/chat, /game/:id/check) with a
  header back to the game and no tab-bar, replacing their modals. The soft keyboard just
  resizes the visible viewport (tracked into --vvh, which the Screen height uses since iOS
  does not shrink dvh for the keyboard) with the input pinned to the bottom: no modal
  relayout, no page jump. Supersedes the earlier bottom-sheet Modal attempt.
- A new chat message raises an unread badge on the in-game hamburger + the Chat menu row
  (per game, cleared on opening the chat), mirroring the lobby badge.
- TG native back + the header back chevron return chat/check to their game.
- Exposes --tg-safe-top (device notch) for the finalised TG-fullscreen header.

Tests: e2e for chat/check opening as their own screens + back. Docs: PLAN, FUNCTIONAL(+ru).
This commit is contained in:
Ilia Denisov
2026-06-08 23:23:05 +02:00
parent 295e45486d
commit 70110effd9
12 changed files with 330 additions and 187 deletions
+11 -2
View File
@@ -15,6 +15,8 @@
import Friends from './screens/Friends.svelte';
import Stats from './screens/Stats.svelte';
import Game from './game/Game.svelte';
import ChatScreen from './game/ChatScreen.svelte';
import CheckScreen from './game/CheckScreen.svelte';
onMount(() => {
void bootstrap();
@@ -25,8 +27,11 @@
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
$effect(() => {
if (!insideTelegram()) return;
const name = router.route.name;
telegramBackButton(name !== 'lobby' && name !== 'login', () => navigate('/'));
const r = router.route;
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
const sub = r.name === 'gameChat' || r.name === 'gameCheck';
const target = sub ? `/game/${r.params.id}` : '/';
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
});
// Screen transitions: the lobby is the navigation root. Entering a screen from the
@@ -63,6 +68,10 @@
<NewGame />
{:else if router.route.name === 'game'}
<Game id={router.route.params.id} />
{:else if router.route.name === 'gameChat'}
<ChatScreen id={router.route.params.id} />
{:else if router.route.name === 'gameCheck'}
<CheckScreen id={router.route.params.id} />
{:else if router.route.name === 'profile'}
<Profile />
{:else if router.route.name === 'settings'}
+4 -1
View File
@@ -70,7 +70,10 @@
.screen {
display: flex;
flex-direction: column;
height: 100%;
/* Fit the visible viewport (set from visualViewport, app.svelte.ts) so a screen with a
bottom input — chat, word-check — stays above an open soft keyboard without the page
scrolling; falls back to the full height where the var is unset (Stage 17). */
height: var(--vvh, 100%);
}
.content {
flex: 0 1 auto;
+7 -4
View File
@@ -69,10 +69,13 @@
display: flex;
flex-direction: column;
gap: 10px;
/* dvh so the chat shrinks with an open keyboard, keeping the start of the
conversation on screen instead of pushed above the fold (vh fallback). */
height: 56vh;
height: 56dvh;
/* Fill the chat screen; the list scrolls and the input pins to the bottom. The screen
fits the visual viewport (--vvh), so an open keyboard simply shrinks it and the input
stays visible — no modal relayout, no page jump (Stage 17). */
flex: 1;
min-height: 0;
padding: 10px var(--pad);
box-sizing: border-box;
}
.list {
flex: 1;
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, clearChatUnread } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, StateView } from '../lib/model';
// The chat is its own screen (Stage 17), so the soft keyboard simply resizes the viewport with
// the input pinned to the bottom — no modal relayout jank. It loads the game state (for the
// turn-based chat/nudge toggle) and the message list, and clears the unread badge while open.
let { id }: { id: string } = $props();
let view = $state<StateView | null>(null);
let messages = $state<ChatMessage[]>([]);
let busy = $state(false);
let tick = $state(0);
const myId = $derived(app.session?.userId ?? '');
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const nudgeCooldownSecs = 3600;
// The nudge is one-per-hour-per-game and clears once the player chats (engagement); the
// backend stays authoritative, so a move-based reset is left to it.
const nudgeOnCooldown = $derived.by(() => {
void tick;
let lastNudge = 0;
let lastChat = 0;
for (const m of messages) {
if (m.senderId !== myId) continue;
if (m.kind === 'nudge') lastNudge = Math.max(lastNudge, m.createdAtUnix);
else lastChat = Math.max(lastChat, m.createdAtUnix);
}
if (lastNudge === 0 || Date.now() / 1000 - lastNudge >= nudgeCooldownSecs) return false;
return lastChat <= lastNudge;
});
async function refresh() {
try {
messages = await gateway.chatList(id);
clearChatUnread(id);
} catch {
/* best-effort */
}
}
onMount(async () => {
try {
view = await gateway.gameState(id, false);
} catch (e) {
handleError(e);
}
await refresh();
});
// Live: refresh (and keep the unread cleared) on a chat / nudge for this game.
$effect(() => {
const e = app.lastEvent;
if (!e) return;
if ((e.kind === 'chat_message' && e.message.gameId === id) || (e.kind === 'nudge' && e.gameId === id)) {
void refresh();
}
});
// Re-evaluate the nudge cooldown on a timer so the control re-enables on time.
$effect(() => {
const h = setInterval(() => (tick += 1), 20000);
return () => clearInterval(h);
});
async function sendChat(text: string) {
busy = true;
try {
messages = [...messages, await gateway.chatPost(id, text)];
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
async function nudge() {
busy = true;
try {
messages = [...messages, await gateway.nudge(id)];
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
</script>
<Screen title={t('game.chat')} back={`/game/${id}`} scroll={false} column>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
</Screen>
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import type { Variant } from '../lib/model';
// Word-check on its own screen (Stage 17): unlimited dictionary lookups, each with a
// complaint, off the board so the soft keyboard never relayouts the play area.
let { id }: { id: string } = $props();
let variant = $state<Variant>('english');
let word = $state('');
let result = $state<{ word: string; legal: boolean } | null>(null);
let cooling = $state(false);
const checked = new Map<string, boolean>();
onMount(async () => {
try {
// Include the alphabet so input sanitising + the check accept the variant's letters.
const st = await gateway.gameState(id, true);
variant = st.game.variant;
} catch (e) {
handleError(e);
}
});
function onInput(e: Event) {
word = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
}
// Disabled while cooling, for an already-checked word, or an out-of-range length.
function canCheck(): boolean {
return canCheckWord(word, checked.has(word.trim().toUpperCase()), cooling);
}
async function runCheck() {
if (!canCheck()) return;
const w = word.trim().toUpperCase();
cooling = true;
setTimeout(() => (cooling = false), 5000);
try {
const r = await gateway.checkWord(id, w, variant);
checked.set(w, r.legal);
result = { word: w, legal: r.legal };
} catch (e) {
handleError(e);
}
}
async function complain() {
if (!result) return;
try {
await gateway.complaint(id, result.word, '');
showToast(t('game.complaintSent'));
result = null;
} catch (e) {
handleError(e);
}
}
</script>
<Screen title={t('game.checkWord')} back={`/game/${id}`}>
<div class="wrap">
<div class="check">
<input
value={word}
oninput={onInput}
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div>
{#if result}
<p class="verdict" class:ok={result.legal} class:bad={!result.legal}>
{result.legal
? t('game.wordLegal', { word: result.word })
: t('game.wordIllegal', { word: result.word })}
</p>
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
</div>
</Screen>
<style>
.wrap {
padding: 16px var(--pad);
display: flex;
flex-direction: column;
gap: 14px;
}
.check {
display: flex;
gap: 8px;
}
.check input {
flex: 1;
min-width: 0;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
text-transform: uppercase;
}
.check button {
padding: 10px 16px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.check button:disabled {
opacity: 0.5;
}
.verdict {
margin: 0;
font-weight: 600;
}
.verdict.ok {
color: var(--ok, #2e7d32);
}
.verdict.bad {
color: var(--danger, #c0392b);
}
.complain {
align-self: flex-start;
padding: 8px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
</style>
+5 -174
View File
@@ -7,17 +7,16 @@
import Modal from '../components/Modal.svelte';
import Board from './Board.svelte';
import Rack from './Rack.svelte';
import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model';
import type { Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model';
import { replay } from '../lib/board';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame } from '../lib/gamecache';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
@@ -50,21 +49,13 @@
// tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
let rackIds = $state<number[]>([]);
let shuffling = $state(false);
let panel = $state<'none' | 'chat'>('none');
let historyOpen = $state(false);
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
let exchangeOpen = $state(false);
let exchangeSel = $state<number[]>([]);
let checkOpen = $state(false);
let checkWord = $state('');
let checkResult = $state<{ word: string; legal: boolean } | null>(null);
let resignOpen = $state(false);
let messages = $state<ChatMessage[]>([]);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
const checkedWords = new Map<string, boolean>();
let cooling = $state(false);
const variant = $derived(view?.game.variant ?? 'english');
const board = $derived(replay(moves));
const premium = $derived(premiumGrid(variant));
@@ -96,29 +87,6 @@
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);
// Unix seconds of the player's own last move, which resets the nudge cooldown (mirrors the
// backend, Stage 17). A chat reset is read from `messages`; a move is tracked client-side
// (the backend stays authoritative across a reload).
let lastActedAt = $state(0);
const nudgeOnCooldown = $derived.by(() => {
void nudgeTick;
const mine = app.session?.userId ?? '';
let lastNudge = 0;
let lastChat = 0;
for (const m of messages) {
if (m.senderId !== mine) continue;
if (m.kind === 'nudge') lastNudge = Math.max(lastNudge, m.createdAtUnix);
else lastChat = Math.max(lastChat, m.createdAtUnix);
}
if (lastNudge === 0 || Date.now() / 1000 - lastNudge >= nudgeCooldownSecs) return false;
// Engagement since the nudge clears the cooldown: a chat or a move.
return lastChat <= lastNudge && lastActedAt <= lastNudge;
});
async function load() {
try {
@@ -169,13 +137,6 @@
const rack = order.map((i) => st.rack[i]);
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
}
async function loadChat() {
try {
messages = await gateway.chatList(id);
} catch (e) {
handleError(e);
}
}
onMount(() => {
// Guard against an accidental swipe-close losing the open game (Telegram).
telegramClosingConfirmation(true);
@@ -200,20 +161,11 @@
// player's other devices): this device already reloaded after the submit.
if (e.seat !== view?.seat) void load();
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
// A request the player sent was answered (accepted -> now friends; declined -> stays
// "request sent"): re-derive the in-game friend state.
else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends();
});
// 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;
}
@@ -499,7 +451,6 @@
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
telegramHaptic('success');
zoomed = false;
await load();
@@ -521,7 +472,6 @@
busy = true;
try {
await gateway.pass(id);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load();
} catch (e) {
handleError(e);
@@ -603,7 +553,6 @@
busy = true;
try {
await gateway.exchange(id, tiles, variant);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load();
} catch (e) {
handleError(e);
@@ -612,64 +561,6 @@
}
}
function openCheck() {
checkWord = '';
checkResult = null;
checkOpen = true;
}
function onCheckInput(e: Event) {
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
}
// Check is disabled while cooling down, for an already-checked word, or an out-of-range
// length. The input filter already restricts to the variant's alphabet.
function canCheck(): boolean {
return canCheckWord(checkWord, checkedWords.has(checkWord.trim().toUpperCase()), cooling);
}
async function runCheck() {
if (!canCheck()) return;
const w = checkWord.trim().toUpperCase();
cooling = true;
setTimeout(() => (cooling = false), 5000);
try {
const r = await gateway.checkWord(id, w, variant);
// Key the cache and the displayed result on the upper-case word the player typed; the
// server echoes the decoded concrete word in the solver's lower case.
checkedWords.set(w, r.legal);
checkResult = { word: w, legal: r.legal };
} catch (e) {
handleError(e);
}
}
async function complain() {
if (!checkResult) return;
try {
await gateway.complaint(id, checkResult.word, '');
showToast(t('game.complaintSent'));
checkOpen = false;
} catch (e) {
handleError(e);
}
}
function openChat() {
panel = 'chat';
void loadChat();
}
async function sendChat(text: string) {
try {
messages = [...messages, await gateway.chatPost(id, text)];
} catch (e) {
handleError(e);
}
}
async function nudge() {
try {
messages = [...messages, await gateway.nudge(id)];
} catch (e) {
handleError(e);
}
}
function resultText(): string {
if (!view) return '';
const me = view.game.seats[view.seat];
@@ -724,8 +615,8 @@
// an "add to friends" item flips to a disabled "request sent" once tapped.
const menuItems = $derived([
{ label: t('game.history'), onclick: () => (historyOpen = true) },
{ label: t('game.chat'), onclick: openChat },
...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: openCheck }]),
{ label: t('game.chat'), onclick: () => navigate(`/game/${id}/chat`), badge: app.chatUnread[id] ?? 0 },
...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: () => navigate(`/game/${id}/check`) }]),
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
...(!app.profile?.isGuest
? opponents.map((s) =>
@@ -742,7 +633,7 @@
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
{#snippet menu()}
<Menu items={menuItems} />
<Menu items={menuItems} badge={app.chatUnread[id] ?? 0} />
{/snippet}
{#if view}
@@ -898,28 +789,6 @@
</Modal>
{/if}
{#if checkOpen}
<Modal title={t('game.checkWord')} overlayKeyboard onclose={() => (checkOpen = false)}>
<div class="check">
<input
value={checkWord}
oninput={onCheckInput}
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div>
{#if checkResult}
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
{checkResult.legal
? t('game.wordLegal', { word: checkResult.word })
: t('game.wordIllegal', { word: checkResult.word })}
</p>
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
</Modal>
{/if}
{#if resignOpen}
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row">
@@ -929,12 +798,6 @@
</Modal>
{/if}
{#if panel === 'chat'}
<Modal title={t('game.chat')} bottomSheet onclose={() => (panel = 'none')}>
<Chat {messages} myId={app.session?.userId ?? ''} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
</Modal>
{/if}
<style>
.scoreboard {
display: flex;
@@ -1193,38 +1056,6 @@
.confirm:disabled {
opacity: 0.5;
}
.check {
display: flex;
gap: 6px;
}
.check input {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
text-transform: uppercase;
}
.check button {
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.ok {
color: var(--ok);
}
.bad {
color: var(--danger);
}
.complain {
background: none;
border: none;
color: var(--accent);
padding: 4px 0;
}
.confirm-row {
display: flex;
gap: 8px;
+37 -1
View File
@@ -47,6 +47,8 @@ export const app = $state<{
localeLocked: boolean;
/** Pending incoming friend requests + invitations, for the lobby badge. */
notifications: number;
/** Unread chat-message count per game id, for the in-game menu/hamburger badge. */
chatUnread: Record<string, number>;
}>({
ready: false,
session: null,
@@ -60,6 +62,7 @@ export const app = $state<{
boardLines: false,
localeLocked: false,
notifications: 0,
chatUnread: {},
});
let unsubscribeStream: (() => void) | null = null;
@@ -105,6 +108,11 @@ export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
toastTimer = setTimeout(() => (app.toast = null), 4000);
}
/** clearChatUnread resets a game's unread chat-message count (called when its chat is opened). */
export function clearChatUnread(gameId: string): void {
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 };
}
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
export function handleError(err: unknown): void {
telegramHaptic('error');
@@ -126,7 +134,15 @@ function openStream(): void {
(e) => {
app.lastEvent = e;
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
// While the player is on that game's chat screen, neither toast nor bump the unread.
const onChat = router.route.name === 'gameChat' && router.route.params.id === e.message.gameId;
if (!onChat) {
if (e.message.kind !== 'nudge') {
const gid = e.message.gameId;
app.chatUnread = { ...app.chatUnread, [gid]: (app.chatUnread[gid] ?? 0) + 1 };
}
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
}
} else if (e.kind === 'nudge') {
showToast(t('chat.nudge'), 'info');
} else if (e.kind === 'your_turn') {
@@ -243,6 +259,19 @@ function syncTelegramSafeArea(): void {
document.documentElement.classList.toggle('tg-fullscreen', top > 0);
}
/**
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard).
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible
* without the page scrolling, so the layout no longer jumps when the keyboard appears (Stage 17).
*/
function syncViewportHeight(): void {
if (typeof document === 'undefined') return;
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0;
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
}
export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
@@ -261,6 +290,13 @@ export async function bootstrap(): Promise<void> {
setLocale(guess);
}
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh).
syncViewportHeight();
if (typeof window !== 'undefined' && window.visualViewport) {
window.visualViewport.addEventListener('resize', syncViewportHeight);
window.visualViewport.addEventListener('scroll', syncViewportHeight);
}
// Telegram Mini App launch: apply the platform theme, authenticate via initData,
// and route any deep-link start parameter. On the dedicated /telegram/ entry path
// outside Telegram (no initData), refuse to render and send the visitor to the
+6 -1
View File
@@ -7,6 +7,8 @@ export type RouteName =
| 'lobby'
| 'new'
| 'game'
| 'gameChat'
| 'gameCheck'
| 'profile'
| 'settings'
| 'about'
@@ -29,7 +31,10 @@ function parse(hash: string): Route {
case 'new':
return { name: 'new', params: {} };
case 'game':
return seg[1] ? { name: 'game', params: { id: seg[1] } } : { name: 'notfound', params: {} };
if (!seg[1]) return { name: 'notfound', params: {} };
if (seg[2] === 'chat') return { name: 'gameChat', params: { id: seg[1] } };
if (seg[2] === 'check') return { name: 'gameCheck', params: { id: seg[1] } };
return { name: 'game', params: { id: seg[1] } };
case 'profile':
return { name: 'profile', params: {} };
case 'settings':