Compare commits

..
Author SHA1 Message Date
kamilkrzywda 3287bcaca4 Merge pull request 'v0.3.0 — URL deep-linking (game id in URL, multi-tab)' (#13) from url-deep-link into master
CI / build + unit + e2e (push) Successful in 1m53s
2026-10-11 17:39:18 +02:00
kamilkrzywda f622cb676a url deep-link: game id in #/game/<id>, multi-tab + bad-link fallback (v0.3.0)
CI / build + unit + e2e (pull_request) Successful in 1m52s
2026-10-11 15:32:51 +00:00
kamilkrzywda 502fbd4874 Merge pull request 'v0.2.0 — LLM reliability + batched-round turn model' (#9) from reliability-batched-round into master
CI / build + unit + e2e (push) Successful in 1m52s
2026-10-11 16:46:01 +02:00
5 changed files with 210 additions and 17 deletions
+8 -1
View File
@@ -62,7 +62,7 @@ history/
│ ├── db.ts # SQLite open/save/load
│ └── *.test.ts # unit tests (vitest)
└── src/
├── App.tsx # screen router: home → pick → game; owns game/regions/advisors/busy state
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
├── main.tsx # React root + ThemeProvider(dark)
├── index.css # Tailwind v4 + dark "command-center" theme
├── lib/
@@ -104,6 +104,13 @@ history/
- **No hardcoded origins.** The client uses same-origin relative paths (`/api/...`, `/sse/...`).
In dev, Vite proxies them to :4321; in prod/Traefik they're the same origin. So the app works
unchanged behind the proxy — never bake in `http://host:port`.
- **Hash-based routing (client-only).** The active game's id lives in the URL fragment: home is
`#/` (a bare page with no hash is also home) and an active game is `#/game/<id>`. This is a manual
hash sync in `App.tsx` — no react-router, no server route (every non-API GET just serves
`index.html`), so it is Traefik/static-safe. A deep link opens that exact game and two tabs can
run two different games (each has its own React state + `useSse(game.id)`). The URL hash takes
precedence over the localStorage active-game id, which remains the fallback for a bare `/` load;
a bad/missing id clears the hash to `#/` and falls back to home.
- **Batched round model.** Actions and diplomatic messages are queued in `game.pending` via
`POST /action` and `POST /diplomacy` (fast, no LLM call, no turn advance). A single
`POST /jump` ("Jump forward") commits the whole pending batch in one LLM turn: `engine.runTurn`
+4
View File
@@ -2,6 +2,10 @@
All notable changes to **History** are listed here, newest first.
## v0.3.0 — 2026-10-11 15:29
- **Deep-linking** — the active game's id now lives in the URL (`#/game/<id>`), so a link opens that exact game and two tabs can run two different games; a bad or missing id falls back to the home screen. Fixes #3.
## v0.2.0 — 2026-10-11 14:24
- **Batched round model** — replaced the single-action/turn with a queued-round model. Actions and diplomatic messages accumulate in `game.pending` (no LLM call, no turn advance); a single "Jump forward" button commits the whole batch in one LLM turn. This fixes the 502 reliability problem by making each LLM call do one coherent batch of work instead of one tiny fragment.
+103
View File
@@ -180,3 +180,106 @@ test.describe('History — save / resume across a page refresh', () => {
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
});
});
/**
* URL deep-linking (issue #3). The active game's id lives in the URL fragment:
* home is `#/` and an active game is `#/game/<id>`. A fresh page navigated to
* that hash loads that exact game; a bad/missing id falls back to home and
* clears the hash; returning to home clears it. A browser context is fresh per
* test, so localStorage is isolated, but the e2e webServer shares one SQLite
* (`data/e2e`) across tests — so each test captures the *specific* id of the
* game it creates (via the POST /api/games response) rather than assuming "the
* only game".
*/
/**
* Start the first scenario (World War II) and pick the United Kingdom, returning
* the freshly created game's id (read from the POST /api/games response). Leaves
* the caller on the game screen at turn 1.
*/
async function createUkGame(page: Page): Promise<string> {
await page.goto('/');
await page.getByTestId('preset-card').first().waitFor({ timeout: 15000 });
await page.getByTestId('preset-card').first().getByRole('button', { name: 'Play' }).click();
await expect(page.getByTestId('world-background')).toBeVisible();
const [resp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/games',
),
page
.getByTestId('nation-card-uk')
.getByRole('button', { name: 'Play as United Kingdom' })
.click(),
]);
const body = (await resp.json()) as { game: { id: string } };
return body.game.id;
}
/** Poll until `location.hash` equals `expected` (hash writes happen in effects). */
async function waitHash(page: Page, expected: string): Promise<void> {
await page.waitForFunction(
(exp) => window.location.hash === exp,
expected,
{ timeout: 15000 },
);
}
test.describe('History — URL deep-linking (#/game/<id>)', () => {
test('creating a game puts its id in the URL', async ({ page }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
// The game screen mounts at turn 1; the URL-sync effect writes the deep link.
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
await waitHash(page, `#/game/${id}`);
});
test('a fresh page deep-links to the exact saved game', async ({ page, context }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
await waitHash(page, `#/game/${id}`);
const origin = await page.evaluate(() => location.origin);
// A brand-new page navigated straight to the deep link must load that exact
// game (matching id, preset, and player) — the URL hash takes precedence over
// the (shared) localStorage, so this is deterministic.
const page2 = await context.newPage();
await page2.goto(`${origin}/#/game/${id}`);
await expect(page2.getByTestId('world-map')).toBeVisible();
await expect(page2.getByTestId('turn-count')).toHaveText('1');
await expect(page2.getByTestId('player-nation')).toHaveText('United Kingdom');
await waitHash(page2, `#/game/${id}`);
await page2.close();
});
test('a bad game id falls back to the home screen and clears the hash', async ({
page,
context,
}) => {
test.setTimeout(60000);
await page.goto('/');
const origin = await page.evaluate(() => location.origin);
// A fresh page navigated to a non-existent id must not crash: it lands on the
// home screen and the bad hash is cleared to `#/`.
const page2 = await context.newPage();
await page2.goto(`${origin}/#/game/does-not-exist-xyz`);
await expect(page2.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
await waitHash(page2, '#/');
await page2.close();
});
test('returning to home from a game clears the hash to #/', async ({ page }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
await waitHash(page, `#/game/${id}`);
// "← Scenarios" returns to the home screen; the URL-sync effect clears the hash.
await page.getByTestId('back-to-scenarios').click();
await expect(page.getByTestId('preset-card').first()).toBeVisible({ timeout: 15000 });
await waitHash(page, '#/');
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+94 -15
View File
@@ -111,6 +111,28 @@ function clearActiveGameId(): void {
}
}
// ---------------------------------------------------------------------------
// Hash routing. The active game's id lives in the URL fragment: home is `#/`
// (a bare page with no hash is also home) and an active game is `#/game/<id>`.
// This manual hash sync — no react-router, no server-side route — fits the
// existing state-based router and is static/Traefik safe, because the fragment
// never reaches the server (every non-API GET just serves `index.html`). The
// localStorage active-game id above stays as the fallback for a bare `/` load;
// the URL hash takes precedence when present.
// ---------------------------------------------------------------------------
/** The game id encoded in `#/game/<id>`, or `null` when the hash is home/bare. */
function hashGameId(hash: string): string | null {
const m = hash.match(/^#\/game\/(.+)$/);
return m && m[1].length > 0 ? m[1] : null;
}
/** Write the home route (`#/`) into the URL hash unless it is already home/bare. */
function setHomeHash(): void {
const h = window.location.hash;
if (h !== '' && h !== '#/') window.location.hash = '#/';
}
export default function App() {
const [screen, setScreen] = useState<Screen>({ name: 'home' });
const [presets, setPresets] = useState<PresetMeta[]>([]);
@@ -123,6 +145,13 @@ export default function App() {
// list of saved games (newest first) shown on the home screen.
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
const [savedGames, setSavedGames] = useState<GameSummary[]>([]);
// Once the boot fetch resolves (success or fallback), allow the URL-sync effect
// to start writing the hash. Until then, a deep link in the URL must not be
// clobbered by an app-driven "home" write during the initial render.
const bootedRef = useRef(false);
// Latest game + handlers, read by the (mount-once) hashchange listener so it
// never acts on a stale closure. Written each render (same pattern as sse.ts).
const latestRef = useRef<{ game: GameState | null; goHome: () => void; loadGame: (id: string) => void } | null>(null);
// On the home screen: load the scenario manifest and the list of saved games
// (newest-updated first) so the user can resume a prior game.
@@ -142,28 +171,36 @@ export default function App() {
};
}, [screen.name]);
// On first mount, auto-resume the last active game (if any is stored): fetch
// its persisted state, remember it, and drop the user straight back into the
// game screen. The map is refetched by the effect below (keyed on `game`) and
// the `useSse(game?.id)` effect re-attaches the stream, resyncing from the
// server on connect. If the id is stale (404 / deleted), clear it and fall
// back to the home screen gracefully.
// On first mount, decide which game to show and load it. A deep link in the
// URL (`#/game/<id>`) takes precedence over the localStorage auto-resume; a
// bare page (no hash) falls back to the stored active game. The map is
// refetched by the effect below (keyed on `game`) and `useSse(game?.id)`
// re-attaches the stream, resyncing from the server on connect. A bad or
// missing id (404) must never crash: we clear the hash to `#/`, drop any
// stale stored id, and stay on the home screen. Once boot resolves (success
// or fallback) `bootedRef` flips so the URL-sync effect may start writing.
useEffect(() => {
const id = readActiveGameId();
if (!id) return;
const id = hashGameId(window.location.hash) ?? readActiveGameId();
if (!id) {
bootedRef.current = true; // nothing to load — activate URL syncing
return;
}
let alive = true;
(async () => {
try {
const { game } = await api.getGame(id);
if (!alive) return;
persistActiveGameId(id); // remember it so a future bare `/` load resumes it
setActiveGameId(id);
setGame(game);
setScreen({ name: 'game' });
} catch {
if (alive) {
clearActiveGameId();
setActiveGameId(null);
}
if (!alive) return;
clearActiveGameId();
setActiveGameId(null);
setHomeHash(); // bad/missing id → fall back to the home screen
} finally {
bootedRef.current = true; // boot resolved — activate URL syncing
}
})();
return () => {
@@ -171,6 +208,38 @@ export default function App() {
};
}, []);
// Keep the URL in sync with the current screen: a loaded game is
// `#/game/<id>`; everything else (home and the pick screens) is `#/`. This is
// the single writer of the hash for app-driven transitions (game created,
// resumed, or leaving for home). It only writes when the desired hash differs
// from the current one, so it never triggers a redundant `hashchange`.
useEffect(() => {
if (!bootedRef.current) return;
const desired = screen.name === 'game' && game ? `#/game/${game.id}` : '#/';
if (window.location.hash !== desired) window.location.hash = desired;
}, [screen.name, game?.id]);
// React to user-driven hash changes: manual edits and browser back/forward.
// Only re-act when the parsed id actually differs from the loaded game (or the
// hash went home while we're in a game), so the writes above never loop. Uses
// `latestRef` so the mount-once effect always sees the current game + handlers.
useEffect(() => {
const onHashChange = () => {
const cur = latestRef.current;
if (!cur) return;
const id = hashGameId(window.location.hash);
if (id) {
if (id === cur.game?.id) return; // already showing this game — no-op
void cur.loadGame(id);
} else if (cur.game) {
cur.goHome(); // hash is home but we're in a game — return home
}
// else: hash is home and we're already on home/pick — nothing to do
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
// Keep the choropleth in sync with whatever state is shown (territory changes).
useEffect(() => {
if (!game) return;
@@ -282,20 +351,24 @@ export default function App() {
[game],
);
const resumeGame = useCallback(async (id: string) => {
// Load (fetch + show) a saved game by id. Shared by the home screen's resume
// affordances and by the hash router when the URL points at a different game.
// On failure (deleted / 404) it never throws: it clears the stale reference,
// clears the hash to `#/`, and stays on the home screen.
const loadGame = useCallback(async (id: string) => {
setLoading(true);
try {
const { game } = await api.getGame(id);
// Make this the active game again so a refresh keeps resuming it.
// Make this the active game so a bare `/` reload keeps resuming it.
persistActiveGameId(id);
setActiveGameId(id);
setGame(game);
setAdvisors([]);
setScreen({ name: 'game' });
} catch (e) {
// Deleted / not found: clear any stale reference and stay on home.
clearActiveGameId();
setActiveGameId(null);
setHomeHash();
setScreen({ name: 'home' });
toastError(e);
} finally {
@@ -303,6 +376,9 @@ export default function App() {
}
}, []);
// The home screen's "resume" affordances just load a saved game by id.
const resumeGame = useCallback((id: string) => void loadGame(id), [loadGame]);
const goHome = useCallback(() => {
setScreen({ name: 'home' });
setGame(null);
@@ -310,6 +386,9 @@ export default function App() {
setAdvisors([]);
}, []);
// Expose the latest game + handlers to the mount-once hashchange listener.
latestRef.current = { game, goHome, loadGame };
if (screen.name === 'game' && game) {
return (
<>