feat(ui): app-shell core — single-route dispatcher, route collapse, nav→state
Collapse the game UI to one route (`/`): a screen dispatcher renders login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of URL routes. Move screen components to lib/screens & lib/game; the game shell reads the game id from `appScreen.gameId` and re-inits per-game stores via an $effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now state-based. WIP: browser-history (Back→lobby), restore-validation, the return-to-lobby button, push deep-links, and the test migration are follow-ups on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,9 +12,8 @@ header now — we just hand the routes down as callbacks so the
|
||||
viewer keeps its prop-driven contract.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { getContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import {
|
||||
BattleFetchError,
|
||||
@@ -127,10 +126,10 @@ viewer keeps its prop-driven contract.
|
||||
});
|
||||
|
||||
function backToReport() {
|
||||
goto(withBase(`/games/${gameId}/report`));
|
||||
activeView.select("report");
|
||||
}
|
||||
function backToMap() {
|
||||
goto(withBase(`/games/${gameId}/map`));
|
||||
activeView.select("map");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -25,10 +25,8 @@ fractions is a Phase 21 decision documented in
|
||||
`ui/docs/science-designer-ux.md`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { getContext, tick } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import type { ScienceSummary } from "../../api/game-state";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
@@ -53,8 +51,11 @@ fractions is a Phase 21 decision documented in
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
const scienceId = $derived(page.params.scienceId ?? "");
|
||||
// `scienceId` is the only sub-parameter the science designer needs;
|
||||
// the active game id is implicit (the shell only mounts this view
|
||||
// for the active game) and is read from `appScreen` where required.
|
||||
let { scienceId = "" }: { scienceId?: string } = $props();
|
||||
|
||||
const isViewMode = $derived(scienceId !== "");
|
||||
|
||||
const localScience = $derived<ScienceSummary[]>(
|
||||
@@ -126,7 +127,7 @@ fractions is a Phase 21 decision documented in
|
||||
}
|
||||
|
||||
function backToTable(): void {
|
||||
void goto(withBase(`/games/${gameId}/table/sciences`));
|
||||
activeView.select("table", { tableEntity: "sciences" });
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
|
||||
@@ -6,7 +6,7 @@ pane, system-item pane, compose form) live under
|
||||
`./mail/*.svelte`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { mailStore, type MailListEntry } from "$lib/mail-store.svelte";
|
||||
@@ -19,7 +19,7 @@ pane, system-item pane, compose form) live under
|
||||
let selectedKey = $state<string | null>(null);
|
||||
let composeOpen = $state(false);
|
||||
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
const gameId = $derived(appScreen.gameId ?? "");
|
||||
|
||||
const entries = $derived(mailStore.entries);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!--
|
||||
Phase 11 map active view: integrates the Phase 9 renderer with the
|
||||
per-game `GameStateStore` provided through context by
|
||||
`routes/games/[id]/+layout.svelte`. The view mounts the renderer
|
||||
`lib/game/game-shell.svelte`. The view mounts the renderer
|
||||
once the store has produced a report and re-mounts when the
|
||||
report's turn changes (a refresh that returns the same turn keeps
|
||||
the existing renderer instance alive). Empty-planet reports render
|
||||
@@ -20,10 +20,8 @@ Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode`
|
||||
preference the store already manages.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { getContext, onDestroy, onMount, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
createRenderer,
|
||||
@@ -615,6 +613,29 @@ preference the store already manages.
|
||||
// through the same `hit-test` plumbing — the hitLookup map keyed
|
||||
// by primitive id resolves a hit back to either a planet or a
|
||||
// ship-group selection variant.
|
||||
// scrollToBombingRow waits for the report's bombing row for the
|
||||
// given planet to mount, then scrolls it into view. The map context
|
||||
// menu switches to the report view through a store mutation, so the
|
||||
// section renders on a later frame; a short bounded poll bridges
|
||||
// that gap without coupling the map to the report's render timing.
|
||||
function scrollToBombingRow(planet: number): void {
|
||||
if (typeof document === "undefined") return;
|
||||
let attempts = 60;
|
||||
const tick = (): void => {
|
||||
const row = document.querySelector(
|
||||
`[data-testid="report-bombing-row"][data-planet="${planet}"]`,
|
||||
);
|
||||
if (row instanceof HTMLElement) {
|
||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
attempts -= 1;
|
||||
if (attempts <= 0) return;
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function handleMapClick(cursorPx: { x: number; y: number }): void {
|
||||
if (handle === null || store?.report === undefined || store.report === null) {
|
||||
return;
|
||||
@@ -634,26 +655,20 @@ preference the store already manages.
|
||||
selection.selectShipGroup(target.ref);
|
||||
break;
|
||||
case "battle": {
|
||||
const gameId = page.params.id ?? "";
|
||||
const turn = store?.report?.turn ?? 0;
|
||||
void goto(
|
||||
withBase(`/games/${gameId}/battle/${target.battleId}?turn=${turn}`),
|
||||
);
|
||||
activeView.select("battle", {
|
||||
battleId: target.battleId,
|
||||
turn,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "bombing": {
|
||||
const gameId = page.params.id ?? "";
|
||||
void goto(
|
||||
withBase(`/games/${gameId}/report#report-bombings`),
|
||||
).then(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const row = document.querySelector(
|
||||
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
|
||||
);
|
||||
if (row && row.scrollIntoView) {
|
||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
});
|
||||
activeView.select("report");
|
||||
// The report sections render reactively after the view
|
||||
// switches above, so there is no navigation promise to
|
||||
// await; poll a bounded number of animation frames for
|
||||
// the bombing row, then scroll it into view.
|
||||
scrollToBombingRow(target.planet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ TOC and the body iterate the same data.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
|
||||
import ReportToc, {
|
||||
type TocEntry,
|
||||
@@ -71,8 +70,6 @@ TOC and the body iterate the same data.
|
||||
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
|
||||
];
|
||||
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
|
||||
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
|
||||
let bodyEl: HTMLDivElement | null = $state(null);
|
||||
|
||||
@@ -116,7 +113,7 @@ TOC and the body iterate the same data.
|
||||
</script>
|
||||
|
||||
<div class="report-view" data-testid="active-view-report">
|
||||
<ReportToc entries={ENTRIES} {activeSlug} {gameId} />
|
||||
<ReportToc entries={ENTRIES} {activeSlug} />
|
||||
|
||||
<div class="report-body" bind:this={bodyEl}>
|
||||
<SectionGalaxySummary />
|
||||
|
||||
@@ -3,9 +3,10 @@ Phase 23 Report View table of contents.
|
||||
|
||||
Responsibilities:
|
||||
- "Back to map" button at the top — visible on both desktop sidebar
|
||||
and mobile sticky toolbar. Navigates via `$app/navigation.goto` so
|
||||
active-view-host scroll restoration plays through SvelteKit's
|
||||
history machinery and the layout's `mobileTool` resets naturally.
|
||||
and mobile sticky toolbar. Switches the active view to the map
|
||||
through `activeView.select("map")`; the shell's tool gate resets
|
||||
the `mobileTool` overlay naturally once the map is no longer the
|
||||
active view.
|
||||
- Desktop / tablet sidebar: a vertical list of anchor links, one per
|
||||
section. The active link gets `aria-current="location"` and a
|
||||
`.active` style. Click scrolls the active-view-host (not the
|
||||
@@ -20,8 +21,7 @@ The active section is computed by the orchestrator
|
||||
`activeSlug` prop. The TOC itself owns no observers.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
|
||||
@@ -33,10 +33,9 @@ The active section is computed by the orchestrator
|
||||
type Props = {
|
||||
entries: readonly TocEntry[];
|
||||
activeSlug: string;
|
||||
gameId: string;
|
||||
};
|
||||
|
||||
let { entries, activeSlug, gameId }: Props = $props();
|
||||
let { entries, activeSlug }: Props = $props();
|
||||
|
||||
function scrollToSlug(slug: string): void {
|
||||
const target = document.getElementById(`report-${slug}`);
|
||||
@@ -62,8 +61,8 @@ The active section is computed by the orchestrator
|
||||
scrollToSlug(slug);
|
||||
}
|
||||
|
||||
async function backToMap(): Promise<void> {
|
||||
await goto(withBase(`/games/${gameId}/map`));
|
||||
function backToMap(): void {
|
||||
activeView.select("map");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<!--
|
||||
Phase 27 Report View — battles section. Each row is a link into the
|
||||
Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where
|
||||
`turn` follows the current report's turn so history-mode views land
|
||||
on the right battle. Phase 23 rendered the same rows as inactive
|
||||
Phase 27 Report View — battles section. Each row opens the Battle
|
||||
Viewer through `activeView.select("battle", { battleId, turn })`
|
||||
where `turn` follows the current report's turn so history-mode views
|
||||
land on the right battle. Phase 23 rendered the same rows as inactive
|
||||
monospace `<span>`; the rewire here is the one-liner the Phase 23
|
||||
decision log called out.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { getContext } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
@@ -22,8 +21,11 @@ decision log called out.
|
||||
);
|
||||
const report = $derived(rendered?.report ?? null);
|
||||
const battles = $derived(report?.battles ?? []);
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
const turn = $derived(report?.turn ?? 0);
|
||||
|
||||
function openBattle(battleId: string): void {
|
||||
activeView.select("battle", { battleId, turn });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
@@ -46,12 +48,13 @@ decision log called out.
|
||||
<span class="label">
|
||||
{i18n.t("game.report.section.battles.id_label")}
|
||||
</span>
|
||||
<a
|
||||
<button
|
||||
type="button"
|
||||
class="uuid"
|
||||
href={withBase(`/games/${gameId}/battle/${b.id}?turn=${turn}`)}
|
||||
onclick={() => openBattle(b.id)}
|
||||
data-testid="report-battle-row"
|
||||
data-id={b.id}
|
||||
>{b.id}</a>
|
||||
>{b.id}</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -90,10 +93,15 @@ decision log called out.
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.uuid {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: var(--color-accent);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.uuid:hover {
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -11,16 +11,14 @@ The four tech proportions are stored on the wire as fractions in
|
||||
`[0, 1]` and surfaced here as percentages with one decimal so the
|
||||
table matches the designer's input units.
|
||||
|
||||
The component sits inside the active-view slot owned by
|
||||
`routes/games/[id]/+layout.svelte`, so it inherits the per-game
|
||||
The component sits inside the active-view area owned by
|
||||
`lib/game/game-shell.svelte`, so it inherits the per-game
|
||||
`OrderDraftStore` and `RenderedReportSource` through context. No
|
||||
data fetching is performed here — the layout is responsible.
|
||||
data fetching is performed here — the shell is responsible.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { getContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import type { ScienceSummary } from "../../api/game-state";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
@@ -60,8 +58,6 @@ data fetching is performed here — the layout is responsible.
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
|
||||
let sortColumn: SortColumn = $state("name");
|
||||
let sortDirection: SortDirection = $state("asc");
|
||||
let filter: string = $state("");
|
||||
@@ -118,11 +114,11 @@ data fetching is performed here — the layout is responsible.
|
||||
}
|
||||
|
||||
function openDesigner(name: string): void {
|
||||
void goto(withBase(`/games/${gameId}/designer/science/${encodeURIComponent(name)}`));
|
||||
activeView.select("designer-science", { scienceId: name });
|
||||
}
|
||||
|
||||
function newScience(): void {
|
||||
void goto(withBase(`/games/${gameId}/designer/science`));
|
||||
activeView.select("designer-science");
|
||||
}
|
||||
|
||||
async function deleteScience(name: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
<!--
|
||||
In-game shell. Composes the header, a conditionally-visible sidebar
|
||||
(Calculator / Inspector / Order tabs), the active-view area selected
|
||||
by `activeView`, and a mobile-only bottom-tab bar. In the single-URL
|
||||
app-shell there are no per-view routes: the active game id comes from
|
||||
`appScreen.gameId` and the visible view from `activeView`, both held
|
||||
in `$lib/app-nav.svelte`. The shell owns:
|
||||
|
||||
- `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the
|
||||
sidebar pinned via CSS; mobile hides it entirely.
|
||||
- `mobileTool` — mobile-only tool overlay state. The tool only
|
||||
visually overrides the active-view area when the active view is the
|
||||
map, so switching to any other view through the More drawer or the
|
||||
header view-menu naturally drops the overlay even if `mobileTool`
|
||||
was set on a previous tap.
|
||||
- `activeTab` — current sidebar tool (`calculator` / `inspector` /
|
||||
`order`). Held here, bound into the sidebar so a planet click on
|
||||
the map can flip it to `inspector` from the outside.
|
||||
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
|
||||
`SelectionStore`. All three are exposed to descendants via Svelte
|
||||
context; their lifetimes match the shell instance.
|
||||
|
||||
The per-game `GameStateStore` constructs the `GalaxyClient`, fetches
|
||||
the matching lobby record to discover `current_turn`, then loads the
|
||||
report. The store is shared with descendants via
|
||||
`setContext(GAME_STATE_CONTEXT_KEY, ...)` so the header turn counter,
|
||||
the map view, and the inspector tab all read from the same snapshot.
|
||||
|
||||
The planet inspector: the shell watches the selection store and, on
|
||||
the null → planet transition, flips `activeTab` to `inspector` and
|
||||
`sidebarOpen` to `true` so the inspector becomes visible regardless
|
||||
of breakpoint (desktop already has the sidebar pinned; tablet needs
|
||||
the drawer to surface). On mobile the `<PlanetSheet />` overlay reads
|
||||
the same selection and displays a read-only sheet over the map;
|
||||
closing the sheet clears the selection.
|
||||
|
||||
The per-game bootstrap (client construction, store init, push-event
|
||||
subscriptions) runs from an `$effect` keyed on `appScreen.gameId`:
|
||||
the cleanup tears the previous game's subscriptions down and the body
|
||||
re-initialises the shared stores for the new id, so a direct
|
||||
game → game switch (without leaving the shell) rebinds cleanly. The
|
||||
shell unmounts when the dispatcher leaves the `game` screen, so a
|
||||
return to the lobby still disposes the stores via `onDestroy`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy, setContext, untrack } from "svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { appScreen, activeView } from "$lib/app-nav.svelte";
|
||||
import Header from "$lib/header/header.svelte";
|
||||
import HistoryBanner from "$lib/header/history-banner.svelte";
|
||||
import Sidebar from "$lib/sidebar/sidebar.svelte";
|
||||
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
|
||||
import Calculator from "$lib/sidebar/calculator-tab.svelte";
|
||||
import Order from "$lib/sidebar/order-tab.svelte";
|
||||
import MapView from "$lib/active-view/map.svelte";
|
||||
import TableView from "$lib/active-view/table.svelte";
|
||||
import ReportView from "$lib/active-view/report.svelte";
|
||||
import BattleView from "$lib/active-view/battle.svelte";
|
||||
import MailView from "$lib/active-view/mail.svelte";
|
||||
import DesignerScience from "$lib/active-view/designer-science.svelte";
|
||||
import PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
|
||||
import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
|
||||
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
|
||||
import type { MobileTool, SidebarTab } from "$lib/sidebar/types";
|
||||
import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte";
|
||||
import {
|
||||
SelectionStore,
|
||||
SELECTION_CONTEXT_KEY,
|
||||
} from "$lib/selection.svelte";
|
||||
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
|
||||
import {
|
||||
createRenderedReportSource,
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
} from "$lib/rendered-report.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../../sync/order-draft.svelte";
|
||||
import {
|
||||
MAP_PICK_CONTEXT_KEY,
|
||||
MapPickService,
|
||||
} from "$lib/map-pick.svelte";
|
||||
import {
|
||||
GALAXY_CLIENT_CONTEXT_KEY,
|
||||
GalaxyClientHolder,
|
||||
} from "$lib/galaxy-client-context.svelte";
|
||||
import {
|
||||
CORE_CONTEXT_KEY,
|
||||
CoreHolder,
|
||||
} from "$lib/core-context.svelte";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { loadStore } from "../../platform/store/index";
|
||||
import { loadCore } from "../../platform/core/index";
|
||||
import { createGatewayClient } from "../../api/connect";
|
||||
import { GalaxyClient } from "../../api/galaxy-client";
|
||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||
import {
|
||||
getSyntheticReport,
|
||||
isSyntheticGameId,
|
||||
} from "../../api/synthetic-report";
|
||||
import {
|
||||
eventStream,
|
||||
type VerifiedEvent,
|
||||
} from "../../api/events.svelte";
|
||||
import { toast } from "$lib/toast.svelte";
|
||||
import { mailStore } from "$lib/mail-store.svelte";
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
let mobileTool: MobileTool = $state("map");
|
||||
let activeTab: SidebarTab = $state("inspector");
|
||||
|
||||
// The tool overlay (Calculator / Order) only replaces the active
|
||||
// view while the map is showing; switching to any other view drops
|
||||
// it, matching the previous URL-driven behaviour.
|
||||
const isOnMap = $derived(activeView.view === "map");
|
||||
const effectiveTool: MobileTool = $derived.by(() =>
|
||||
isOnMap ? mobileTool : "map",
|
||||
);
|
||||
|
||||
const gameState = new GameStateStore();
|
||||
setContext(GAME_STATE_CONTEXT_KEY, gameState);
|
||||
const orderDraft = new OrderDraftStore();
|
||||
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
|
||||
// Phase 26: the order tab vanishes from the sidebar and bottom-tabs
|
||||
// when the player is viewing a past turn. The flag is owned by
|
||||
// `GameStateStore` (single source of truth for "what turn are we
|
||||
// looking at") and surfaced here so the Phase 12 sidebar wiring,
|
||||
// the new `HistoryBanner`, and `orderDraft.bindClient` all read
|
||||
// from the same derivation.
|
||||
const historyMode = $derived(gameState.historyMode);
|
||||
const selection = new SelectionStore();
|
||||
setContext(SELECTION_CONTEXT_KEY, selection);
|
||||
const renderedReport = createRenderedReportSource(gameState, orderDraft);
|
||||
setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport);
|
||||
const galaxyClient = new GalaxyClientHolder();
|
||||
setContext(GALAXY_CLIENT_CONTEXT_KEY, galaxyClient);
|
||||
const coreHolder = new CoreHolder();
|
||||
setContext(CORE_CONTEXT_KEY, coreHolder);
|
||||
// `MapPickService` lives at the layout so both the active map
|
||||
// view (which binds the renderer-side resolver) and the
|
||||
// inspector subsections (which call `pick(...)`) see the same
|
||||
// instance via context — they sit on sibling branches of the
|
||||
// component tree.
|
||||
const mapPick = new MapPickService();
|
||||
setContext(MAP_PICK_CONTEXT_KEY, mapPick);
|
||||
|
||||
// selectedPlanet resolves the current selection against the live
|
||||
// report so both the desktop sidebar and the mobile sheet display
|
||||
// the same snapshot. A selection that points at a planet missing
|
||||
// from the current report (e.g. visibility lost between turns)
|
||||
// reads as `null` here, which collapses the inspector and the
|
||||
// sheet without surfacing a stale row. The rendered report layers
|
||||
// the local order draft on top so the player sees their pending
|
||||
// renames immediately.
|
||||
const selectedPlanet = $derived.by(() => {
|
||||
const sel = selection.selected;
|
||||
if (sel === null || sel.kind !== "planet") return null;
|
||||
const report = renderedReport.report;
|
||||
if (report === null) return null;
|
||||
return report.planets.find((p) => p.number === sel.id) ?? null;
|
||||
});
|
||||
const selectedShipGroup: ShipGroupSelection | null = $derived.by(() => {
|
||||
const sel = selection.selected;
|
||||
if (sel === null || sel.kind !== "shipGroup") return null;
|
||||
const report = renderedReport.report;
|
||||
if (report === null) return null;
|
||||
const ref = sel.ref;
|
||||
switch (ref.variant) {
|
||||
case "local": {
|
||||
const group = report.localShipGroups.find((g) => g.id === ref.id);
|
||||
if (group === undefined) return null;
|
||||
return { variant: "local", group };
|
||||
}
|
||||
case "other": {
|
||||
const group = report.otherShipGroups[ref.index];
|
||||
if (group === undefined) return null;
|
||||
return { variant: "other", group };
|
||||
}
|
||||
case "incoming": {
|
||||
const group = report.incomingShipGroups[ref.index];
|
||||
if (group === undefined) return null;
|
||||
return { variant: "incoming", group };
|
||||
}
|
||||
case "unidentified": {
|
||||
const group = report.unidentifiedShipGroups[ref.index];
|
||||
if (group === undefined) return null;
|
||||
return { variant: "unidentified", group };
|
||||
}
|
||||
}
|
||||
});
|
||||
const localShipClass = $derived(
|
||||
renderedReport.report?.localShipClass ?? [],
|
||||
);
|
||||
const localScience = $derived(renderedReport.report?.localScience ?? []);
|
||||
const inspectorPlanets = $derived(renderedReport.report?.planets ?? []);
|
||||
const inspectorRoutes = $derived(renderedReport.report?.routes ?? []);
|
||||
const inspectorMapWidth = $derived(renderedReport.report?.mapWidth ?? 1);
|
||||
const inspectorMapHeight = $derived(renderedReport.report?.mapHeight ?? 1);
|
||||
const inspectorLocalDrive = $derived(
|
||||
renderedReport.report?.localPlayerDrive ?? 0,
|
||||
);
|
||||
const inspectorLocalWeapons = $derived(
|
||||
renderedReport.report?.localPlayerWeapons ?? 0,
|
||||
);
|
||||
const inspectorLocalShields = $derived(
|
||||
renderedReport.report?.localPlayerShields ?? 0,
|
||||
);
|
||||
const inspectorLocalCargo = $derived(
|
||||
renderedReport.report?.localPlayerCargo ?? 0,
|
||||
);
|
||||
const inspectorLocalShipGroups = $derived(
|
||||
renderedReport.report?.localShipGroups ?? [],
|
||||
);
|
||||
const inspectorOtherShipGroups = $derived(
|
||||
renderedReport.report?.otherShipGroups ?? [],
|
||||
);
|
||||
const inspectorLocalFleets = $derived(
|
||||
renderedReport.report?.localFleets ?? [],
|
||||
);
|
||||
const inspectorOtherRaces = $derived(
|
||||
renderedReport.report?.otherRaces ?? [],
|
||||
);
|
||||
const inspectorLocalRace = $derived(renderedReport.report?.race ?? "");
|
||||
|
||||
// Reveal the inspector whenever a new planet selection lands.
|
||||
// Reading `selection.selected` once outside the effect keeps the
|
||||
// effect dependent on the rune transition and not on the derived
|
||||
// `selectedPlanet`, which can flicker as the report refreshes.
|
||||
$effect(() => {
|
||||
const sel = selection.selected;
|
||||
if (sel === null) return;
|
||||
// Stay in the calculator when a planet is picked: the calculator
|
||||
// consumes the selection in its planet area + reach circles, and
|
||||
// it is a long-lived workspace the user should not be ejected
|
||||
// from. `activeTab` is read untracked so a manual tab switch does
|
||||
// not re-fire this effect. Any other case (including a ship-group
|
||||
// selection, which the calculator does not use) reveals the
|
||||
// inspector as before.
|
||||
const tab = untrack(() => activeTab);
|
||||
if (!(tab === "calculator" && sel.kind === "planet")) {
|
||||
activeTab = "inspector";
|
||||
}
|
||||
sidebarOpen = true;
|
||||
});
|
||||
|
||||
// Reveal the calculator whenever the ship-classes table or the
|
||||
// bottom-tabs entry asks to load a class (or start a fresh design).
|
||||
let lastCalcLoadToken = 0;
|
||||
$effect(() => {
|
||||
const token = calculatorLoadRequest.token;
|
||||
if (token === lastCalcLoadToken) return;
|
||||
lastCalcLoadToken = token;
|
||||
activeTab = "calculator";
|
||||
sidebarOpen = true;
|
||||
});
|
||||
|
||||
function toggleSidebar(): void {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
|
||||
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
// `unsubTurnReady` / `unsubGamePaused` carry the
|
||||
// `eventStream.on(...)` disposers for the game-scoped push
|
||||
// handlers. The layout registers them once the local
|
||||
// `GameStateStore` is initialised so an event arriving before
|
||||
// `currentTurn` is known cannot misfire.
|
||||
let unsubTurnReady: (() => void) | null = null;
|
||||
let unsubGamePaused: (() => void) | null = null;
|
||||
let unsubMailReceived: (() => void) | null = null;
|
||||
const turnReadyDecoder = new TextDecoder("utf-8");
|
||||
|
||||
function parseTurnReadyPayload(
|
||||
event: VerifiedEvent,
|
||||
): { gameId: string; turn: number } | null {
|
||||
try {
|
||||
const text = turnReadyDecoder.decode(event.payloadBytes);
|
||||
const json: unknown = JSON.parse(text);
|
||||
if (typeof json !== "object" || json === null) {
|
||||
return null;
|
||||
}
|
||||
const record = json as Record<string, unknown>;
|
||||
const eventGameId = record.game_id;
|
||||
const eventTurn = record.turn;
|
||||
if (
|
||||
typeof eventGameId !== "string" ||
|
||||
typeof eventTurn !== "number" ||
|
||||
!Number.isFinite(eventTurn)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { gameId: eventGameId, turn: eventTurn };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMailReceivedPayload(
|
||||
event: VerifiedEvent,
|
||||
): { gameId: string; from: string } | null {
|
||||
try {
|
||||
const text = turnReadyDecoder.decode(event.payloadBytes);
|
||||
const json: unknown = JSON.parse(text);
|
||||
if (typeof json !== "object" || json === null) {
|
||||
return null;
|
||||
}
|
||||
const record = json as Record<string, unknown>;
|
||||
const eventGameId = record.game_id;
|
||||
if (typeof eventGameId !== "string") {
|
||||
return null;
|
||||
}
|
||||
const subject =
|
||||
typeof record.subject === "string" && record.subject !== ""
|
||||
? record.subject
|
||||
: typeof record.preview === "string"
|
||||
? record.preview
|
||||
: "";
|
||||
return { gameId: eventGameId, from: subject };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGamePausedPayload(
|
||||
event: VerifiedEvent,
|
||||
): { gameId: string; reason: string } | null {
|
||||
try {
|
||||
const text = turnReadyDecoder.decode(event.payloadBytes);
|
||||
const json: unknown = JSON.parse(text);
|
||||
if (typeof json !== "object" || json === null) {
|
||||
return null;
|
||||
}
|
||||
const record = json as Record<string, unknown>;
|
||||
const eventGameId = record.game_id;
|
||||
if (typeof eventGameId !== "string") {
|
||||
return null;
|
||||
}
|
||||
const reason = typeof record.reason === "string" ? record.reason : "";
|
||||
return { gameId: eventGameId, reason };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let activeTurnReadyToastId: string | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const pending = gameState.pendingTurn;
|
||||
if (pending === null) {
|
||||
if (activeTurnReadyToastId !== null) {
|
||||
toast.dismiss(activeTurnReadyToastId);
|
||||
activeTurnReadyToastId = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
activeTurnReadyToastId = toast.show({
|
||||
messageKey: "game.events.turn_ready.message",
|
||||
messageParams: { turn: String(pending) },
|
||||
actionLabelKey: "game.events.turn_ready.action",
|
||||
onAction: () => {
|
||||
void gameState.advanceToPending();
|
||||
},
|
||||
durationMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
function teardownSubscriptions(): void {
|
||||
if (unsubTurnReady !== null) {
|
||||
unsubTurnReady();
|
||||
unsubTurnReady = null;
|
||||
}
|
||||
if (unsubGamePaused !== null) {
|
||||
unsubGamePaused();
|
||||
unsubGamePaused = null;
|
||||
}
|
||||
if (unsubMailReceived !== null) {
|
||||
unsubMailReceived();
|
||||
unsubMailReceived = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-game bootstrap. The effect re-runs whenever `appScreen.gameId`
|
||||
// changes: its cleanup tears the previous game's push-event
|
||||
// subscriptions down, then the body rebinds the shared stores to the
|
||||
// new id. The shared store instances persist across the switch
|
||||
// (descendants captured them through context at construction), so a
|
||||
// game → game switch re-initialises them in place rather than
|
||||
// recreating them; `onDestroy` performs the terminal `dispose()`
|
||||
// when the dispatcher leaves the `game` screen and unmounts the
|
||||
// shell. A null id (no active game) is a no-op.
|
||||
$effect(() => {
|
||||
const activeGameId = appScreen.gameId;
|
||||
if (activeGameId === null || activeGameId === "") return;
|
||||
|
||||
(async (): Promise<void> => {
|
||||
// DEV-only synthetic-report path. The lobby's "Load
|
||||
// synthetic report" affordance enters the game with a
|
||||
// `synthetic-<uuid>` id and the matching report
|
||||
// pre-registered in an in-memory map. A page reload
|
||||
// loses the map entry; that case returns to the lobby
|
||||
// so the user reloads the JSON.
|
||||
if (isSyntheticGameId(activeGameId)) {
|
||||
const report = getSyntheticReport(activeGameId);
|
||||
if (report === undefined) {
|
||||
appScreen.go("lobby");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Synthetic mode still needs the wasm `Core` so
|
||||
// components that bridge to `pkg/calc/ship.go`
|
||||
// (designer preview, BattleViewer mass radii) can
|
||||
// resolve their math against the same engine helpers
|
||||
// the live path uses. The live branch below also
|
||||
// calls `loadCore()`; without it here the Battle
|
||||
// Viewer rendered every ship-class circle at
|
||||
// MAX_RADIUS in synthetic mode.
|
||||
const [{ cache }, core] = await Promise.all([
|
||||
loadStore(),
|
||||
loadCore(),
|
||||
]);
|
||||
coreHolder.set(core);
|
||||
await Promise.all([
|
||||
gameState.initSynthetic({ cache, gameId: activeGameId, report }),
|
||||
orderDraft.init({ cache, gameId: activeGameId }),
|
||||
]);
|
||||
// Deliberately no `galaxyClient.set` and no
|
||||
// `orderDraft.bindClient`: synthetic mode never
|
||||
// sends to the gateway. The auto-sync pipeline
|
||||
// already short-circuits via the UUID guard in
|
||||
// `scheduleSync`, but skipping the bind keeps
|
||||
// the path simple to reason about.
|
||||
} catch (err) {
|
||||
gameState.failBootstrap(describeBootstrapError(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
session.keypair === null ||
|
||||
session.deviceSessionId === null ||
|
||||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const keypair = session.keypair;
|
||||
const deviceSessionId = session.deviceSessionId;
|
||||
try {
|
||||
const [{ cache }, core] = await Promise.all([loadStore(), loadCore()]);
|
||||
coreHolder.set(core);
|
||||
const client = new GalaxyClient({
|
||||
core,
|
||||
edge: createGatewayClient(gatewayRpcBaseUrl()),
|
||||
signer: (canonical) => keypair.sign(canonical),
|
||||
sha256,
|
||||
deviceSessionId,
|
||||
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
||||
});
|
||||
// Register the `game.turn.ready` dispatch before the
|
||||
// network roundtrips below so an event delivered
|
||||
// while `gameState.init` is still in flight is not
|
||||
// dropped by the singleton stream. `markPendingTurn`
|
||||
// already protects against turns that do not advance
|
||||
// past the current snapshot. Phase 25: a turn-ready
|
||||
// frame arriving while the draft is in `conflict` or
|
||||
// `paused` state also resets the draft and rehydrates
|
||||
// from the server for the new turn — the old commands
|
||||
// became history at the cutoff.
|
||||
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
|
||||
const parsed = parseTurnReadyPayload(event);
|
||||
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||
return;
|
||||
}
|
||||
gameState.markPendingTurn(parsed.turn);
|
||||
if (
|
||||
orderDraft.syncStatus === "conflict" ||
|
||||
orderDraft.syncStatus === "paused"
|
||||
) {
|
||||
void orderDraft.resetForNewTurn({
|
||||
client,
|
||||
turn: parsed.turn,
|
||||
});
|
||||
}
|
||||
});
|
||||
unsubGamePaused = eventStream.on("game.paused", (event) => {
|
||||
const parsed = parseGamePausedPayload(event);
|
||||
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||
return;
|
||||
}
|
||||
orderDraft.markPaused({ reason: parsed.reason });
|
||||
});
|
||||
unsubMailReceived = eventStream.on(
|
||||
"diplomail.message.received",
|
||||
(event) => {
|
||||
const parsed = parseMailReceivedPayload(event);
|
||||
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||
return;
|
||||
}
|
||||
void mailStore.applyPushEvent(parsed.gameId);
|
||||
toast.show({
|
||||
messageKey: "game.events.mail_new.message",
|
||||
messageParams: { from: parsed.from },
|
||||
actionLabelKey: "game.events.mail_new.action",
|
||||
onAction: () => {
|
||||
activeView.select("mail");
|
||||
},
|
||||
durationMs: 8000,
|
||||
});
|
||||
},
|
||||
);
|
||||
await Promise.all([
|
||||
gameState.init({ client, cache, gameId: activeGameId }),
|
||||
orderDraft.init({ cache, gameId: activeGameId }),
|
||||
mailStore.init({ client, cache, gameId: activeGameId }),
|
||||
]);
|
||||
galaxyClient.set(client);
|
||||
orderDraft.bindClient(client, {
|
||||
getCurrentTurn: () => gameState.currentTurn,
|
||||
getHistoryMode: () => gameState.historyMode,
|
||||
});
|
||||
// The server is always polled at game boot — its
|
||||
// stored order may be fresher than the local cache
|
||||
// (e.g. user is on a new device), and an offline
|
||||
// edit must catch up at re-sync time. The hydration
|
||||
// is non-fatal: a network error keeps the local
|
||||
// cache and surfaces through `draft.syncStatus`.
|
||||
await orderDraft.hydrateFromServer({
|
||||
client,
|
||||
turn: gameState.currentTurn,
|
||||
});
|
||||
} catch (err) {
|
||||
gameState.failBootstrap(describeBootstrapError(err));
|
||||
}
|
||||
})();
|
||||
|
||||
return teardownSubscriptions;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
teardownSubscriptions();
|
||||
gameState.dispose();
|
||||
orderDraft.dispose();
|
||||
selection.dispose();
|
||||
});
|
||||
|
||||
function describeBootstrapError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return "request failed";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="game-shell" data-testid="game-shell">
|
||||
<a class="skip-link" href="#active-view-host">
|
||||
{i18n.t("common.skip_to_content")}
|
||||
</a>
|
||||
<Header
|
||||
{sidebarOpen}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
/>
|
||||
<HistoryBanner />
|
||||
<div class="body">
|
||||
<main
|
||||
class="active-view-host"
|
||||
id="active-view-host"
|
||||
tabindex="-1"
|
||||
data-testid="active-view-host"
|
||||
>
|
||||
{#if effectiveTool === "calc"}
|
||||
<Calculator />
|
||||
{:else if effectiveTool === "order"}
|
||||
<Order />
|
||||
{:else if activeView.view === "map"}
|
||||
<MapView />
|
||||
{:else if activeView.view === "table"}
|
||||
<TableView entity={activeView.state.tableEntity ?? ""} />
|
||||
{:else if activeView.view === "report"}
|
||||
<ReportView />
|
||||
{:else if activeView.view === "battle"}
|
||||
<BattleView
|
||||
gameId={appScreen.gameId ?? ""}
|
||||
turn={activeView.state.turn ?? 0}
|
||||
battleId={activeView.state.battleId ?? ""}
|
||||
/>
|
||||
{:else if activeView.view === "mail"}
|
||||
<MailView />
|
||||
{:else if activeView.view === "designer-science"}
|
||||
<DesignerScience scienceId={activeView.state.scienceId} />
|
||||
{/if}
|
||||
</main>
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
onClose={() => (sidebarOpen = false)}
|
||||
{historyMode}
|
||||
bind:activeTab
|
||||
/>
|
||||
</div>
|
||||
<BottomTabs
|
||||
activeTool={effectiveTool}
|
||||
onSelectTool={(tool) => (mobileTool = tool)}
|
||||
hideOrder={historyMode}
|
||||
/>
|
||||
<PlanetSheet
|
||||
planet={selectedPlanet}
|
||||
{localShipClass}
|
||||
{localScience}
|
||||
routes={inspectorRoutes}
|
||||
planets={inspectorPlanets}
|
||||
mapWidth={inspectorMapWidth}
|
||||
mapHeight={inspectorMapHeight}
|
||||
localPlayerDrive={inspectorLocalDrive}
|
||||
localShipGroups={inspectorLocalShipGroups}
|
||||
otherShipGroups={inspectorOtherShipGroups}
|
||||
localRace={inspectorLocalRace}
|
||||
onMap={effectiveTool === "map"}
|
||||
onClose={() => selection.clear()}
|
||||
/>
|
||||
<ShipGroupSheet
|
||||
selection={selectedShipGroup}
|
||||
planets={inspectorPlanets}
|
||||
{localShipClass}
|
||||
localFleets={inspectorLocalFleets}
|
||||
otherRaces={inspectorOtherRaces}
|
||||
mapWidth={inspectorMapWidth}
|
||||
mapHeight={inspectorMapHeight}
|
||||
localPlayerDrive={inspectorLocalDrive}
|
||||
localPlayerWeapons={inspectorLocalWeapons}
|
||||
localPlayerShields={inspectorLocalShields}
|
||||
localPlayerCargo={inspectorLocalCargo}
|
||||
onMap={effectiveTool === "map"}
|
||||
onClose={() => selection.clear()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.game-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
.active-view-host {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.body {
|
||||
padding-bottom: 3.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -27,11 +27,10 @@ absent until Phase 24 wires push-event state.
|
||||
import TurnNavigator from "./turn-navigator.svelte";
|
||||
|
||||
type Props = {
|
||||
gameId: string;
|
||||
sidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
};
|
||||
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
|
||||
let { sidebarOpen, onToggleSidebar }: Props = $props();
|
||||
|
||||
const gameState = getContext<GameStateStore | undefined>(
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
@@ -69,7 +68,7 @@ absent until Phase 24 wires push-event state.
|
||||
>
|
||||
⤧
|
||||
</button>
|
||||
<ViewMenu {gameId} />
|
||||
<ViewMenu />
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -7,21 +7,18 @@ itself is identical. The same component is reused for the mobile
|
||||
|
||||
Lists the seven IA destinations: map, tables (sub-list of six
|
||||
entities), report, battle, mail, ship-class designer, science
|
||||
designer. Closes on Escape, on outside click, and after a
|
||||
navigation. Phase 26 introduces the history-mode entry; Phase 35
|
||||
polishes microcopy.
|
||||
designer. Each entry mutates `activeView` (the single-URL app-shell
|
||||
has no per-view routes) and closes the menu. Closes on Escape, on
|
||||
outside click, and after a selection. Phase 26 introduces the
|
||||
history-mode entry; Phase 35 polishes microcopy.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeView, type GameView } from "$lib/app-nav.svelte";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import { mailStore } from "$lib/mail-store.svelte";
|
||||
import { restoreFocus } from "$lib/a11y/restore-focus";
|
||||
|
||||
type Props = { gameId: string };
|
||||
let { gameId }: Props = $props();
|
||||
|
||||
const mailUnread = $derived(mailStore.unreadCount);
|
||||
|
||||
let open = $state(false);
|
||||
@@ -40,9 +37,12 @@ polishes microcopy.
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function go(path: string): void {
|
||||
function select(
|
||||
view: GameView,
|
||||
params: { tableEntity?: string } = {},
|
||||
): void {
|
||||
open = false;
|
||||
void goto(withBase(path));
|
||||
activeView.select(view, params);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
@@ -93,7 +93,7 @@ polishes microcopy.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-map"
|
||||
onclick={() => go(`/games/${gameId}/map`)}
|
||||
onclick={() => select("map")}
|
||||
>
|
||||
{i18n.t("game.view.map")}
|
||||
</button>
|
||||
@@ -105,7 +105,7 @@ polishes microcopy.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-table-{entry.slug}"
|
||||
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)}
|
||||
onclick={() => select("table", { tableEntity: entry.slug })}
|
||||
>
|
||||
{i18n.t(entry.key)}
|
||||
</button>
|
||||
@@ -116,7 +116,7 @@ polishes microcopy.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-report"
|
||||
onclick={() => go(`/games/${gameId}/report`)}
|
||||
onclick={() => select("report")}
|
||||
>
|
||||
{i18n.t("game.view.report")}
|
||||
</button>
|
||||
@@ -124,7 +124,7 @@ polishes microcopy.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-battle"
|
||||
onclick={() => go(`/games/${gameId}/battle`)}
|
||||
onclick={() => select("battle")}
|
||||
>
|
||||
{i18n.t("game.view.battle")}
|
||||
</button>
|
||||
@@ -133,7 +133,7 @@ polishes microcopy.
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-mail"
|
||||
class="with-badge"
|
||||
onclick={() => go(`/games/${gameId}/mail`)}
|
||||
onclick={() => select("mail")}
|
||||
>
|
||||
<span>{i18n.t("game.view.mail")}</span>
|
||||
{#if mailUnread > 0}
|
||||
@@ -146,7 +146,7 @@ polishes microcopy.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-designer-science"
|
||||
onclick={() => go(`/games/${gameId}/designer/science`)}
|
||||
onclick={() => select("designer-science")}
|
||||
>
|
||||
{i18n.t("game.view.designer.science")}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
|
||||
import { createGatewayClient } from "../../api/connect";
|
||||
import { GalaxyClient } from "../../api/galaxy-client";
|
||||
import { LobbyError, createGame } from "../../api/lobby";
|
||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import { loadCore } from "../../platform/core/index";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
|
||||
const DEFAULT_MIN_PLAYERS = 2;
|
||||
const DEFAULT_MAX_PLAYERS = 8;
|
||||
const DEFAULT_START_GAP_HOURS = 24;
|
||||
const DEFAULT_START_GAP_PLAYERS = 2;
|
||||
const DEFAULT_TARGET_ENGINE_VERSION = "v1";
|
||||
|
||||
let gameName = $state("");
|
||||
let description = $state("");
|
||||
let turnSchedule = $state("0 0 * * *");
|
||||
let enrollmentEndsAt = $state("");
|
||||
|
||||
let minPlayers = $state(DEFAULT_MIN_PLAYERS);
|
||||
let maxPlayers = $state(DEFAULT_MAX_PLAYERS);
|
||||
let startGapHours = $state(DEFAULT_START_GAP_HOURS);
|
||||
let startGapPlayers = $state(DEFAULT_START_GAP_PLAYERS);
|
||||
let targetEngineVersion = $state(DEFAULT_TARGET_ENGINE_VERSION);
|
||||
|
||||
let formError: string | null = $state(null);
|
||||
let configError: string | null = $state(null);
|
||||
let submitting = $state(false);
|
||||
|
||||
let client: GalaxyClient | null = null;
|
||||
|
||||
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
function describeLobbyError(err: unknown): string {
|
||||
if (err instanceof LobbyError) {
|
||||
const key = `lobby.error.${err.code}` as TranslationKey;
|
||||
const translated = i18n.t(key);
|
||||
if (translated !== key) {
|
||||
return translated;
|
||||
}
|
||||
return i18n.t("lobby.error.unknown", { message: err.message });
|
||||
}
|
||||
return err instanceof Error ? err.message : "request failed";
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
appScreen.go("lobby");
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
formError = null;
|
||||
const trimmedName = gameName.trim();
|
||||
const trimmedSchedule = turnSchedule.trim();
|
||||
const trimmedEnrollment = enrollmentEndsAt.trim();
|
||||
if (trimmedName === "") {
|
||||
formError = i18n.t("lobby.create.game_name_required");
|
||||
return;
|
||||
}
|
||||
if (trimmedSchedule === "") {
|
||||
formError = i18n.t("lobby.create.turn_schedule_required");
|
||||
return;
|
||||
}
|
||||
if (trimmedEnrollment === "") {
|
||||
formError = i18n.t("lobby.create.enrollment_ends_at_required");
|
||||
return;
|
||||
}
|
||||
const enrollmentDate = new Date(trimmedEnrollment);
|
||||
if (Number.isNaN(enrollmentDate.getTime())) {
|
||||
formError = i18n.t("lobby.create.enrollment_ends_at_required");
|
||||
return;
|
||||
}
|
||||
if (client === null) {
|
||||
formError = configError ?? "client not ready";
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
try {
|
||||
await createGame(client, {
|
||||
gameName: trimmedName,
|
||||
description: description.trim(),
|
||||
minPlayers,
|
||||
maxPlayers,
|
||||
startGapHours,
|
||||
startGapPlayers,
|
||||
enrollmentEndsAt: enrollmentDate,
|
||||
turnSchedule: trimmedSchedule,
|
||||
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
|
||||
});
|
||||
appScreen.go("lobby");
|
||||
} catch (err) {
|
||||
formError = describeLobbyError(err);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (
|
||||
session.keypair === null ||
|
||||
session.deviceSessionId === null ||
|
||||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
|
||||
) {
|
||||
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
|
||||
configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const keypair = session.keypair;
|
||||
const core = await loadCore();
|
||||
client = new GalaxyClient({
|
||||
core,
|
||||
edge: createGatewayClient(gatewayRpcBaseUrl()),
|
||||
signer: (canonical) => keypair.sign(canonical),
|
||||
sha256,
|
||||
deviceSessionId: session.deviceSessionId,
|
||||
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
|
||||
<main id="main-content" tabindex="-1">
|
||||
<h1>{i18n.t("lobby.create.title")}</h1>
|
||||
{#if configError !== null}
|
||||
<p role="alert" data-testid="lobby-create-config-error">{configError}</p>
|
||||
{/if}
|
||||
<form
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
data-testid="lobby-create-form"
|
||||
>
|
||||
<label>
|
||||
{i18n.t("lobby.create.game_name_label")}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={gameName}
|
||||
data-testid="lobby-create-game-name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{i18n.t("lobby.create.description_label")}
|
||||
<textarea
|
||||
bind:value={description}
|
||||
data-testid="lobby-create-description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
{i18n.t("lobby.create.turn_schedule_label")}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={turnSchedule}
|
||||
data-testid="lobby-create-turn-schedule"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<small>{i18n.t("lobby.create.turn_schedule_hint")}</small>
|
||||
</label>
|
||||
<label>
|
||||
{i18n.t("lobby.create.enrollment_ends_at_label")}
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={enrollmentEndsAt}
|
||||
data-testid="lobby-create-enrollment-ends-at"
|
||||
/>
|
||||
</label>
|
||||
<details data-testid="lobby-create-advanced">
|
||||
<summary>{i18n.t("lobby.create.advanced")}</summary>
|
||||
<label>
|
||||
{i18n.t("lobby.create.min_players_label")}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={minPlayers}
|
||||
data-testid="lobby-create-min-players"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{i18n.t("lobby.create.max_players_label")}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={maxPlayers}
|
||||
data-testid="lobby-create-max-players"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{i18n.t("lobby.create.start_gap_hours_label")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={startGapHours}
|
||||
data-testid="lobby-create-start-gap-hours"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{i18n.t("lobby.create.start_gap_players_label")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={startGapPlayers}
|
||||
data-testid="lobby-create-start-gap-players"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{i18n.t("lobby.create.target_engine_version_label")}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={targetEngineVersion}
|
||||
data-testid="lobby-create-target-engine-version"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
</details>
|
||||
{#if formError !== null}
|
||||
<p role="alert" data-testid="lobby-create-error">{formError}</p>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={submitting} data-testid="lobby-create-submit">
|
||||
{submitting ? i18n.t("lobby.create.submitting") : i18n.t("lobby.create.submit")}
|
||||
</button>
|
||||
<button type="button" onclick={cancel} data-testid="lobby-create-cancel">
|
||||
{i18n.t("lobby.create.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
padding: 1.5rem 1rem;
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="datetime-local"],
|
||||
textarea {
|
||||
font-size: 1rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
details {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
details > label {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,605 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { appScreen, activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import { createGatewayClient } from "../../api/connect";
|
||||
import { GalaxyClient } from "../../api/galaxy-client";
|
||||
import {
|
||||
LobbyError,
|
||||
listMyApplications,
|
||||
listMyGames,
|
||||
listMyInvites,
|
||||
listPublicGames,
|
||||
redeemInvite,
|
||||
declineInvite,
|
||||
submitApplication,
|
||||
type ApplicationSummary,
|
||||
type GameSummary,
|
||||
type InviteSummary,
|
||||
} from "../../api/lobby";
|
||||
import { ByteBuffer } from "flatbuffers";
|
||||
import { AccountResponse } from "../../proto/galaxy/fbs/user";
|
||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||
import {
|
||||
SyntheticReportError,
|
||||
loadSyntheticReportFromJSON,
|
||||
} from "../../api/synthetic-report";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import { loadCore } from "../../platform/core/index";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { Builder } from "flatbuffers";
|
||||
import { GetMyAccountRequest } from "../../proto/galaxy/fbs/user";
|
||||
|
||||
let displayName: string | null = $state(null);
|
||||
let configError: string | null = $state(null);
|
||||
let listsLoading = $state(true);
|
||||
let lobbyError: string | null = $state(null);
|
||||
|
||||
let myGames: GameSummary[] = $state([]);
|
||||
let invitations: InviteSummary[] = $state([]);
|
||||
let applications: ApplicationSummary[] = $state([]);
|
||||
let publicGames: GameSummary[] = $state([]);
|
||||
|
||||
let openApplicationFor: string | null = $state(null);
|
||||
let raceNameInput = $state("");
|
||||
let raceNameError: string | null = $state(null);
|
||||
let submittingApplication = $state(false);
|
||||
|
||||
let inviteActionInFlight: string | null = $state(null);
|
||||
|
||||
let syntheticError: string | null = $state(null);
|
||||
|
||||
let client: GalaxyClient | null = null;
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await session.signOut("user");
|
||||
}
|
||||
|
||||
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
function describeLobbyError(err: unknown): string {
|
||||
if (err instanceof LobbyError) {
|
||||
const key = `lobby.error.${err.code}` as TranslationKey;
|
||||
const translated = i18n.t(key);
|
||||
if (translated !== key) {
|
||||
return translated;
|
||||
}
|
||||
return i18n.t("lobby.error.unknown", { message: err.message });
|
||||
}
|
||||
return err instanceof Error ? err.message : "request failed";
|
||||
}
|
||||
|
||||
async function refreshAll(): Promise<void> {
|
||||
if (client === null) return;
|
||||
listsLoading = true;
|
||||
lobbyError = null;
|
||||
try {
|
||||
const [games, invites, apps, publicPage] = await Promise.all([
|
||||
listMyGames(client),
|
||||
listMyInvites(client),
|
||||
listMyApplications(client),
|
||||
listPublicGames(client),
|
||||
]);
|
||||
myGames = games;
|
||||
invitations = invites.filter((invite) => invite.status === "pending");
|
||||
applications = apps;
|
||||
publicGames = publicPage.items;
|
||||
} catch (err) {
|
||||
lobbyError = describeLobbyError(err);
|
||||
} finally {
|
||||
listsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applicationStatusLabel(status: string): string {
|
||||
const key = `lobby.application.status.${status}` as TranslationKey;
|
||||
const translated = i18n.t(key);
|
||||
if (translated === key) {
|
||||
return i18n.t("lobby.application.status.unknown", { status });
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
function openApplicationForm(gameId: string): void {
|
||||
openApplicationFor = gameId;
|
||||
raceNameInput = "";
|
||||
raceNameError = null;
|
||||
}
|
||||
|
||||
function cancelApplicationForm(): void {
|
||||
openApplicationFor = null;
|
||||
raceNameInput = "";
|
||||
raceNameError = null;
|
||||
}
|
||||
|
||||
async function submitApplicationFor(gameId: string): Promise<void> {
|
||||
if (client === null) return;
|
||||
const trimmed = raceNameInput.trim();
|
||||
if (trimmed === "") {
|
||||
raceNameError = i18n.t("lobby.application.race_name_required");
|
||||
return;
|
||||
}
|
||||
submittingApplication = true;
|
||||
raceNameError = null;
|
||||
try {
|
||||
const result = await submitApplication(client, gameId, trimmed);
|
||||
applications = [result, ...applications];
|
||||
openApplicationFor = null;
|
||||
raceNameInput = "";
|
||||
} catch (err) {
|
||||
raceNameError = describeLobbyError(err);
|
||||
} finally {
|
||||
submittingApplication = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptInvite(invite: InviteSummary): Promise<void> {
|
||||
if (client === null) return;
|
||||
inviteActionInFlight = invite.inviteId;
|
||||
try {
|
||||
await redeemInvite(client, invite.gameId, invite.inviteId);
|
||||
invitations = invitations.filter((i) => i.inviteId !== invite.inviteId);
|
||||
myGames = await listMyGames(client);
|
||||
} catch (err) {
|
||||
lobbyError = describeLobbyError(err);
|
||||
} finally {
|
||||
inviteActionInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectInvite(invite: InviteSummary): Promise<void> {
|
||||
if (client === null) return;
|
||||
inviteActionInFlight = invite.inviteId;
|
||||
try {
|
||||
await declineInvite(client, invite.gameId, invite.inviteId);
|
||||
invitations = invitations.filter((i) => i.inviteId !== invite.inviteId);
|
||||
} catch (err) {
|
||||
lobbyError = describeLobbyError(err);
|
||||
} finally {
|
||||
inviteActionInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGreeting(c: GalaxyClient): Promise<void> {
|
||||
const builder = new Builder(32);
|
||||
GetMyAccountRequest.startGetMyAccountRequest(builder);
|
||||
builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder));
|
||||
const result = await c.executeCommand("user.account.get", builder.asUint8Array());
|
||||
if (result.resultCode !== "ok") {
|
||||
return;
|
||||
}
|
||||
const response = AccountResponse.getRootAsAccountResponse(
|
||||
new ByteBuffer(result.payloadBytes),
|
||||
);
|
||||
const account = response.account();
|
||||
if (account === null) {
|
||||
return;
|
||||
}
|
||||
const display = account.displayName();
|
||||
const userName = account.userName();
|
||||
displayName = display && display.length > 0 ? display : userName;
|
||||
}
|
||||
|
||||
function gotoCreate(): void {
|
||||
appScreen.go("lobby-create");
|
||||
}
|
||||
|
||||
function gotoGame(gameId: string): void {
|
||||
// Enter a fresh game on the map view: reset the in-game view
|
||||
// state first so a stale snapshot from a previous game does not
|
||||
// leak into the new one, then switch the top-level screen.
|
||||
activeView.reset();
|
||||
appScreen.go("game", { gameId });
|
||||
}
|
||||
|
||||
async function onSyntheticFileChange(
|
||||
event: Event & { currentTarget: HTMLInputElement },
|
||||
): Promise<void> {
|
||||
// Capture the element synchronously: `event.currentTarget`
|
||||
// is nulled by the time any of the awaits below resolve, and
|
||||
// reaching for it from the `finally` block then throws
|
||||
// "null is not an object". The reset still has to happen so
|
||||
// re-selecting the same file fires `change` again.
|
||||
const input = event.currentTarget;
|
||||
syntheticError = null;
|
||||
const file = input.files?.[0];
|
||||
if (file === undefined) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json: unknown = JSON.parse(text);
|
||||
const { gameId } = loadSyntheticReportFromJSON(json);
|
||||
activeView.reset();
|
||||
appScreen.go("game", { gameId });
|
||||
} catch (err) {
|
||||
if (err instanceof SyntheticReportError) {
|
||||
syntheticError = err.message;
|
||||
} else if (err instanceof SyntaxError) {
|
||||
syntheticError = `invalid JSON: ${err.message}`;
|
||||
} else if (err instanceof Error) {
|
||||
syntheticError = err.message;
|
||||
} else {
|
||||
syntheticError = "failed to load synthetic report";
|
||||
}
|
||||
} finally {
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Statuses for which the game has a navigable in-game view.
|
||||
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
|
||||
// starting, start_failed) and terminal ones (cancelled) stay
|
||||
// non-clickable; entering them otherwise opens the game shell on a
|
||||
// game whose runtime state does not exist yet.
|
||||
function isPlayableStatus(status: string): boolean {
|
||||
return status === "running" || status === "paused" || status === "finished";
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (
|
||||
session.keypair === null ||
|
||||
session.deviceSessionId === null ||
|
||||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
|
||||
) {
|
||||
listsLoading = false;
|
||||
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
|
||||
configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const keypair = session.keypair;
|
||||
try {
|
||||
const core = await loadCore();
|
||||
client = new GalaxyClient({
|
||||
core,
|
||||
edge: createGatewayClient(gatewayRpcBaseUrl()),
|
||||
signer: (canonical) => keypair.sign(canonical),
|
||||
sha256,
|
||||
deviceSessionId: session.deviceSessionId,
|
||||
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
||||
});
|
||||
loadGreeting(client).catch(() => {});
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
lobbyError = describeLobbyError(err);
|
||||
listsLoading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
|
||||
<main id="main-content" tabindex="-1">
|
||||
<header>
|
||||
<h1>{i18n.t("lobby.title")}</h1>
|
||||
<p>
|
||||
{i18n.t("lobby.device_session_id_label")}:
|
||||
<code data-testid="device-session-id">{session.deviceSessionId ?? ""}</code>
|
||||
</p>
|
||||
{#if displayName !== null}
|
||||
<p data-testid="account-greeting">
|
||||
{i18n.t("lobby.greeting", { name: displayName })}
|
||||
</p>
|
||||
{/if}
|
||||
<button onclick={logout} data-testid="lobby-logout">
|
||||
{i18n.t("lobby.logout")}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if configError !== null}
|
||||
<p role="alert" data-testid="account-error">{configError}</p>
|
||||
{:else if lobbyError !== null}
|
||||
<p role="alert" data-testid="lobby-error">{lobbyError}</p>
|
||||
{/if}
|
||||
|
||||
<section data-testid="lobby-create-section">
|
||||
<button onclick={gotoCreate} data-testid="lobby-create-button">
|
||||
{i18n.t("lobby.create_button")}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section data-testid="lobby-my-games-section">
|
||||
<h2>{i18n.t("lobby.section.my_games")}</h2>
|
||||
{#if listsLoading}
|
||||
<p role="status">{i18n.t("lobby.list_loading")}</p>
|
||||
{:else if myGames.length === 0}
|
||||
<p data-testid="lobby-my-games-empty">{i18n.t("lobby.my_games.empty")}</p>
|
||||
{:else}
|
||||
<ul class="card-list">
|
||||
{#each myGames as game (game.gameId)}
|
||||
<li>
|
||||
<button
|
||||
class="card"
|
||||
onclick={() => gotoGame(game.gameId)}
|
||||
disabled={!isPlayableStatus(game.status)}
|
||||
data-testid="lobby-my-game-card"
|
||||
>
|
||||
<strong>{game.gameName}</strong>
|
||||
<span class="meta">{game.status}</span>
|
||||
<span class="meta">{game.minPlayers}–{game.maxPlayers} players</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section data-testid="lobby-invitations-section">
|
||||
<h2>{i18n.t("lobby.section.invitations")}</h2>
|
||||
{#if listsLoading}
|
||||
<p role="status">{i18n.t("lobby.list_loading")}</p>
|
||||
{:else if invitations.length === 0}
|
||||
<p data-testid="lobby-invitations-empty">{i18n.t("lobby.invitations.empty")}</p>
|
||||
{:else}
|
||||
<ul class="card-list">
|
||||
{#each invitations as invite (invite.inviteId)}
|
||||
<li class="card">
|
||||
<strong>{invite.raceName}</strong>
|
||||
<span class="meta">{invite.gameId}</span>
|
||||
<div class="actions">
|
||||
<button
|
||||
onclick={() => acceptInvite(invite)}
|
||||
disabled={inviteActionInFlight === invite.inviteId}
|
||||
data-testid="lobby-invite-accept"
|
||||
>
|
||||
{i18n.t("lobby.invitation.accept")}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => rejectInvite(invite)}
|
||||
disabled={inviteActionInFlight === invite.inviteId}
|
||||
data-testid="lobby-invite-decline"
|
||||
>
|
||||
{i18n.t("lobby.invitation.decline")}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section data-testid="lobby-applications-section">
|
||||
<h2>{i18n.t("lobby.section.applications")}</h2>
|
||||
{#if listsLoading}
|
||||
<p role="status">{i18n.t("lobby.list_loading")}</p>
|
||||
{:else if applications.length === 0}
|
||||
<p data-testid="lobby-applications-empty">{i18n.t("lobby.applications.empty")}</p>
|
||||
{:else}
|
||||
<ul class="card-list">
|
||||
{#each applications as app (app.applicationId)}
|
||||
<li class="card" data-testid="lobby-application-card">
|
||||
<strong>{app.raceName}</strong>
|
||||
<span class="meta">{app.gameId}</span>
|
||||
<span class="status" data-status={app.status}>
|
||||
{applicationStatusLabel(app.status)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true"}
|
||||
<!--
|
||||
Synthetic-report loader. Dev-only affordance for visual testing
|
||||
against rich game states without playing many turns. The JSON
|
||||
is produced offline by the Go CLI in
|
||||
`tools/local-dev/legacy-report/`; see
|
||||
`ui/docs/testing.md#synthetic-reports` for the workflow. Gated
|
||||
on `VITE_GALAXY_DEV_AFFORDANCES` (set in `.env.development` and
|
||||
mirrored by `dev-deploy.yaml`) rather than `import.meta.env.DEV`
|
||||
so the long-lived dev environment can also surface it from a
|
||||
production-mode bundle. The prod build path leaves the flag
|
||||
unset, so the section is stripped from prod chunks.
|
||||
-->
|
||||
<section data-testid="lobby-synthetic-section">
|
||||
<h2>Synthetic test reports (DEV)</h2>
|
||||
<p class="meta">
|
||||
Load a JSON file produced by
|
||||
<code>legacy-report-to-json</code> to open the map view
|
||||
against a synthetic snapshot. Orders compose locally but
|
||||
never reach the server.
|
||||
</p>
|
||||
<label class="synthetic-loader">
|
||||
Load JSON…
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onchange={onSyntheticFileChange}
|
||||
data-testid="lobby-synthetic-file"
|
||||
/>
|
||||
</label>
|
||||
{#if syntheticError !== null}
|
||||
<p role="alert" data-testid="lobby-synthetic-error">
|
||||
{syntheticError}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section data-testid="lobby-public-games-section">
|
||||
<h2>{i18n.t("lobby.section.public_games")}</h2>
|
||||
{#if listsLoading}
|
||||
<p role="status">{i18n.t("lobby.list_loading")}</p>
|
||||
{:else if publicGames.length === 0}
|
||||
<p data-testid="lobby-public-games-empty">{i18n.t("lobby.public_games.empty")}</p>
|
||||
{:else}
|
||||
<ul class="card-list">
|
||||
{#each publicGames as game (game.gameId)}
|
||||
<li class="card">
|
||||
<strong>{game.gameName}</strong>
|
||||
<span class="meta">{game.status}</span>
|
||||
<span class="meta">{game.minPlayers}–{game.maxPlayers} players</span>
|
||||
{#if openApplicationFor === game.gameId}
|
||||
<form
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitApplicationFor(game.gameId);
|
||||
}}
|
||||
data-testid="lobby-application-form"
|
||||
>
|
||||
<label>
|
||||
{i18n.t("lobby.application.race_name_label")}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={raceNameInput}
|
||||
data-testid="lobby-application-race-name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
{#if raceNameError !== null}
|
||||
<p role="alert" data-testid="lobby-application-error">
|
||||
{raceNameError}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submittingApplication}
|
||||
data-testid="lobby-application-submit"
|
||||
>
|
||||
{i18n.t("lobby.application.submit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelApplicationForm}
|
||||
data-testid="lobby-application-cancel"
|
||||
>
|
||||
{i18n.t("lobby.application.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => openApplicationForm(game.gameId)}
|
||||
data-testid="lobby-public-game-apply"
|
||||
>
|
||||
{i18n.t("lobby.application.submit_for", { name: game.gameName })}
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
padding: 1.5rem 1rem;
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
header button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--color-surface-raised);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button.card:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--color-text-faint);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
li.card {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
align-self: flex-start;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-raised);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
font-size: 1rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.synthetic-loader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px dashed var(--color-text-muted);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--color-surface-raised);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.synthetic-loader input[type="file"] {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,345 @@
|
||||
<script lang="ts">
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
import {
|
||||
AuthError,
|
||||
confirmEmailCode,
|
||||
sendEmailCode,
|
||||
} from "../../api/auth";
|
||||
import { GATEWAY_BASE_URL } from "$lib/env";
|
||||
import { i18n, SUPPORTED_LOCALES } from "$lib/i18n/index.svelte";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
|
||||
type Step = "email" | "code";
|
||||
|
||||
let step: Step = $state("email");
|
||||
let email = $state("");
|
||||
let code = $state("");
|
||||
let challengeId: string | null = $state(null);
|
||||
let pending = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Safari ignores `autocomplete="off"` on type=email / login-shaped
|
||||
// fields and pops the Keychain suggester regardless. The classic
|
||||
// workaround is to render the input as `readonly` initially —
|
||||
// Safari does not autofill readonly fields — and drop the
|
||||
// attribute on the first user focus so typing still works. Once
|
||||
// dropped, the flag stays false for the rest of the page life.
|
||||
let emailReadonly = $state(true);
|
||||
let codeReadonly = $state(true);
|
||||
|
||||
function describe(err: unknown): string {
|
||||
if (err instanceof AuthError) {
|
||||
return err.message;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return "request failed";
|
||||
}
|
||||
|
||||
async function submitEmail(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const trimmed = email.trim();
|
||||
if (trimmed.length === 0) {
|
||||
error = i18n.t("login.email_required");
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
|
||||
locale: i18n.locale,
|
||||
});
|
||||
challengeId = result.challengeId;
|
||||
code = "";
|
||||
step = "code";
|
||||
} catch (err) {
|
||||
error = describe(err);
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCode(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const trimmedCode = code.trim();
|
||||
if (trimmedCode.length === 0) {
|
||||
error = i18n.t("login.code_required");
|
||||
return;
|
||||
}
|
||||
if (challengeId === null) {
|
||||
error = i18n.t("login.challenge_expired");
|
||||
step = "email";
|
||||
return;
|
||||
}
|
||||
if (session.keypair === null) {
|
||||
error = i18n.t("login.device_key_not_ready");
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await confirmEmailCode(GATEWAY_BASE_URL, {
|
||||
challengeId,
|
||||
code: trimmedCode,
|
||||
publicKey: session.keypair.publicKey,
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
await session.signIn(result.deviceSessionId);
|
||||
appScreen.go("lobby");
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError && err.code === "invalid_request") {
|
||||
challengeId = null;
|
||||
code = "";
|
||||
step = "email";
|
||||
error = i18n.t("login.code_expired_or_used");
|
||||
} else {
|
||||
error = describe(err);
|
||||
}
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resend(): Promise<void> {
|
||||
if (pending) return;
|
||||
const trimmed = email.trim();
|
||||
if (trimmed.length === 0) {
|
||||
step = "email";
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
|
||||
locale: i18n.locale,
|
||||
});
|
||||
challengeId = result.challengeId;
|
||||
code = "";
|
||||
} catch (err) {
|
||||
error = describe(err);
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changeEmail(): void {
|
||||
challengeId = null;
|
||||
code = "";
|
||||
error = null;
|
||||
step = "email";
|
||||
}
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
|
||||
<main id="main-content" tabindex="-1">
|
||||
<header>
|
||||
<h1>{i18n.t("login.title")}</h1>
|
||||
<div class="language-picker">
|
||||
<svg
|
||||
class="globe"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12 3a13 13 0 0 1 0 18M12 3a13 13 0 0 0 0 18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
<label class="sr-only" for="login-language-select">
|
||||
{i18n.t("common.language")}
|
||||
</label>
|
||||
<select
|
||||
id="login-language-select"
|
||||
data-testid="login-language-select"
|
||||
bind:value={i18n.locale}
|
||||
>
|
||||
{#each SUPPORTED_LOCALES as locale (locale.code)}
|
||||
<option value={locale.code}>{locale.nativeName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if step === "email"}
|
||||
<form
|
||||
onsubmit={submitEmail}
|
||||
aria-busy={pending}
|
||||
autocomplete="off"
|
||||
>
|
||||
<label>
|
||||
{i18n.t("login.email_label")}
|
||||
<input
|
||||
type="email"
|
||||
name="galaxy-login-email"
|
||||
autocomplete="new-password"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
readonly={emailReadonly}
|
||||
onfocus={() => (emailReadonly = false)}
|
||||
bind:value={email}
|
||||
disabled={pending}
|
||||
required
|
||||
data-testid="login-email-input"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
data-testid="login-email-submit"
|
||||
>
|
||||
{pending ? i18n.t("login.sending") : i18n.t("login.send_code")}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form
|
||||
onsubmit={submitCode}
|
||||
aria-busy={pending}
|
||||
autocomplete="off"
|
||||
>
|
||||
<p data-testid="login-code-target">
|
||||
{i18n.t("login.code_sent_to", { email })}
|
||||
</p>
|
||||
<label>
|
||||
{i18n.t("login.code_label")}
|
||||
<input
|
||||
type="text"
|
||||
name="galaxy-login-code"
|
||||
inputmode="numeric"
|
||||
autocomplete="new-password"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
readonly={codeReadonly}
|
||||
onfocus={() => (codeReadonly = false)}
|
||||
bind:value={code}
|
||||
disabled={pending}
|
||||
required
|
||||
data-testid="login-code-input"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={pending} data-testid="login-code-submit">
|
||||
{pending ? i18n.t("login.verifying") : i18n.t("login.verify")}
|
||||
</button>
|
||||
<div class="secondary">
|
||||
<button
|
||||
type="button"
|
||||
onclick={resend}
|
||||
disabled={pending}
|
||||
data-testid="login-resend"
|
||||
>
|
||||
{i18n.t("login.send_new_code")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={changeEmail}
|
||||
disabled={pending}
|
||||
data-testid="login-change-email"
|
||||
>
|
||||
{i18n.t("login.change_email")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if error !== null}
|
||||
<p role="alert" data-testid="login-error">{error}</p>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.language-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.globe {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.language-picker select {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
form > label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.secondary button {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
[role="alert"] {
|
||||
margin-top: 1rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +1,31 @@
|
||||
<!--
|
||||
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map navigates
|
||||
to `/games/:id/map` and resets the tool overlay. Calc and Order also
|
||||
navigate to `/games/:id/map` — the layout's tool gate replaces the
|
||||
active view with the matching sidebar tool only when the URL is
|
||||
`/map`, so navigating to any other view via the More drawer or the
|
||||
header view-menu naturally drops the overlay.
|
||||
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map switches
|
||||
the active view to the map and resets the tool overlay. Calc and
|
||||
Order also switch to the map view — the shell's tool gate replaces
|
||||
the active view with the matching sidebar tool only while the map is
|
||||
the active view, so navigating to any other view via the More drawer
|
||||
or the header view-menu naturally drops the overlay.
|
||||
|
||||
More opens a drawer with the same destination list as the header
|
||||
view-menu. Phase 35 polish narrows it to the IA-spec subset
|
||||
(Mail, Battle log, Tables, History, Settings, Logout) once History
|
||||
exists; until then the convenience of one source of truth for
|
||||
destinations beats the duplication.
|
||||
view-menu, each entry mutating `activeView` directly (the single-URL
|
||||
app-shell has no per-view routes). Phase 35 polish narrows it to the
|
||||
IA-spec subset (Mail, Battle log, Tables, History, Settings, Logout)
|
||||
once History exists; until then the convenience of one source of
|
||||
truth for destinations beats the duplication.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeView, type GameView } from "$lib/app-nav.svelte";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import { restoreFocus } from "$lib/a11y/restore-focus";
|
||||
import type { MobileTool } from "./types";
|
||||
|
||||
type Props = {
|
||||
gameId: string;
|
||||
activeTool: MobileTool;
|
||||
onSelectTool: (tool: MobileTool) => void;
|
||||
hideOrder?: boolean;
|
||||
};
|
||||
let {
|
||||
gameId,
|
||||
activeTool,
|
||||
onSelectTool,
|
||||
hideOrder = false,
|
||||
@@ -45,16 +43,18 @@ destinations beats the duplication.
|
||||
{ slug: "races", key: "game.view.table.races" },
|
||||
];
|
||||
|
||||
async function selectTool(tool: MobileTool): Promise<void> {
|
||||
function selectTool(tool: MobileTool): void {
|
||||
moreOpen = false;
|
||||
onSelectTool(tool);
|
||||
await goto(withBase(`/games/${gameId}/map`));
|
||||
// Calc / Order surface only over the map; selecting Map simply
|
||||
// drops the overlay. Either way the map must be the active view.
|
||||
activeView.select("map");
|
||||
}
|
||||
|
||||
async function go(path: string): Promise<void> {
|
||||
function go(view: GameView, params: { tableEntity?: string } = {}): void {
|
||||
moreOpen = false;
|
||||
onSelectTool("map");
|
||||
await goto(withBase(path));
|
||||
activeView.select(view, params);
|
||||
}
|
||||
|
||||
function toggleMore(): void {
|
||||
@@ -143,7 +143,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-map"
|
||||
onclick={() => go(`/games/${gameId}/map`)}
|
||||
onclick={() => go("map")}
|
||||
>
|
||||
{i18n.t("game.view.map")}
|
||||
</button>
|
||||
@@ -155,7 +155,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-table-{entry.slug}"
|
||||
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)}
|
||||
onclick={() => go("table", { tableEntity: entry.slug })}
|
||||
>
|
||||
{i18n.t(entry.key)}
|
||||
</button>
|
||||
@@ -166,7 +166,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-report"
|
||||
onclick={() => go(`/games/${gameId}/report`)}
|
||||
onclick={() => go("report")}
|
||||
>
|
||||
{i18n.t("game.view.report")}
|
||||
</button>
|
||||
@@ -174,7 +174,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-battle"
|
||||
onclick={() => go(`/games/${gameId}/battle`)}
|
||||
onclick={() => go("battle")}
|
||||
>
|
||||
{i18n.t("game.view.battle")}
|
||||
</button>
|
||||
@@ -182,7 +182,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-mail"
|
||||
onclick={() => go(`/games/${gameId}/mail`)}
|
||||
onclick={() => go("mail")}
|
||||
>
|
||||
{i18n.t("game.view.mail")}
|
||||
</button>
|
||||
@@ -190,7 +190,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-designer-science"
|
||||
onclick={() => go(`/games/${gameId}/designer/science`)}
|
||||
onclick={() => go("designer-science")}
|
||||
>
|
||||
{i18n.t("game.view.designer.science")}
|
||||
</button>
|
||||
|
||||
@@ -15,7 +15,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
@@ -67,7 +67,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
// Reset the design when the active game changes; a no-op otherwise, so
|
||||
// the design persists across tab switches within a game.
|
||||
$effect(() => {
|
||||
cs.ensureGame(page.params.id ?? "");
|
||||
cs.ensureGame(appScreen.gameId ?? "");
|
||||
});
|
||||
|
||||
const core = $derived(coreHandle?.core ?? null);
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
<!--
|
||||
Sidebar with three tabs (Calculator, Inspector, Order). The parent
|
||||
layout decides whether the sidebar is rendered at all (mobile hides
|
||||
shell decides whether the sidebar is rendered at all (mobile hides
|
||||
it, tablet collapses it behind the header toggle, desktop keeps it
|
||||
always visible). State preservation across active-view switches
|
||||
works for free because the layout never remounts when the user
|
||||
navigates within `/games/:id/*`.
|
||||
|
||||
The optional `?sidebar=calc|calculator|inspector|order` URL param
|
||||
seeds the initial tab on first mount — used by the lobby card path
|
||||
when later phases want to land directly on a particular tool.
|
||||
works for free because the shell never remounts when the user
|
||||
switches the active view within a game.
|
||||
|
||||
The `historyMode` prop hides the Order tab when true: the tab-bar
|
||||
filters it out and any URL seed targeting `order` falls back to
|
||||
`inspector`. Phase 12 wires the prop through the layout as a
|
||||
constant `false`; Phase 26 flips it on for past-turn snapshots.
|
||||
filters it out and the history-mode reset falls back to `inspector`.
|
||||
Phase 12 wires the prop through the shell as a constant `false`;
|
||||
Phase 26 flips it on for past-turn snapshots.
|
||||
|
||||
`activeTab` is a `$bindable` prop so the layout can drive it from
|
||||
`activeTab` is a `$bindable` prop so the shell can drive it from
|
||||
external events (Phase 13 reveals the inspector tab when a planet
|
||||
is clicked on the map). The URL seed and the history-mode reset
|
||||
both mutate the bindable in place; the layout sees the change
|
||||
through the binding without extra plumbing.
|
||||
is clicked on the map). The history-mode reset mutates the bindable
|
||||
in place; the shell sees the change through the binding without
|
||||
extra plumbing.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import TabBar from "./tab-bar.svelte";
|
||||
import Calculator from "./calculator-tab.svelte";
|
||||
import Inspector from "./inspector-tab.svelte";
|
||||
@@ -44,29 +38,11 @@ through the binding without extra plumbing.
|
||||
activeTab = $bindable<SidebarTab>("inspector"),
|
||||
}: Props = $props();
|
||||
|
||||
function readUrlSeed(): SidebarTab | null {
|
||||
const v = page.url.searchParams.get("sidebar");
|
||||
if (v === "calc" || v === "calculator") return "calculator";
|
||||
if (v === "inspector") return "inspector";
|
||||
if (v === "order") return "order";
|
||||
return null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (historyMode && activeTab === "order") {
|
||||
activeTab = "inspector";
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const seed = readUrlSeed();
|
||||
if (seed === null) return;
|
||||
if (seed === "order" && historyMode) {
|
||||
activeTab = "inspector";
|
||||
return;
|
||||
}
|
||||
activeTab = seed;
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside
|
||||
|
||||
Reference in New Issue
Block a user