feat(ui): single-URL game app-shell (in-memory screens/views) #35
@@ -12,9 +12,8 @@ header now — we just hand the routes down as callbacks so the
|
|||||||
viewer keeps its prop-driven contract.
|
viewer keeps its prop-driven contract.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BattleFetchError,
|
BattleFetchError,
|
||||||
@@ -127,10 +126,10 @@ viewer keeps its prop-driven contract.
|
|||||||
});
|
});
|
||||||
|
|
||||||
function backToReport() {
|
function backToReport() {
|
||||||
goto(withBase(`/games/${gameId}/report`));
|
activeView.select("report");
|
||||||
}
|
}
|
||||||
function backToMap() {
|
function backToMap() {
|
||||||
goto(withBase(`/games/${gameId}/map`));
|
activeView.select("map");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ fractions is a Phase 21 decision documented in
|
|||||||
`ui/docs/science-designer-ux.md`.
|
`ui/docs/science-designer-ux.md`.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { getContext, tick } from "svelte";
|
import { getContext, tick } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
import { page } from "$app/state";
|
|
||||||
|
|
||||||
import type { ScienceSummary } from "../../api/game-state";
|
import type { ScienceSummary } from "../../api/game-state";
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
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,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
);
|
);
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
// `scienceId` is the only sub-parameter the science designer needs;
|
||||||
const scienceId = $derived(page.params.scienceId ?? "");
|
// 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 isViewMode = $derived(scienceId !== "");
|
||||||
|
|
||||||
const localScience = $derived<ScienceSummary[]>(
|
const localScience = $derived<ScienceSummary[]>(
|
||||||
@@ -126,7 +127,7 @@ fractions is a Phase 21 decision documented in
|
|||||||
}
|
}
|
||||||
|
|
||||||
function backToTable(): void {
|
function backToTable(): void {
|
||||||
void goto(withBase(`/games/${gameId}/table/sciences`));
|
activeView.select("table", { tableEntity: "sciences" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(): Promise<void> {
|
async function save(): Promise<void> {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ pane, system-item pane, compose form) live under
|
|||||||
`./mail/*.svelte`.
|
`./mail/*.svelte`.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/state";
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import { mailStore, type MailListEntry } from "$lib/mail-store.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 selectedKey = $state<string | null>(null);
|
||||||
let composeOpen = $state(false);
|
let composeOpen = $state(false);
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
const gameId = $derived(appScreen.gameId ?? "");
|
||||||
|
|
||||||
const entries = $derived(mailStore.entries);
|
const entries = $derived(mailStore.entries);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!--
|
<!--
|
||||||
Phase 11 map active view: integrates the Phase 9 renderer with the
|
Phase 11 map active view: integrates the Phase 9 renderer with the
|
||||||
per-game `GameStateStore` provided through context by
|
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
|
once the store has produced a report and re-mounts when the
|
||||||
report's turn changes (a refresh that returns the same turn keeps
|
report's turn changes (a refresh that returns the same turn keeps
|
||||||
the existing renderer instance alive). Empty-planet reports render
|
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.
|
preference the store already manages.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { getContext, onDestroy, onMount, untrack } from "svelte";
|
import { getContext, onDestroy, onMount, untrack } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
import { page } from "$app/state";
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import {
|
import {
|
||||||
createRenderer,
|
createRenderer,
|
||||||
@@ -615,6 +613,29 @@ preference the store already manages.
|
|||||||
// through the same `hit-test` plumbing — the hitLookup map keyed
|
// through the same `hit-test` plumbing — the hitLookup map keyed
|
||||||
// by primitive id resolves a hit back to either a planet or a
|
// by primitive id resolves a hit back to either a planet or a
|
||||||
// ship-group selection variant.
|
// 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 {
|
function handleMapClick(cursorPx: { x: number; y: number }): void {
|
||||||
if (handle === null || store?.report === undefined || store.report === null) {
|
if (handle === null || store?.report === undefined || store.report === null) {
|
||||||
return;
|
return;
|
||||||
@@ -634,26 +655,20 @@ preference the store already manages.
|
|||||||
selection.selectShipGroup(target.ref);
|
selection.selectShipGroup(target.ref);
|
||||||
break;
|
break;
|
||||||
case "battle": {
|
case "battle": {
|
||||||
const gameId = page.params.id ?? "";
|
|
||||||
const turn = store?.report?.turn ?? 0;
|
const turn = store?.report?.turn ?? 0;
|
||||||
void goto(
|
activeView.select("battle", {
|
||||||
withBase(`/games/${gameId}/battle/${target.battleId}?turn=${turn}`),
|
battleId: target.battleId,
|
||||||
);
|
turn,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "bombing": {
|
case "bombing": {
|
||||||
const gameId = page.params.id ?? "";
|
activeView.select("report");
|
||||||
void goto(
|
// The report sections render reactively after the view
|
||||||
withBase(`/games/${gameId}/report#report-bombings`),
|
// switches above, so there is no navigation promise to
|
||||||
).then(() => {
|
// await; poll a bounded number of animation frames for
|
||||||
if (typeof document === "undefined") return;
|
// the bombing row, then scroll it into view.
|
||||||
const row = document.querySelector(
|
scrollToBombingRow(target.planet);
|
||||||
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
|
|
||||||
);
|
|
||||||
if (row && row.scrollIntoView) {
|
|
||||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ TOC and the body iterate the same data.
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { page } from "$app/state";
|
|
||||||
|
|
||||||
import ReportToc, {
|
import ReportToc, {
|
||||||
type TocEntry,
|
type TocEntry,
|
||||||
@@ -71,8 +70,6 @@ TOC and the body iterate the same data.
|
|||||||
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
|
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
|
||||||
|
|
||||||
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
|
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
|
||||||
let bodyEl: HTMLDivElement | null = $state(null);
|
let bodyEl: HTMLDivElement | null = $state(null);
|
||||||
|
|
||||||
@@ -116,7 +113,7 @@ TOC and the body iterate the same data.
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="report-view" data-testid="active-view-report">
|
<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}>
|
<div class="report-body" bind:this={bodyEl}>
|
||||||
<SectionGalaxySummary />
|
<SectionGalaxySummary />
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ Phase 23 Report View table of contents.
|
|||||||
|
|
||||||
Responsibilities:
|
Responsibilities:
|
||||||
- "Back to map" button at the top — visible on both desktop sidebar
|
- "Back to map" button at the top — visible on both desktop sidebar
|
||||||
and mobile sticky toolbar. Navigates via `$app/navigation.goto` so
|
and mobile sticky toolbar. Switches the active view to the map
|
||||||
active-view-host scroll restoration plays through SvelteKit's
|
through `activeView.select("map")`; the shell's tool gate resets
|
||||||
history machinery and the layout's `mobileTool` resets naturally.
|
the `mobileTool` overlay naturally once the map is no longer the
|
||||||
|
active view.
|
||||||
- Desktop / tablet sidebar: a vertical list of anchor links, one per
|
- Desktop / tablet sidebar: a vertical list of anchor links, one per
|
||||||
section. The active link gets `aria-current="location"` and a
|
section. The active link gets `aria-current="location"` and a
|
||||||
`.active` style. Click scrolls the active-view-host (not the
|
`.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.
|
`activeSlug` prop. The TOC itself owns no observers.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
|
|
||||||
@@ -33,10 +33,9 @@ The active section is computed by the orchestrator
|
|||||||
type Props = {
|
type Props = {
|
||||||
entries: readonly TocEntry[];
|
entries: readonly TocEntry[];
|
||||||
activeSlug: string;
|
activeSlug: string;
|
||||||
gameId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let { entries, activeSlug, gameId }: Props = $props();
|
let { entries, activeSlug }: Props = $props();
|
||||||
|
|
||||||
function scrollToSlug(slug: string): void {
|
function scrollToSlug(slug: string): void {
|
||||||
const target = document.getElementById(`report-${slug}`);
|
const target = document.getElementById(`report-${slug}`);
|
||||||
@@ -62,8 +61,8 @@ The active section is computed by the orchestrator
|
|||||||
scrollToSlug(slug);
|
scrollToSlug(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function backToMap(): Promise<void> {
|
function backToMap(): void {
|
||||||
await goto(withBase(`/games/${gameId}/map`));
|
activeView.select("map");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<!--
|
<!--
|
||||||
Phase 27 Report View — battles section. Each row is a link into the
|
Phase 27 Report View — battles section. Each row opens the Battle
|
||||||
Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where
|
Viewer through `activeView.select("battle", { battleId, turn })`
|
||||||
`turn` follows the current report's turn so history-mode views land
|
where `turn` follows the current report's turn so history-mode views
|
||||||
on the right battle. Phase 23 rendered the same rows as inactive
|
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
|
monospace `<span>`; the rewire here is the one-liner the Phase 23
|
||||||
decision log called out.
|
decision log called out.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { page } from "$app/state";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +21,11 @@ decision log called out.
|
|||||||
);
|
);
|
||||||
const report = $derived(rendered?.report ?? null);
|
const report = $derived(rendered?.report ?? null);
|
||||||
const battles = $derived(report?.battles ?? []);
|
const battles = $derived(report?.battles ?? []);
|
||||||
const gameId = $derived(page.params.id ?? "");
|
|
||||||
const turn = $derived(report?.turn ?? 0);
|
const turn = $derived(report?.turn ?? 0);
|
||||||
|
|
||||||
|
function openBattle(battleId: string): void {
|
||||||
|
activeView.select("battle", { battleId, turn });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -46,12 +48,13 @@ decision log called out.
|
|||||||
<span class="label">
|
<span class="label">
|
||||||
{i18n.t("game.report.section.battles.id_label")}
|
{i18n.t("game.report.section.battles.id_label")}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<button
|
||||||
|
type="button"
|
||||||
class="uuid"
|
class="uuid"
|
||||||
href={withBase(`/games/${gameId}/battle/${b.id}?turn=${turn}`)}
|
onclick={() => openBattle(b.id)}
|
||||||
data-testid="report-battle-row"
|
data-testid="report-battle-row"
|
||||||
data-id={b.id}
|
data-id={b.id}
|
||||||
>{b.id}</a>
|
>{b.id}</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -90,10 +93,15 @@ decision log called out.
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
.uuid {
|
.uuid {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.uuid:hover {
|
.uuid:hover {
|
||||||
color: var(--color-text);
|
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
|
`[0, 1]` and surfaced here as percentages with one decimal so the
|
||||||
table matches the designer's input units.
|
table matches the designer's input units.
|
||||||
|
|
||||||
The component sits inside the active-view slot owned by
|
The component sits inside the active-view area owned by
|
||||||
`routes/games/[id]/+layout.svelte`, so it inherits the per-game
|
`lib/game/game-shell.svelte`, so it inherits the per-game
|
||||||
`OrderDraftStore` and `RenderedReportSource` through context. No
|
`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">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
import { page } from "$app/state";
|
|
||||||
|
|
||||||
import type { ScienceSummary } from "../../api/game-state";
|
import type { ScienceSummary } from "../../api/game-state";
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
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,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
);
|
);
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
|
||||||
|
|
||||||
let sortColumn: SortColumn = $state("name");
|
let sortColumn: SortColumn = $state("name");
|
||||||
let sortDirection: SortDirection = $state("asc");
|
let sortDirection: SortDirection = $state("asc");
|
||||||
let filter: string = $state("");
|
let filter: string = $state("");
|
||||||
@@ -118,11 +114,11 @@ data fetching is performed here — the layout is responsible.
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openDesigner(name: string): void {
|
function openDesigner(name: string): void {
|
||||||
void goto(withBase(`/games/${gameId}/designer/science/${encodeURIComponent(name)}`));
|
activeView.select("designer-science", { scienceId: name });
|
||||||
}
|
}
|
||||||
|
|
||||||
function newScience(): void {
|
function newScience(): void {
|
||||||
void goto(withBase(`/games/${gameId}/designer/science`));
|
activeView.select("designer-science");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteScience(name: string): Promise<void> {
|
async function deleteScience(name: string): Promise<void> {
|
||||||
|
|||||||
+110
-76
@@ -1,59 +1,63 @@
|
|||||||
<!--
|
<!--
|
||||||
Phase 10 in-game shell. Composes the header, a conditionally-visible
|
In-game shell. Composes the header, a conditionally-visible sidebar
|
||||||
sidebar (Calculator / Inspector / Order tabs), the active-view slot
|
(Calculator / Inspector / Order tabs), the active-view area selected
|
||||||
filled by the child route, and a mobile-only bottom-tab bar. The
|
by `activeView`, and a mobile-only bottom-tab bar. In the single-URL
|
||||||
layout owns:
|
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
|
- `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the
|
||||||
sidebar pinned via CSS; mobile hides it entirely.
|
sidebar pinned via CSS; mobile hides it entirely.
|
||||||
- `mobileTool` — mobile-only tool overlay state. The tool only
|
- `mobileTool` — mobile-only tool overlay state. The tool only
|
||||||
visually overrides the active-view slot when the URL is `/map`,
|
visually overrides the active-view area when the active view is the
|
||||||
so navigating to any other view through the More drawer or the
|
map, so switching to any other view through the More drawer or the
|
||||||
header view-menu naturally drops the overlay even if `mobileTool`
|
header view-menu naturally drops the overlay even if `mobileTool`
|
||||||
was set on a previous tap.
|
was set on a previous tap.
|
||||||
- `activeTab` — current sidebar tool (`calculator` / `inspector` /
|
- `activeTab` — current sidebar tool (`calculator` / `inspector` /
|
||||||
`order`). Held here, bound into the sidebar so a planet click on
|
`order`). Held here, bound into the sidebar so a planet click on
|
||||||
the map can flip it to `inspector` from the outside (Phase 13).
|
the map can flip it to `inspector` from the outside.
|
||||||
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
|
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
|
||||||
Phase 13 `SelectionStore`. All three are exposed to descendants
|
`SelectionStore`. All three are exposed to descendants via Svelte
|
||||||
via Svelte context; their lifetimes match the layout instance,
|
context; their lifetimes match the shell instance.
|
||||||
which itself stays mounted across active-view switches inside
|
|
||||||
`/games/:id/*`.
|
|
||||||
|
|
||||||
Phase 11 added the per-game `GameStateStore` instance owned by this
|
The per-game `GameStateStore` constructs the `GalaxyClient`, fetches
|
||||||
layout: it constructs the `GalaxyClient`, fetches the matching lobby
|
the matching lobby record to discover `current_turn`, then loads the
|
||||||
record to discover `current_turn`, then loads the report. The store
|
report. The store is shared with descendants via
|
||||||
is shared with descendants via `setContext("gameState", ...)` so the
|
`setContext(GAME_STATE_CONTEXT_KEY, ...)` so the header turn counter,
|
||||||
header turn counter, the map view, and the inspector tab all read
|
the map view, and the inspector tab all read from the same snapshot.
|
||||||
from the same snapshot.
|
|
||||||
|
|
||||||
Phase 13 adds the planet inspector. The layout watches the selection
|
The planet inspector: the shell watches the selection store and, on
|
||||||
store and, on the null → planet transition, flips `activeTab` to
|
the null → planet transition, flips `activeTab` to `inspector` and
|
||||||
`inspector` and `sidebarOpen` to `true` so the inspector becomes
|
`sidebarOpen` to `true` so the inspector becomes visible regardless
|
||||||
visible regardless of breakpoint (desktop already has the sidebar
|
of breakpoint (desktop already has the sidebar pinned; tablet needs
|
||||||
pinned; tablet needs the drawer to surface). On mobile the
|
the drawer to surface). On mobile the `<PlanetSheet />` overlay reads
|
||||||
`<PlanetSheet />` overlay reads the same selection and displays a
|
the same selection and displays a read-only sheet over the map;
|
||||||
read-only sheet over the map; closing the sheet clears the
|
closing the sheet clears the selection.
|
||||||
selection.
|
|
||||||
|
|
||||||
State preservation across active-view switches works for free
|
The per-game bootstrap (client construction, store init, push-event
|
||||||
because SvelteKit keeps this layout instance mounted while children
|
subscriptions) runs from an `$effect` keyed on `appScreen.gameId`:
|
||||||
swap; navigating between games unmounts and remounts the layout, so
|
the cleanup tears the previous game's subscriptions down and the body
|
||||||
the next game's snapshot — and the next game's selection — start
|
re-initialises the shared stores for the new id, so a direct
|
||||||
fresh.
|
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">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
import { onDestroy, setContext, untrack } from "svelte";
|
||||||
import { onDestroy, onMount, setContext, untrack } from "svelte";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import { appScreen, activeView } from "$lib/app-nav.svelte";
|
||||||
import Header from "$lib/header/header.svelte";
|
import Header from "$lib/header/header.svelte";
|
||||||
import HistoryBanner from "$lib/header/history-banner.svelte";
|
import HistoryBanner from "$lib/header/history-banner.svelte";
|
||||||
import Sidebar from "$lib/sidebar/sidebar.svelte";
|
import Sidebar from "$lib/sidebar/sidebar.svelte";
|
||||||
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
|
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
|
||||||
import Calculator from "$lib/sidebar/calculator-tab.svelte";
|
import Calculator from "$lib/sidebar/calculator-tab.svelte";
|
||||||
import Order from "$lib/sidebar/order-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 PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
|
||||||
import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
|
import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
|
||||||
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
|
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
|
||||||
@@ -71,7 +75,7 @@ fresh.
|
|||||||
import {
|
import {
|
||||||
ORDER_DRAFT_CONTEXT_KEY,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
OrderDraftStore,
|
OrderDraftStore,
|
||||||
} from "../../../sync/order-draft.svelte";
|
} from "../../sync/order-draft.svelte";
|
||||||
import {
|
import {
|
||||||
MAP_PICK_CONTEXT_KEY,
|
MAP_PICK_CONTEXT_KEY,
|
||||||
MapPickService,
|
MapPickService,
|
||||||
@@ -85,30 +89,30 @@ fresh.
|
|||||||
CoreHolder,
|
CoreHolder,
|
||||||
} from "$lib/core-context.svelte";
|
} from "$lib/core-context.svelte";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
import { loadStore } from "../../../platform/store/index";
|
import { loadStore } from "../../platform/store/index";
|
||||||
import { loadCore } from "../../../platform/core/index";
|
import { loadCore } from "../../platform/core/index";
|
||||||
import { createGatewayClient } from "../../../api/connect";
|
import { createGatewayClient } from "../../api/connect";
|
||||||
import { GalaxyClient } from "../../../api/galaxy-client";
|
import { GalaxyClient } from "../../api/galaxy-client";
|
||||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||||
import {
|
import {
|
||||||
getSyntheticReport,
|
getSyntheticReport,
|
||||||
isSyntheticGameId,
|
isSyntheticGameId,
|
||||||
} from "../../../api/synthetic-report";
|
} from "../../api/synthetic-report";
|
||||||
import {
|
import {
|
||||||
eventStream,
|
eventStream,
|
||||||
type VerifiedEvent,
|
type VerifiedEvent,
|
||||||
} from "../../../api/events.svelte";
|
} from "../../api/events.svelte";
|
||||||
import { toast } from "$lib/toast.svelte";
|
import { toast } from "$lib/toast.svelte";
|
||||||
import { mailStore } from "$lib/mail-store.svelte";
|
import { mailStore } from "$lib/mail-store.svelte";
|
||||||
|
|
||||||
let { children } = $props();
|
|
||||||
|
|
||||||
let sidebarOpen = $state(false);
|
let sidebarOpen = $state(false);
|
||||||
let mobileTool: MobileTool = $state("map");
|
let mobileTool: MobileTool = $state("map");
|
||||||
let activeTab: SidebarTab = $state("inspector");
|
let activeTab: SidebarTab = $state("inspector");
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
// The tool overlay (Calculator / Order) only replaces the active
|
||||||
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
|
// 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(() =>
|
const effectiveTool: MobileTool = $derived.by(() =>
|
||||||
isOnMap ? mobileTool : "map",
|
isOnMap ? mobileTool : "map",
|
||||||
);
|
);
|
||||||
@@ -363,18 +367,45 @@ fresh.
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
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> => {
|
(async (): Promise<void> => {
|
||||||
// DEV-only synthetic-report path. The lobby's "Load
|
// DEV-only synthetic-report path. The lobby's "Load
|
||||||
// synthetic report" affordance navigates here with a
|
// synthetic report" affordance enters the game with a
|
||||||
// `synthetic-<uuid>` id and the matching report
|
// `synthetic-<uuid>` id and the matching report
|
||||||
// pre-registered in an in-memory map. A page reload
|
// pre-registered in an in-memory map. A page reload
|
||||||
// loses the map entry; that case redirects to /lobby
|
// loses the map entry; that case returns to the lobby
|
||||||
// so the user reloads the JSON.
|
// so the user reloads the JSON.
|
||||||
if (isSyntheticGameId(gameId)) {
|
if (isSyntheticGameId(activeGameId)) {
|
||||||
const report = getSyntheticReport(gameId);
|
const report = getSyntheticReport(activeGameId);
|
||||||
if (report === undefined) {
|
if (report === undefined) {
|
||||||
await goto(withBase("/lobby"));
|
appScreen.go("lobby");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -392,8 +423,8 @@ fresh.
|
|||||||
]);
|
]);
|
||||||
coreHolder.set(core);
|
coreHolder.set(core);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
gameState.initSynthetic({ cache, gameId, report }),
|
gameState.initSynthetic({ cache, gameId: activeGameId, report }),
|
||||||
orderDraft.init({ cache, gameId }),
|
orderDraft.init({ cache, gameId: activeGameId }),
|
||||||
]);
|
]);
|
||||||
// Deliberately no `galaxyClient.set` and no
|
// Deliberately no `galaxyClient.set` and no
|
||||||
// `orderDraft.bindClient`: synthetic mode never
|
// `orderDraft.bindClient`: synthetic mode never
|
||||||
@@ -439,7 +470,7 @@ fresh.
|
|||||||
// became history at the cutoff.
|
// became history at the cutoff.
|
||||||
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
|
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
|
||||||
const parsed = parseTurnReadyPayload(event);
|
const parsed = parseTurnReadyPayload(event);
|
||||||
if (parsed === null || parsed.gameId !== gameId) {
|
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
gameState.markPendingTurn(parsed.turn);
|
gameState.markPendingTurn(parsed.turn);
|
||||||
@@ -455,7 +486,7 @@ fresh.
|
|||||||
});
|
});
|
||||||
unsubGamePaused = eventStream.on("game.paused", (event) => {
|
unsubGamePaused = eventStream.on("game.paused", (event) => {
|
||||||
const parsed = parseGamePausedPayload(event);
|
const parsed = parseGamePausedPayload(event);
|
||||||
if (parsed === null || parsed.gameId !== gameId) {
|
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
orderDraft.markPaused({ reason: parsed.reason });
|
orderDraft.markPaused({ reason: parsed.reason });
|
||||||
@@ -464,7 +495,7 @@ fresh.
|
|||||||
"diplomail.message.received",
|
"diplomail.message.received",
|
||||||
(event) => {
|
(event) => {
|
||||||
const parsed = parseMailReceivedPayload(event);
|
const parsed = parseMailReceivedPayload(event);
|
||||||
if (parsed === null || parsed.gameId !== gameId) {
|
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void mailStore.applyPushEvent(parsed.gameId);
|
void mailStore.applyPushEvent(parsed.gameId);
|
||||||
@@ -473,16 +504,16 @@ fresh.
|
|||||||
messageParams: { from: parsed.from },
|
messageParams: { from: parsed.from },
|
||||||
actionLabelKey: "game.events.mail_new.action",
|
actionLabelKey: "game.events.mail_new.action",
|
||||||
onAction: () => {
|
onAction: () => {
|
||||||
void goto(withBase(`/games/${gameId}/mail`));
|
activeView.select("mail");
|
||||||
},
|
},
|
||||||
durationMs: 8000,
|
durationMs: 8000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
gameState.init({ client, cache, gameId }),
|
gameState.init({ client, cache, gameId: activeGameId }),
|
||||||
orderDraft.init({ cache, gameId }),
|
orderDraft.init({ cache, gameId: activeGameId }),
|
||||||
mailStore.init({ client, cache, gameId }),
|
mailStore.init({ client, cache, gameId: activeGameId }),
|
||||||
]);
|
]);
|
||||||
galaxyClient.set(client);
|
galaxyClient.set(client);
|
||||||
orderDraft.bindClient(client, {
|
orderDraft.bindClient(client, {
|
||||||
@@ -503,21 +534,12 @@ fresh.
|
|||||||
gameState.failBootstrap(describeBootstrapError(err));
|
gameState.failBootstrap(describeBootstrapError(err));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
return teardownSubscriptions;
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (unsubTurnReady !== null) {
|
teardownSubscriptions();
|
||||||
unsubTurnReady();
|
|
||||||
unsubTurnReady = null;
|
|
||||||
}
|
|
||||||
if (unsubGamePaused !== null) {
|
|
||||||
unsubGamePaused();
|
|
||||||
unsubGamePaused = null;
|
|
||||||
}
|
|
||||||
if (unsubMailReceived !== null) {
|
|
||||||
unsubMailReceived();
|
|
||||||
unsubMailReceived = null;
|
|
||||||
}
|
|
||||||
gameState.dispose();
|
gameState.dispose();
|
||||||
orderDraft.dispose();
|
orderDraft.dispose();
|
||||||
selection.dispose();
|
selection.dispose();
|
||||||
@@ -534,7 +556,6 @@ fresh.
|
|||||||
{i18n.t("common.skip_to_content")}
|
{i18n.t("common.skip_to_content")}
|
||||||
</a>
|
</a>
|
||||||
<Header
|
<Header
|
||||||
{gameId}
|
|
||||||
{sidebarOpen}
|
{sidebarOpen}
|
||||||
onToggleSidebar={toggleSidebar}
|
onToggleSidebar={toggleSidebar}
|
||||||
/>
|
/>
|
||||||
@@ -550,8 +571,22 @@ fresh.
|
|||||||
<Calculator />
|
<Calculator />
|
||||||
{:else if effectiveTool === "order"}
|
{:else if effectiveTool === "order"}
|
||||||
<Order />
|
<Order />
|
||||||
{:else}
|
{:else if activeView.view === "map"}
|
||||||
{@render children()}
|
<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}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -562,7 +597,6 @@ fresh.
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<BottomTabs
|
<BottomTabs
|
||||||
{gameId}
|
|
||||||
activeTool={effectiveTool}
|
activeTool={effectiveTool}
|
||||||
onSelectTool={(tool) => (mobileTool = tool)}
|
onSelectTool={(tool) => (mobileTool = tool)}
|
||||||
hideOrder={historyMode}
|
hideOrder={historyMode}
|
||||||
@@ -27,11 +27,10 @@ absent until Phase 24 wires push-event state.
|
|||||||
import TurnNavigator from "./turn-navigator.svelte";
|
import TurnNavigator from "./turn-navigator.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gameId: string;
|
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
};
|
};
|
||||||
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
|
let { sidebarOpen, onToggleSidebar }: Props = $props();
|
||||||
|
|
||||||
const gameState = getContext<GameStateStore | undefined>(
|
const gameState = getContext<GameStateStore | undefined>(
|
||||||
GAME_STATE_CONTEXT_KEY,
|
GAME_STATE_CONTEXT_KEY,
|
||||||
@@ -69,7 +68,7 @@ absent until Phase 24 wires push-event state.
|
|||||||
>
|
>
|
||||||
⤧
|
⤧
|
||||||
</button>
|
</button>
|
||||||
<ViewMenu {gameId} />
|
<ViewMenu />
|
||||||
<AccountMenu />
|
<AccountMenu />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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
|
Lists the seven IA destinations: map, tables (sub-list of six
|
||||||
entities), report, battle, mail, ship-class designer, science
|
entities), report, battle, mail, ship-class designer, science
|
||||||
designer. Closes on Escape, on outside click, and after a
|
designer. Each entry mutates `activeView` (the single-URL app-shell
|
||||||
navigation. Phase 26 introduces the history-mode entry; Phase 35
|
has no per-view routes) and closes the menu. Closes on Escape, on
|
||||||
polishes microcopy.
|
outside click, and after a selection. Phase 26 introduces the
|
||||||
|
history-mode entry; Phase 35 polishes microcopy.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { onMount } from "svelte";
|
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 { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import { mailStore } from "$lib/mail-store.svelte";
|
import { mailStore } from "$lib/mail-store.svelte";
|
||||||
import { restoreFocus } from "$lib/a11y/restore-focus";
|
import { restoreFocus } from "$lib/a11y/restore-focus";
|
||||||
|
|
||||||
type Props = { gameId: string };
|
|
||||||
let { gameId }: Props = $props();
|
|
||||||
|
|
||||||
const mailUnread = $derived(mailStore.unreadCount);
|
const mailUnread = $derived(mailStore.unreadCount);
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -40,9 +37,12 @@ polishes microcopy.
|
|||||||
open = !open;
|
open = !open;
|
||||||
}
|
}
|
||||||
|
|
||||||
function go(path: string): void {
|
function select(
|
||||||
|
view: GameView,
|
||||||
|
params: { tableEntity?: string } = {},
|
||||||
|
): void {
|
||||||
open = false;
|
open = false;
|
||||||
void goto(withBase(path));
|
activeView.select(view, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(event: KeyboardEvent): void {
|
function onKeyDown(event: KeyboardEvent): void {
|
||||||
@@ -93,7 +93,7 @@ polishes microcopy.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-map"
|
data-testid="view-menu-item-map"
|
||||||
onclick={() => go(`/games/${gameId}/map`)}
|
onclick={() => select("map")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.map")}
|
{i18n.t("game.view.map")}
|
||||||
</button>
|
</button>
|
||||||
@@ -105,7 +105,7 @@ polishes microcopy.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-table-{entry.slug}"
|
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)}
|
{i18n.t(entry.key)}
|
||||||
</button>
|
</button>
|
||||||
@@ -116,7 +116,7 @@ polishes microcopy.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-report"
|
data-testid="view-menu-item-report"
|
||||||
onclick={() => go(`/games/${gameId}/report`)}
|
onclick={() => select("report")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.report")}
|
{i18n.t("game.view.report")}
|
||||||
</button>
|
</button>
|
||||||
@@ -124,7 +124,7 @@ polishes microcopy.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-battle"
|
data-testid="view-menu-item-battle"
|
||||||
onclick={() => go(`/games/${gameId}/battle`)}
|
onclick={() => select("battle")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.battle")}
|
{i18n.t("game.view.battle")}
|
||||||
</button>
|
</button>
|
||||||
@@ -133,7 +133,7 @@ polishes microcopy.
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-mail"
|
data-testid="view-menu-item-mail"
|
||||||
class="with-badge"
|
class="with-badge"
|
||||||
onclick={() => go(`/games/${gameId}/mail`)}
|
onclick={() => select("mail")}
|
||||||
>
|
>
|
||||||
<span>{i18n.t("game.view.mail")}</span>
|
<span>{i18n.t("game.view.mail")}</span>
|
||||||
{#if mailUnread > 0}
|
{#if mailUnread > 0}
|
||||||
@@ -146,7 +146,7 @@ polishes microcopy.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-designer-science"
|
data-testid="view-menu-item-designer-science"
|
||||||
onclick={() => go(`/games/${gameId}/designer/science`)}
|
onclick={() => select("designer-science")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.designer.science")}
|
{i18n.t("game.view.designer.science")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+7
-8
@@ -1,14 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import { createGatewayClient } from "../../../api/connect";
|
import { createGatewayClient } from "../../api/connect";
|
||||||
import { GalaxyClient } from "../../../api/galaxy-client";
|
import { GalaxyClient } from "../../api/galaxy-client";
|
||||||
import { LobbyError, createGame } from "../../../api/lobby";
|
import { LobbyError, createGame } from "../../api/lobby";
|
||||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import { loadCore } from "../../../platform/core/index";
|
import { loadCore } from "../../platform/core/index";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
|
|
||||||
const DEFAULT_MIN_PLAYERS = 2;
|
const DEFAULT_MIN_PLAYERS = 2;
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancel(): void {
|
function cancel(): void {
|
||||||
goto(withBase("/lobby"));
|
appScreen.go("lobby");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(): Promise<void> {
|
async function submit(): Promise<void> {
|
||||||
@@ -94,7 +93,7 @@
|
|||||||
turnSchedule: trimmedSchedule,
|
turnSchedule: trimmedSchedule,
|
||||||
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
|
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
|
||||||
});
|
});
|
||||||
goto(withBase("/lobby"));
|
appScreen.go("lobby");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
formError = describeLobbyError(err);
|
formError = describeLobbyError(err);
|
||||||
} finally {
|
} finally {
|
||||||
+11
-8
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { appScreen, activeView } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import { createGatewayClient } from "../../api/connect";
|
import { createGatewayClient } from "../../api/connect";
|
||||||
import { GalaxyClient } from "../../api/galaxy-client";
|
import { GalaxyClient } from "../../api/galaxy-client";
|
||||||
@@ -185,11 +184,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function gotoCreate(): void {
|
function gotoCreate(): void {
|
||||||
goto(withBase("/lobby/create"));
|
appScreen.go("lobby-create");
|
||||||
}
|
}
|
||||||
|
|
||||||
function gotoGame(gameId: string): void {
|
function gotoGame(gameId: string): void {
|
||||||
goto(withBase(`/games/${gameId}/map`));
|
// 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(
|
async function onSyntheticFileChange(
|
||||||
@@ -208,7 +211,8 @@
|
|||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const json: unknown = JSON.parse(text);
|
const json: unknown = JSON.parse(text);
|
||||||
const { gameId } = loadSyntheticReportFromJSON(json);
|
const { gameId } = loadSyntheticReportFromJSON(json);
|
||||||
await goto(withBase(`/games/${gameId}/map`));
|
activeView.reset();
|
||||||
|
appScreen.go("game", { gameId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof SyntheticReportError) {
|
if (err instanceof SyntheticReportError) {
|
||||||
syntheticError = err.message;
|
syntheticError = err.message;
|
||||||
@@ -227,9 +231,8 @@
|
|||||||
// Statuses for which the game has a navigable in-game view.
|
// Statuses for which the game has a navigable in-game view.
|
||||||
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
|
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
|
||||||
// starting, start_failed) and terminal ones (cancelled) stay
|
// starting, start_failed) and terminal ones (cancelled) stay
|
||||||
// non-clickable; clicking them otherwise lands on a 404 because
|
// non-clickable; entering them otherwise opens the game shell on a
|
||||||
// /games/:id/map only meaningfully exists once the runtime has
|
// game whose runtime state does not exist yet.
|
||||||
// produced game state.
|
|
||||||
function isPlayableStatus(status: string): boolean {
|
function isPlayableStatus(status: string): boolean {
|
||||||
return status === "running" || status === "paused" || status === "finished";
|
return status === "running" || status === "paused" || status === "finished";
|
||||||
}
|
}
|
||||||
+2
-3
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import {
|
import {
|
||||||
AuthError,
|
AuthError,
|
||||||
confirmEmailCode,
|
confirmEmailCode,
|
||||||
@@ -89,7 +88,7 @@
|
|||||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
});
|
});
|
||||||
await session.signIn(result.deviceSessionId);
|
await session.signIn(result.deviceSessionId);
|
||||||
void goto(withBase("/lobby"), { replaceState: true });
|
appScreen.go("lobby");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AuthError && err.code === "invalid_request") {
|
if (err instanceof AuthError && err.code === "invalid_request") {
|
||||||
challengeId = null;
|
challengeId = null;
|
||||||
@@ -1,33 +1,31 @@
|
|||||||
<!--
|
<!--
|
||||||
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map navigates
|
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map switches
|
||||||
to `/games/:id/map` and resets the tool overlay. Calc and Order also
|
the active view to the map and resets the tool overlay. Calc and
|
||||||
navigate to `/games/:id/map` — the layout's tool gate replaces the
|
Order also switch to the map view — the shell's tool gate replaces
|
||||||
active view with the matching sidebar tool only when the URL is
|
the active view with the matching sidebar tool only while the map is
|
||||||
`/map`, so navigating to any other view via the More drawer or the
|
the active view, so navigating to any other view via the More drawer
|
||||||
header view-menu naturally drops the overlay.
|
or the header view-menu naturally drops the overlay.
|
||||||
|
|
||||||
More opens a drawer with the same destination list as the header
|
More opens a drawer with the same destination list as the header
|
||||||
view-menu. Phase 35 polish narrows it to the IA-spec subset
|
view-menu, each entry mutating `activeView` directly (the single-URL
|
||||||
(Mail, Battle log, Tables, History, Settings, Logout) once History
|
app-shell has no per-view routes). Phase 35 polish narrows it to the
|
||||||
exists; until then the convenience of one source of truth for
|
IA-spec subset (Mail, Battle log, Tables, History, Settings, Logout)
|
||||||
destinations beats the duplication.
|
once History exists; until then the convenience of one source of
|
||||||
|
truth for destinations beats the duplication.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { onMount } from "svelte";
|
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 { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import { restoreFocus } from "$lib/a11y/restore-focus";
|
import { restoreFocus } from "$lib/a11y/restore-focus";
|
||||||
import type { MobileTool } from "./types";
|
import type { MobileTool } from "./types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gameId: string;
|
|
||||||
activeTool: MobileTool;
|
activeTool: MobileTool;
|
||||||
onSelectTool: (tool: MobileTool) => void;
|
onSelectTool: (tool: MobileTool) => void;
|
||||||
hideOrder?: boolean;
|
hideOrder?: boolean;
|
||||||
};
|
};
|
||||||
let {
|
let {
|
||||||
gameId,
|
|
||||||
activeTool,
|
activeTool,
|
||||||
onSelectTool,
|
onSelectTool,
|
||||||
hideOrder = false,
|
hideOrder = false,
|
||||||
@@ -45,16 +43,18 @@ destinations beats the duplication.
|
|||||||
{ slug: "races", key: "game.view.table.races" },
|
{ slug: "races", key: "game.view.table.races" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function selectTool(tool: MobileTool): Promise<void> {
|
function selectTool(tool: MobileTool): void {
|
||||||
moreOpen = false;
|
moreOpen = false;
|
||||||
onSelectTool(tool);
|
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;
|
moreOpen = false;
|
||||||
onSelectTool("map");
|
onSelectTool("map");
|
||||||
await goto(withBase(path));
|
activeView.select(view, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMore(): void {
|
function toggleMore(): void {
|
||||||
@@ -143,7 +143,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-map"
|
data-testid="bottom-tabs-more-map"
|
||||||
onclick={() => go(`/games/${gameId}/map`)}
|
onclick={() => go("map")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.map")}
|
{i18n.t("game.view.map")}
|
||||||
</button>
|
</button>
|
||||||
@@ -155,7 +155,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-table-{entry.slug}"
|
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)}
|
{i18n.t(entry.key)}
|
||||||
</button>
|
</button>
|
||||||
@@ -166,7 +166,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-report"
|
data-testid="bottom-tabs-more-report"
|
||||||
onclick={() => go(`/games/${gameId}/report`)}
|
onclick={() => go("report")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.report")}
|
{i18n.t("game.view.report")}
|
||||||
</button>
|
</button>
|
||||||
@@ -174,7 +174,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-battle"
|
data-testid="bottom-tabs-more-battle"
|
||||||
onclick={() => go(`/games/${gameId}/battle`)}
|
onclick={() => go("battle")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.battle")}
|
{i18n.t("game.view.battle")}
|
||||||
</button>
|
</button>
|
||||||
@@ -182,7 +182,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-mail"
|
data-testid="bottom-tabs-more-mail"
|
||||||
onclick={() => go(`/games/${gameId}/mail`)}
|
onclick={() => go("mail")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.mail")}
|
{i18n.t("game.view.mail")}
|
||||||
</button>
|
</button>
|
||||||
@@ -190,7 +190,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-designer-science"
|
data-testid="bottom-tabs-more-designer-science"
|
||||||
onclick={() => go(`/games/${gameId}/designer/science`)}
|
onclick={() => go("designer-science")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.designer.science")}
|
{i18n.t("game.view.designer.science")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
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 { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import {
|
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
|
// Reset the design when the active game changes; a no-op otherwise, so
|
||||||
// the design persists across tab switches within a game.
|
// the design persists across tab switches within a game.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
cs.ensureGame(page.params.id ?? "");
|
cs.ensureGame(appScreen.gameId ?? "");
|
||||||
});
|
});
|
||||||
|
|
||||||
const core = $derived(coreHandle?.core ?? null);
|
const core = $derived(coreHandle?.core ?? null);
|
||||||
|
|||||||
@@ -1,29 +1,23 @@
|
|||||||
<!--
|
<!--
|
||||||
Sidebar with three tabs (Calculator, Inspector, Order). The parent
|
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
|
it, tablet collapses it behind the header toggle, desktop keeps it
|
||||||
always visible). State preservation across active-view switches
|
always visible). State preservation across active-view switches
|
||||||
works for free because the layout never remounts when the user
|
works for free because the shell never remounts when the user
|
||||||
navigates within `/games/:id/*`.
|
switches the active view within a game.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
The `historyMode` prop hides the Order tab when true: the tab-bar
|
The `historyMode` prop hides the Order tab when true: the tab-bar
|
||||||
filters it out and any URL seed targeting `order` falls back to
|
filters it out and the history-mode reset falls back to `inspector`.
|
||||||
`inspector`. Phase 12 wires the prop through the layout as a
|
Phase 12 wires the prop through the shell as a constant `false`;
|
||||||
constant `false`; Phase 26 flips it on for past-turn snapshots.
|
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
|
external events (Phase 13 reveals the inspector tab when a planet
|
||||||
is clicked on the map). The URL seed and the history-mode reset
|
is clicked on the map). The history-mode reset mutates the bindable
|
||||||
both mutate the bindable in place; the layout sees the change
|
in place; the shell sees the change through the binding without
|
||||||
through the binding without extra plumbing.
|
extra plumbing.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import TabBar from "./tab-bar.svelte";
|
import TabBar from "./tab-bar.svelte";
|
||||||
import Calculator from "./calculator-tab.svelte";
|
import Calculator from "./calculator-tab.svelte";
|
||||||
import Inspector from "./inspector-tab.svelte";
|
import Inspector from "./inspector-tab.svelte";
|
||||||
@@ -44,29 +38,11 @@ through the binding without extra plumbing.
|
|||||||
activeTab = $bindable<SidebarTab>("inspector"),
|
activeTab = $bindable<SidebarTab>("inspector"),
|
||||||
}: Props = $props();
|
}: 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(() => {
|
$effect(() => {
|
||||||
if (historyMode && activeTab === "order") {
|
if (historyMode && activeTab === "order") {
|
||||||
activeTab = "inspector";
|
activeTab = "inspector";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const seed = readUrlSeed();
|
|
||||||
if (seed === null) return;
|
|
||||||
if (seed === "order" && historyMode) {
|
|
||||||
activeTab = "inspector";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeTab = seed;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
import "$lib/theme/tokens.css";
|
import "$lib/theme/tokens.css";
|
||||||
import "$lib/theme/base.css";
|
import "$lib/theme/base.css";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import { dev } from "$app/environment";
|
import { dev } from "$app/environment";
|
||||||
import { appBase, withBase } from "$lib/paths";
|
import { withBase } from "$lib/paths";
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
import { eventStream } from "../api/events.svelte";
|
import { eventStream } from "../api/events.svelte";
|
||||||
@@ -77,25 +75,6 @@
|
|||||||
eventStream.stop();
|
eventStream.stop();
|
||||||
streamSessionId = null;
|
streamSessionId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// page.url.pathname includes the configured base path; strip it so
|
|
||||||
// the route comparisons below stay base-agnostic.
|
|
||||||
const pathname = page.url.pathname.slice(appBase.length);
|
|
||||||
// Debug-only routes under /__debug/* run their own bootstrap
|
|
||||||
// path against the storage primitives and must bypass the
|
|
||||||
// auth guard so Phase 6's Playwright spec can drive the
|
|
||||||
// keystore directly.
|
|
||||||
if (pathname.startsWith("/__debug/")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session.status === "anonymous" && pathname !== "/login") {
|
|
||||||
void goto(withBase("/login"), { replaceState: true });
|
|
||||||
} else if (
|
|
||||||
session.status === "authenticated" &&
|
|
||||||
(pathname === "/login" || pathname === "/")
|
|
||||||
) {
|
|
||||||
void goto(withBase("/lobby"), { replaceState: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The app root renders no content of its own. The root layout's auth
|
// Single-route screen dispatcher for the app-shell. There are no
|
||||||
// guard redirects "/" to /lobby (authenticated) or /login
|
// per-screen routes: the visible screen is selected from in-memory
|
||||||
// (anonymous); this placeholder only shows for the brief moment
|
// state (`session.status` for the auth gate, `appScreen.screen` for
|
||||||
// before that client-side redirect resolves.
|
// the authenticated screen) rather than from the URL. The root
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
// layout intercepts the `loading` and `unsupported` session states
|
||||||
|
// before this component renders, so here `session.status` is either
|
||||||
|
// `anonymous` (login) or `authenticated` (lobby / create / game).
|
||||||
|
import { session } from "$lib/session-store.svelte";
|
||||||
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
|
import LoginScreen from "$lib/screens/login-screen.svelte";
|
||||||
|
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
|
||||||
|
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
|
||||||
|
import GameShell from "$lib/game/game-shell.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="status">
|
{#if session.status === "authenticated"}
|
||||||
<p>{i18n.t("common.loading")}</p>
|
{#if appScreen.screen === "lobby-create"}
|
||||||
</main>
|
<LobbyCreateScreen />
|
||||||
|
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
|
||||||
<style>
|
<GameShell />
|
||||||
.status {
|
{:else}
|
||||||
padding: var(--space-6);
|
<!--
|
||||||
font-family: var(--font-sans);
|
Default authenticated screen. Covers `lobby`, a stale `login`
|
||||||
}
|
screen restored from a previous anonymous session, and a `game`
|
||||||
</style>
|
screen with no active game id (a snapshot that lost its id).
|
||||||
|
-->
|
||||||
|
<LobbyScreen />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<LoginScreen />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
// SPA mode for the in-game shell, mirroring the root layout. The
|
|
||||||
// session bootstrap and the auth gate already live in the root
|
|
||||||
// `+layout.svelte`; this layout just inherits the SPA flags so the
|
|
||||||
// static adapter does not try to prerender a per-game shell at build
|
|
||||||
// time.
|
|
||||||
|
|
||||||
export const ssr = false;
|
|
||||||
export const prerender = false;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// A bare `/games/:id` URL is not in the IA section — every in-game
|
|
||||||
// view sits under one of the typed sub-routes (`map`, `table/...`,
|
|
||||||
// etc.). Default the user to the map view so the URL is always
|
|
||||||
// pointing at a real active view; SvelteKit's `redirect` runs in the
|
|
||||||
// browser because the layout disables SSR.
|
|
||||||
|
|
||||||
import { redirect } from "@sveltejs/kit";
|
|
||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load: PageLoad = ({ params }) => {
|
|
||||||
throw redirect(307, `/games/${params.id}/map`);
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import BattleView from "$lib/active-view/battle.svelte";
|
|
||||||
|
|
||||||
const turn = $derived.by(() => {
|
|
||||||
const raw = page.url.searchParams.get("turn");
|
|
||||||
const n = raw === null ? NaN : Number(raw);
|
|
||||||
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : 0;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<BattleView
|
|
||||||
gameId={page.params.id ?? ""}
|
|
||||||
{turn}
|
|
||||||
battleId={page.params.battleId ?? ""}
|
|
||||||
/>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import DesignerScience from "$lib/active-view/designer-science.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<DesignerScience />
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import MailView from "$lib/active-view/mail.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<MailView />
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import MapView from "$lib/active-view/map.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<MapView />
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<!--
|
|
||||||
Phase 23 turn-report route. The orchestrator renders the table of
|
|
||||||
contents and the twenty sections; scroll save/restore is wired
|
|
||||||
through SvelteKit's `Snapshot` API on this route file.
|
|
||||||
`window.scrollY` is captured before navigating away and restored
|
|
||||||
after `afterNavigate` re-mounts the route. The in-game shell
|
|
||||||
layout expands the active-view-host to fit content rather than
|
|
||||||
constraining its own height, so the document body is what scrolls
|
|
||||||
— hence `window.scroll` rather than a host-element scrollTop.
|
|
||||||
|
|
||||||
A short `requestAnimationFrame` poll waits for the body to grow
|
|
||||||
tall enough to honour the saved offset, because the captured
|
|
||||||
position usually exceeds the viewport height before the sections
|
|
||||||
mount on return navigation.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { Snapshot } from "@sveltejs/kit";
|
|
||||||
|
|
||||||
import ReportView from "$lib/active-view/report.svelte";
|
|
||||||
|
|
||||||
function restoreScroll(target: number): void {
|
|
||||||
if (target <= 0) return;
|
|
||||||
let attempts = 60;
|
|
||||||
const tick = (): void => {
|
|
||||||
const need = target + window.innerHeight;
|
|
||||||
const have = document.documentElement.scrollHeight;
|
|
||||||
if (have >= need || attempts === 0) {
|
|
||||||
window.scrollTo(0, target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
attempts -= 1;
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const snapshot: Snapshot<{ scrollY: number }> = {
|
|
||||||
capture() {
|
|
||||||
return { scrollY: window.scrollY };
|
|
||||||
},
|
|
||||||
restore(value) {
|
|
||||||
restoreScroll(value.scrollY);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ReportView />
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import TableView from "$lib/active-view/table.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TableView entity={page.params.entity ?? ""} />
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// Lobby is the first authenticated screen and depends on the
|
|
||||||
// session keypair plus the WASM core loaded at runtime; SSR and
|
|
||||||
// prerendering stay disabled.
|
|
||||||
|
|
||||||
export const ssr = false;
|
|
||||||
export const prerender = false;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export const ssr = false;
|
|
||||||
export const prerender = false;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// Login depends on browser-only WebCrypto and IndexedDB through the
|
|
||||||
// session store; SSR and prerendering are disabled to keep the
|
|
||||||
// component out of the server-render pipeline.
|
|
||||||
|
|
||||||
export const ssr = false;
|
|
||||||
export const prerender = false;
|
|
||||||
Reference in New Issue
Block a user