feat(ui): app-shell behaviour — restore validation, return-to-lobby, push
- A restored game that no longer exists (cancelled/removed/revoked) drops to
the lobby with a toast instead of the in-game error state: game-state
exposes a `notFound` flag and the shell redirects via appScreen.go("lobby").
- Add a visible "return to lobby" control to the in-game header.
- Push/toast deep-links use activeView.select(...) (no URL); fix a latent
visibility-listener double-install on in-place game switches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,17 @@ export class GameStateStore {
|
|||||||
*/
|
*/
|
||||||
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
|
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
|
||||||
error: string | null = $state(null);
|
error: string | null = $state(null);
|
||||||
|
/**
|
||||||
|
* notFound is the distinct "this game is not in the player's list"
|
||||||
|
* signal, set when `findGame` returns null (cancelled, removed, or
|
||||||
|
* access revoked). It is a clean flag the app-shell reads after
|
||||||
|
* `init` to drop a restored/stale game back to the lobby with a
|
||||||
|
* toast, rather than string-matching the `error` message. Transient
|
||||||
|
* network failures keep `notFound` false (they take the catch path)
|
||||||
|
* so they still surface the in-game error state for a retry. Reset
|
||||||
|
* to false at the start of every `setGame` / `advanceToPending`.
|
||||||
|
*/
|
||||||
|
notFound = $state(false);
|
||||||
/**
|
/**
|
||||||
* currentTurn mirrors the engine's turn number for the running
|
* currentTurn mirrors the engine's turn number for the running
|
||||||
* game (lifted from the lobby record on `setGame`). Phase 14
|
* game (lifted from the lobby record on `setGame`). Phase 14
|
||||||
@@ -218,6 +229,7 @@ export class GameStateStore {
|
|||||||
this.gameId = gameId;
|
this.gameId = gameId;
|
||||||
this.status = "loading";
|
this.status = "loading";
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.notFound = false;
|
||||||
this.report = null;
|
this.report = null;
|
||||||
|
|
||||||
this.wrapMode = await readWrapMode(this.cache, gameId);
|
this.wrapMode = await readWrapMode(this.cache, gameId);
|
||||||
@@ -229,6 +241,7 @@ export class GameStateStore {
|
|||||||
if (summary === null) {
|
if (summary === null) {
|
||||||
this.status = "error";
|
this.status = "error";
|
||||||
this.error = `game ${gameId} is not in your list`;
|
this.error = `game ${gameId} is not in your list`;
|
||||||
|
this.notFound = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.gameName = summary.gameName;
|
this.gameName = summary.gameName;
|
||||||
@@ -306,11 +319,13 @@ export class GameStateStore {
|
|||||||
}
|
}
|
||||||
this.status = "loading";
|
this.status = "loading";
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.notFound = false;
|
||||||
try {
|
try {
|
||||||
const summary = await this.findGame(this.gameId);
|
const summary = await this.findGame(this.gameId);
|
||||||
if (summary === null) {
|
if (summary === null) {
|
||||||
this.status = "error";
|
this.status = "error";
|
||||||
this.error = `game ${this.gameId} is not in your list`;
|
this.error = `game ${this.gameId} is not in your list`;
|
||||||
|
this.notFound = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.gameName = summary.gameName;
|
this.gameName = summary.gameName;
|
||||||
@@ -558,6 +573,18 @@ export class GameStateStore {
|
|||||||
|
|
||||||
private installVisibilityListener(): void {
|
private installVisibilityListener(): void {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
|
// Idempotent on re-init. In the single-URL app-shell a direct
|
||||||
|
// game → game switch (a push deep-link or a refresh-restore)
|
||||||
|
// re-runs `init` without unmounting the shell, so a naive
|
||||||
|
// `addEventListener` here would stack a second listener on every
|
||||||
|
// switch. Drop any previously-registered one first so exactly one
|
||||||
|
// stays live.
|
||||||
|
if (this.visibilityListener !== null) {
|
||||||
|
document.removeEventListener(
|
||||||
|
"visibilitychange",
|
||||||
|
this.visibilityListener,
|
||||||
|
);
|
||||||
|
}
|
||||||
const listener = (): void => {
|
const listener = (): void => {
|
||||||
if (document.visibilityState === "visible" && this.status === "ready") {
|
if (document.visibilityState === "visible" && this.status === "ready") {
|
||||||
void this.refresh();
|
void this.refresh();
|
||||||
|
|||||||
@@ -515,6 +515,25 @@ return to the lobby still disposes the stores via `onDestroy`.
|
|||||||
orderDraft.init({ cache, gameId: activeGameId }),
|
orderDraft.init({ cache, gameId: activeGameId }),
|
||||||
mailStore.init({ client, cache, gameId: activeGameId }),
|
mailStore.init({ client, cache, gameId: activeGameId }),
|
||||||
]);
|
]);
|
||||||
|
// A restored or stale game id may point at a game that is
|
||||||
|
// no longer in the player's list (cancelled, removed, or
|
||||||
|
// access revoked). `init` flags that distinct case via
|
||||||
|
// `gameState.notFound` (a transient network error keeps it
|
||||||
|
// false and surfaces the in-game error state instead). Drop
|
||||||
|
// to the lobby with a toast rather than stranding the user
|
||||||
|
// on the in-game "not in your list" error. Leaving the
|
||||||
|
// `game` screen unmounts the shell, so the stores are
|
||||||
|
// disposed via `onDestroy`; the rest of the bootstrap
|
||||||
|
// (client bind, server hydration) is skipped for the dead
|
||||||
|
// game.
|
||||||
|
if (gameState.notFound) {
|
||||||
|
appScreen.go("lobby");
|
||||||
|
toast.show({
|
||||||
|
messageKey: "game.events.unavailable.message",
|
||||||
|
durationMs: 8000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
galaxyClient.set(client);
|
galaxyClient.set(client);
|
||||||
orderDraft.bindClient(client, {
|
orderDraft.bindClient(client, {
|
||||||
getCurrentTurn: () => gameState.currentTurn,
|
getCurrentTurn: () => gameState.currentTurn,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ absent until Phase 24 wires push-event state.
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
import {
|
import {
|
||||||
GAME_STATE_CONTEXT_KEY,
|
GAME_STATE_CONTEXT_KEY,
|
||||||
type GameStateStore,
|
type GameStateStore,
|
||||||
@@ -56,6 +57,14 @@ absent until Phase 24 wires push-event state.
|
|||||||
<TurnNavigator />
|
<TurnNavigator />
|
||||||
</div>
|
</div>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="return-to-lobby"
|
||||||
|
data-testid="return-to-lobby"
|
||||||
|
onclick={() => appScreen.go("lobby")}
|
||||||
|
>
|
||||||
|
{i18n.t("game.shell.menu.return_to_lobby")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="sidebar-toggle"
|
class="sidebar-toggle"
|
||||||
@@ -101,6 +110,20 @@ absent until Phase 24 wires push-event state.
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.return-to-lobby {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.return-to-lobby:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
.sidebar-toggle {
|
.sidebar-toggle {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const en = {
|
|||||||
"game.events.turn_ready.message": "turn {turn} is ready",
|
"game.events.turn_ready.message": "turn {turn} is ready",
|
||||||
"game.events.turn_ready.action": "view now",
|
"game.events.turn_ready.action": "view now",
|
||||||
"game.events.signature_failed": "verification failed, reconnecting…",
|
"game.events.signature_failed": "verification failed, reconnecting…",
|
||||||
|
"game.events.unavailable.message": "this game is no longer available",
|
||||||
|
|
||||||
"login.title": "sign in to Galaxy",
|
"login.title": "sign in to Galaxy",
|
||||||
"login.email_label": "email",
|
"login.email_label": "email",
|
||||||
@@ -118,6 +119,7 @@ const en = {
|
|||||||
"game.shell.menu.theme_light": "light",
|
"game.shell.menu.theme_light": "light",
|
||||||
"game.shell.menu.theme_dark": "dark",
|
"game.shell.menu.theme_dark": "dark",
|
||||||
"game.shell.menu.language": "language",
|
"game.shell.menu.language": "language",
|
||||||
|
"game.shell.menu.return_to_lobby": "return to lobby",
|
||||||
"game.shell.menu.logout": "logout",
|
"game.shell.menu.logout": "logout",
|
||||||
"game.shell.coming_soon": "coming soon",
|
"game.shell.coming_soon": "coming soon",
|
||||||
"game.shell.turn.label": "turn {turn}",
|
"game.shell.turn.label": "turn {turn}",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.events.turn_ready.message": "ход {turn} готов",
|
"game.events.turn_ready.message": "ход {turn} готов",
|
||||||
"game.events.turn_ready.action": "открыть",
|
"game.events.turn_ready.action": "открыть",
|
||||||
"game.events.signature_failed": "подпись повреждена, переподключение…",
|
"game.events.signature_failed": "подпись повреждена, переподключение…",
|
||||||
|
"game.events.unavailable.message": "эта игра больше недоступна",
|
||||||
|
|
||||||
"login.title": "вход в Galaxy",
|
"login.title": "вход в Galaxy",
|
||||||
"login.email_label": "электронная почта",
|
"login.email_label": "электронная почта",
|
||||||
@@ -119,6 +120,7 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.shell.menu.theme_light": "светлая",
|
"game.shell.menu.theme_light": "светлая",
|
||||||
"game.shell.menu.theme_dark": "тёмная",
|
"game.shell.menu.theme_dark": "тёмная",
|
||||||
"game.shell.menu.language": "язык",
|
"game.shell.menu.language": "язык",
|
||||||
|
"game.shell.menu.return_to_lobby": "вернуться в лобби",
|
||||||
"game.shell.menu.logout": "выйти",
|
"game.shell.menu.logout": "выйти",
|
||||||
"game.shell.coming_soon": "скоро будет",
|
"game.shell.coming_soon": "скоро будет",
|
||||||
"game.shell.turn.label": "ход {turn}",
|
"game.shell.turn.label": "ход {turn}",
|
||||||
|
|||||||
Reference in New Issue
Block a user