History: AI-driven alternate-history simulator
Pax Historia-style sim where the LLM is the world engine.
- React 19 + Vite 6 + Tailwind v4 + shadcn/ui frontend
- Express single-port backend (/api + /sse + built client + SPA fallback)
- SQLite (better-sqlite3) persistence, one row per game
- MapLibre choropleth map (OpenFreeMap dark + ISO_A3 regions)
- Defensive turn loop: strip fences, balanced-{} extract, parse, clamp 0-100,
retry-once nudge, generic 'unclear' fallback; never crashes on bad output
- 5 seed presets (wwii-1939, coldwar-1955, modern-2025, no-wwii-1940, aegis-2400)
- LLM client over native fetch w/ injectable transport (mesh-bot pattern);
LLM_STUB=1 deterministic transport for tests/e2e
- Unit (vitest, 19) + deterministic Playwright e2e
- Multi-stage node:22-slim Docker + Traefik (history.dev.kkhost.pl:4321)
Co-Authored-By: OpenCode <opencode@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
server/dist
|
||||
data
|
||||
.git
|
||||
*.log
|
||||
.DS_Store
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
server/dist/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
data/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
.DS_Store
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,151 @@
|
||||
# AGENTS.md — History
|
||||
|
||||
Guidance for AI agents (and humans) working in this repo.
|
||||
|
||||
## What this is
|
||||
|
||||
A self-contained, AI-driven **alternate-history simulator** in the style of _Pax Historia_:
|
||||
the LLM is the world engine. You pick a scenario (one of 5 seed presets) and a nation to lead,
|
||||
then take actions / send diplomatic messages / ask an advisor; each move is sent to the model,
|
||||
which returns a structured `TurnResult` (events + stat/relation/territory deltas) that the engine
|
||||
validates, clamps, and applies to the world state. A MapLibre choropleth renders territorial
|
||||
ownership; the whole thing persists to SQLite and streams over SSE on a single port.
|
||||
|
||||
## Stack
|
||||
|
||||
- TypeScript everywhere, `strict: true`
|
||||
- Frontend: React 19 + Vite 6 + Tailwind CSS v4 (`@tailwindcss/vite`, no config file) + shadcn/ui
|
||||
- Backend: Node 22 + Express — one process serving the built client, `/api` (REST), and `/sse`
|
||||
- Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
|
||||
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a country choropleth
|
||||
from `public/countries.geo.json` (`id` = ISO_A3)
|
||||
- The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an
|
||||
**injectable transport** (mirrors `mesh-bot/src/llm`). A deterministic **stub** transport
|
||||
(`LLM_STUB=1`) is used by all tests/e2e.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
history/
|
||||
├── AGENTS.md / README.md
|
||||
├── Dockerfile # multi-stage node:22-slim → single-port prod image
|
||||
├── docker-compose.yml # dev_net + 4 Traefik labels → history.dev.kkhost.pl
|
||||
├── eslint.config.js # flat config (ESLint 9)
|
||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
||||
├── vitest.config.ts # unit tests (server/**/*.test.ts)
|
||||
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||
├── index.html
|
||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||
├── public/
|
||||
│ └── countries.geo.json # 177 features, id=ISO_A3 (committed; Vite copies to dist/)
|
||||
├── shared/ # NO runtime deps — the single source of truth
|
||||
│ ├── types.ts # domain types (Nation, GameState, Preset, …)
|
||||
│ └── protocol.ts # LLM TurnResult schema + client↔server messages + pure helpers
|
||||
├── server/
|
||||
│ ├── index.ts # Express: /api/*, /sse/:id, static+SPA; reads PORT/LLM_*/DATA_DIR
|
||||
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / advisor
|
||||
│ ├── llm.ts # LlmClient (injectable transport) + env config
|
||||
│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER
|
||||
│ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color)
|
||||
│ ├── presets.ts # load preset JSON files from disk
|
||||
│ ├── db.ts # SQLite open/save/load
|
||||
│ └── *.test.ts # unit tests (vitest)
|
||||
└── src/
|
||||
├── App.tsx # screen router: home → pick → game; owns game/regions/advisors state
|
||||
├── main.tsx # React root + ThemeProvider(dark)
|
||||
├── index.css # Tailwind v4 + dark "command-center" theme
|
||||
├── lib/
|
||||
│ ├── api.ts # same-origin REST helper (no hardcoded origin — proxy/Traefik safe)
|
||||
│ ├── sse.ts # useSse() hook (resync on connect; ref-based handlers)
|
||||
│ ├── colors.ts # event/relation/outcome → color maps
|
||||
│ └── utils.ts # cn()
|
||||
└── components/
|
||||
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
||||
├── WorldMap.tsx # MapLibre basemap + choropleth + click popup + player highlight
|
||||
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
||||
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + "Jump forward"
|
||||
├── EventFeed.tsx # scrolling event log (newest first)
|
||||
├── StatBar.tsx
|
||||
└── ui/ # 14 shadcn/ui primitives
|
||||
```
|
||||
|
||||
## Commands (run from repo root)
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
||||
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
|
||||
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
|
||||
| `npm test` | vitest unit tests in `server/` (19 tests) |
|
||||
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
||||
| `npm run lint` | ESLint flat config |
|
||||
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); overrides dev ports via `VITE_PORT`/`HISTORY_API_PORT` if set |
|
||||
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
||||
|
||||
## Conventions
|
||||
|
||||
- **The server is authoritative.** All world state lives in SQLite (`server/db.ts`). The client
|
||||
renders state and sends intents (`/api/games/:id/turn|jump|advisor`); it never computes outcomes.
|
||||
`shared/protocol.ts` is the single source of truth for the LLM `TurnResult` and the
|
||||
client↔server message shapes — change it there first, then update both sides.
|
||||
- **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`.
|
||||
- **Defensive turn handling, never crash.** `engine.runTurn` strips fences, extracts the first
|
||||
balanced `{...}`, `JSON.parse`s, validates (ignores invalid ops, clamps stats to 0–100), retries
|
||||
once with a nudge if the result is empty/invalid, and if it's still unusable emits a generic
|
||||
"unclear" event and advances. Bad model output never throws; only a transport/network error
|
||||
(model down) surfaces as an HTTP 502 to the client (shown as a toast).
|
||||
- **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the
|
||||
server's `RegionMap` and any client coloring so the choropleth always agrees.
|
||||
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically: the player
|
||||
nation's `military` is set to `(current + 5)` (clamped) each turn, one event is emitted
|
||||
(action→military, chat→diplomatic, jump→political "The world moves on"), it allies the player with
|
||||
the first other nation, and the date advances 2 days. Unit + e2e assertions rely on these exact
|
||||
values, so keep the stub deterministic.
|
||||
- Styling: Tailwind utility classes + shadcn/ui only; dark "command-center" theme (slate bg, amber
|
||||
primary, cyan accent). No CSS beyond the Tailwind import in `src/index.css`.
|
||||
|
||||
## Deployment
|
||||
|
||||
- Single Docker container (multi-stage `node:22-slim`): Express serves the built client, `/api`,
|
||||
and `/sse` on **one internal port, 4321**. `server/index.ts` `findRoot()` resolves the project
|
||||
root so `presets/`, `dist/`, and the `data/` (SQLite) paths work from the container layout.
|
||||
- Proxied by the shared Traefik (`dev_traefik`, see `../PROXY.md`) at
|
||||
**https://history.dev.kkhost.pl → container port 4321**. `docker-compose.yml` declares `dev_net`
|
||||
as an external network and the 4 mandatory labels (`traefik.enable`, router `Host(...)`,
|
||||
`entrypoints=web`, service `loadbalancer.server.port=4321`). **No TLS/certs in the labels** —
|
||||
the external proxy terminates TLS and forwards plain HTTP on the `web` entrypoint.
|
||||
- Game data persists on the `history-data` volume at `/app/data`.
|
||||
|
||||
## LLM configuration (env)
|
||||
|
||||
| Var | Default | Notes |
|
||||
|---|---|---|
|
||||
| `LLM_API_BASE` | `http://192.168.1.70:1111/v1` | OpenAI-compatible base URL |
|
||||
| `LLM_MODEL` | `qwen3.8-27b` | model id sent in the request body |
|
||||
| `LLM_TIMEOUT_MS` | `180000` | local 27B model can be slow |
|
||||
| `LLM_STUB` | _(unset = live)_ | `1` → deterministic stub transport (no model needed) |
|
||||
| `PORT` | `4321` | single server port |
|
||||
| `DATA_DIR` | `./data` | where `history.db` lives |
|
||||
| `CLIENT_DIST` | `./dist` | built client to serve (auto-found) |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Live LLM is a runtime dependency.** If `192.168.1.70:1111` is down, turns return 502 (and the
|
||||
advisor degrades to a canned list). Set `LLM_STUB=1` for a fully functional offline instance.
|
||||
The map's remote OpenFreeMap style may fail to load without internet — the app still works, the
|
||||
choropleth just waits for `styledata`.
|
||||
- **Dev port override.** Vite defaults to :5173 → proxies to :4321. Both are overridable via
|
||||
`VITE_PORT` / `HISTORY_API_PORT` (the Playwright config reads them) so a run can dodge a port
|
||||
another project already holds. The client uses relative URLs, so it follows whichever port Vite
|
||||
binds.
|
||||
- **`@shared` vs `@` alias.** `shared/` lives at the repo **root** (a sibling of `src/`), not under
|
||||
`src/`. Imports must use `@shared/...` (or `../shared/` from `server/`). A bare `../shared/` from
|
||||
inside `src/` resolves to the nonexistent `src/shared/` — this was a real bug that `typecheck`
|
||||
caught.
|
||||
- **`better-sqlite3`** is the only native module; the Docker build relies on its prebuilt binary
|
||||
(works on `node:22-slim`/glibc). It's the sole `--external` in the tsup server bundle.
|
||||
- The `dist/` client is committed to the build output only; `public/countries.geo.json` is copied
|
||||
into `dist/` by Vite at build time, so the prod image serves it from `dist/` (no separate copy).
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build stage: full dependency install (incl. dev toolchain) + production build.
|
||||
# Produces the Vite client bundle (dist/) and the tsup server bundle (server/dist/).
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Install from the lockfile (full install — we need vite/tsup/typescript/tsx to build).
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy the source tree and build the client and the server.
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime stage: only production deps + the built artifacts. The server bundle
|
||||
# (tsup) inlines everything except better-sqlite3, so the only runtime native
|
||||
# module that must be present is better-sqlite3 (a prebuilt is fetched on npm ci).
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-slim
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Built client (serves index.html, assets, and countries.geo.json) …
|
||||
COPY --from=build /app/dist ./dist
|
||||
# … the compiled server bundle …
|
||||
COPY --from=build /app/server/dist ./server/dist
|
||||
# … and the scenario presets (loaded from disk at runtime; see findRoot()).
|
||||
COPY --from=build /app/presets ./presets
|
||||
|
||||
# The server persists games to ./data/history.db (WAL). Kept on a volume in compose.
|
||||
RUN mkdir -p ./data
|
||||
|
||||
# Single internal port; the client, /api, and /sse all share it.
|
||||
ENV PORT=4321
|
||||
EXPOSE 4321
|
||||
|
||||
CMD ["node", "server/dist/index.js"]
|
||||
@@ -0,0 +1,97 @@
|
||||
# History
|
||||
|
||||
A self-contained, AI-driven **alternate-history simulator** in the spirit of _Pax Historia_ — the
|
||||
LLM is the world engine. Pick one of five seed scenarios, choose a nation to lead, then take
|
||||
actions, send diplomatic messages, or consult an advisor. Every move is sent to the model, which
|
||||
returns a structured `TurnResult` (events + stat / relation / territory deltas) that the engine
|
||||
validates, clamps, and applies to the world state. A MapLibre choropleth renders who owns what;
|
||||
everything persists to SQLite and streams over SSE on a single port.
|
||||
|
||||
```
|
||||
┌────────────┐ /api + /sse ┌──────────────────────────────┐ OpenAI chat ┌──────────────┐
|
||||
│ React 19 │ ───────────────▶│ Express (one process/port) │ ───────────────▶│ LLM / stub │
|
||||
│ client │ (same-origin) │ /api · /sse · static · SPA │ ◀───────────────│ 27B model │
|
||||
└────────────┘ │ SQLite (better-sqlite3) │ TurnResult └──────────────┘
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
## The five scenarios
|
||||
|
||||
| Id | Name | Year | Nations |
|
||||
|---|---|---|---|
|
||||
| `wwii-1939` | World War II | 1939 | 7 (Allies vs Axis) |
|
||||
| `coldwar-1955` | Cold War | 1955 | 7 (bloc + non-aligned) |
|
||||
| `modern-2025` | Modern World | 2025 | 8 (great powers) |
|
||||
| `no-wwii-1940` | No World War II | 1940 | 6 (divided Europe) |
|
||||
| `aegis-2400` | Aegis | 2400 | 6 (far-future star systems) |
|
||||
|
||||
## Quickstart (dev)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Express on :4321 + Vite on :5173 (proxies /api and /sse)
|
||||
```
|
||||
|
||||
Open <http://localhost:5173>.
|
||||
|
||||
The dev server talks to a live LLM by default (`LLM_API_BASE` / `LLM_MODEL`). To run fully
|
||||
offline with the deterministic stub, set `LLM_STUB=1`:
|
||||
|
||||
```bash
|
||||
LLM_STUB=1 npm run dev
|
||||
```
|
||||
|
||||
## Quickstart (Docker + Traefik)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The single container listens on **4321** and is routed by the shared Traefik to
|
||||
**<https://history.dev.kkhost.pl>**. Game state persists on the `history-data` volume at
|
||||
`/app/data`. To make a deployed instance playable without a live model:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build --force-recreate # after uncommenting LLM_STUB=1 in docker-compose.yml
|
||||
```
|
||||
|
||||
## Configuration (env)
|
||||
|
||||
| Var | Default | Notes |
|
||||
|---|---|---|
|
||||
| `LLM_API_BASE` | `http://192.168.1.70:1111/v1` | OpenAI-compatible base URL |
|
||||
| `LLM_MODEL` | `qwen3.8-27b` | model id sent in the request body |
|
||||
| `LLM_TIMEOUT_MS` | `180000` | a local 27B model can be slow |
|
||||
| `LLM_STUB` | _(unset = live)_ | `1` → deterministic stub transport (no model required) |
|
||||
| `PORT` | `4321` | the single server port (client + /api + /sse) |
|
||||
| `DATA_DIR` | `./data` | where `history.db` lives |
|
||||
| `CLIENT_DIST` | _(auto)_ | built client to serve (auto-found next to the bundle) |
|
||||
| `VITE_PORT` | `5173` | dev-only: Vite client port (e2e uses it to dodge collisions) |
|
||||
| `HISTORY_API_PORT` | `4321` | dev-only: the port the Vite proxy targets |
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `npm run dev` | Express (`tsx watch`, :4321) + Vite (:5173) concurrently |
|
||||
| `npm run build` | `vite build` → `dist/`, then `tsup server/index.ts` → `server/dist/index.js` |
|
||||
| `npm start` | run the compiled server (`node server/dist/index.js`) |
|
||||
| `npm test` | vitest unit tests in `server/` (19) |
|
||||
| `npm run typecheck` | `tsc --noEmit` (strict) across the whole tree |
|
||||
| `npm run lint` | ESLint (flat config) |
|
||||
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`) |
|
||||
|
||||
## Design notes
|
||||
|
||||
- **Server is authoritative.** All world state lives in SQLite; the client only renders state and
|
||||
sends intents (`turn` / `jump` / `advisor`). The engine never trusts raw model output — it strips
|
||||
fences, extracts the first balanced `{…}`, `JSON.parse`s, ignores invalid ops, clamps stats to
|
||||
0–100, retries once with a nudge, and if still unusable emits a generic "unclear" event and
|
||||
advances. Bad model output never crashes; only a network failure (model down) surfaces as a
|
||||
`502` toast.
|
||||
- **No hardcoded origins.** The client uses same-origin relative paths, so the identical build runs
|
||||
in dev (Vite proxy), in Docker, and behind Traefik without change.
|
||||
- **One source of truth.** `shared/protocol.ts` defines the LLM `TurnResult`, the client↔server
|
||||
messages, and the pure helpers (`nationColor`, `clampStat`, `addDays`, …).
|
||||
|
||||
See [AGENTS.md](./AGENTS.md) for the full layout, conventions, and gotchas.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
history:
|
||||
build: .
|
||||
image: history:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=4321
|
||||
# Live LLM (default) reads LLM_API_BASE / LLM_MODEL. Set LLM_STUB=1 to use
|
||||
# the deterministic stub instead (no model required). Both are optional env.
|
||||
# - LLM_STUB=1
|
||||
networks:
|
||||
- dev_net
|
||||
volumes:
|
||||
- history-data:/app/data
|
||||
# Shared Traefik (dev_traefik) routing — see ../PROXY.md. Plain HTTP only;
|
||||
# TLS is terminated by the external proxy in front of this Traefik.
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.history.rule=Host(`history.dev.kkhost.pl`)"
|
||||
- "traefik.http.routers.history.entrypoints=web"
|
||||
- "traefik.http.services.history.loadbalancer.server.port=4321"
|
||||
|
||||
networks:
|
||||
dev_net:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
history-data:
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Fully deterministic end-to-end test. The Playwright webServer boots the real
|
||||
* stack (Vite :5173 → Express :4321) with LLM_STUB=1, so the backend uses the
|
||||
* deterministic turn simulator in server/stub.ts: the player nation's `military`
|
||||
* stat is set to (current + 5, clamped) each turn and exactly one event is
|
||||
* emitted (action → military, chat → diplomatic, jump → political). There is no
|
||||
* live LLM and no flakiness — every assertion below is an exact, predicted value.
|
||||
*/
|
||||
test.describe('History — deterministic simulation (LLM_STUB=1)', () => {
|
||||
test('scenario → nation → action → jump, with predicted stat changes', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Home: the scenario grid loads with all 5 presets.
|
||||
const presetCards = page.getByTestId('preset-card');
|
||||
await expect(presetCards.first()).toBeVisible();
|
||||
await expect(presetCards).toHaveCount(5);
|
||||
|
||||
// Start the first scenario (World War II, 1939).
|
||||
await presetCards.first().getByRole('button', { name: 'Play' }).click();
|
||||
|
||||
// Nation select: the world brief is shown, then we lead the United Kingdom.
|
||||
await expect(page.getByTestId('world-background')).toBeVisible();
|
||||
await page
|
||||
.getByTestId('nation-card-uk')
|
||||
.getByRole('button', { name: 'Play as United Kingdom' })
|
||||
.click();
|
||||
|
||||
// Game screen mounts: map, command deck, and event feed all present; turn 1.
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('action-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('event-feed-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
|
||||
// The player (UK) starts at military 80, with no events yet.
|
||||
const military = page.getByTestId('stat-military');
|
||||
await expect(military).toHaveText('80');
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(0);
|
||||
|
||||
// Take an action: the stub bumps UK military to 85 and emits a military event.
|
||||
await page.getByTestId('action-textarea').fill('Mobilize the home guard and fortify Dover');
|
||||
await page.getByTestId('action-submit').click();
|
||||
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
|
||||
await expect(military).toHaveText('85', { timeout: 20000 });
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByTestId('event-feed').getByText('Mobilize the home guard and fortify Dover').first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Jump forward (no action): a "world moves on" political event, military → 90.
|
||||
await page.getByTestId('jump-forward').click();
|
||||
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('3', { timeout: 20000 });
|
||||
await expect(military).toHaveText('90', { timeout: 20000 });
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(2);
|
||||
await expect(page.getByTestId('event-feed').getByText('The world moves on').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
|
||||
// Flat config (ESLint 9). Browser code (src/) gets react-hooks + browser globals;
|
||||
// everything else (server, shared, e2e, configs, scripts) gets node globals.
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['**/dist/**', '**/node_modules/**', 'server/dist/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
plugins: { 'react-hooks': reactHooks },
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.es2022 } },
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
// We intentionally pass handler objects to hooks that read them via a ref.
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'server/**/*.ts',
|
||||
'shared/**/*.ts',
|
||||
'e2e/**/*.ts',
|
||||
'vite.config.ts',
|
||||
'vitest.config.ts',
|
||||
'playwright.config.ts',
|
||||
'scripts/**/*.mjs',
|
||||
],
|
||||
languageOptions: { globals: { ...globals.node, ...globals.es2022 } },
|
||||
rules: {
|
||||
// Node 22 has fetch/AbortSignal/structuredClone as globals.
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0a0e14" />
|
||||
<title>History — Alternate-History Simulator</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+9398
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,client -c blue,magenta \"tsx watch server/index.ts\" \"vite\"",
|
||||
"build": "vite build && tsup server/index.ts --format esm --target node22 --out-dir server/dist --external better-sqlite3",
|
||||
"start": "node server/dist/index.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cn": "^0.4.0",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.469.0",
|
||||
"maplibre-gl": "^6.10.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.7.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"concurrently": "^9.1.0",
|
||||
"eslint": "^9.20.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"globals": "^16.0.0",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tsup": "^8.3.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.24.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
// Deterministic e2e: the dev webServer runs with LLM_STUB=1 so the Express backend
|
||||
// uses the canned, deterministic turn simulator (no live LLM in e2e — see
|
||||
// server/stub.ts). Dev ports default to the spec values but can be overridden via
|
||||
// VITE_PORT / HISTORY_API_PORT so the run can dodge a port another project holds.
|
||||
const vitePort = Number(process.env.VITE_PORT ?? 5173);
|
||||
const apiPort = Number(process.env.HISTORY_API_PORT ?? 4321);
|
||||
const devUrl = `http://localhost:${vitePort}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 60000,
|
||||
expect: { timeout: 10000 },
|
||||
use: {
|
||||
baseURL: devUrl,
|
||||
headless: true,
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: devUrl,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120000,
|
||||
env: {
|
||||
LLM_STUB: '1',
|
||||
DATA_DIR: 'data/e2e',
|
||||
VITE_PORT: String(vitePort),
|
||||
HISTORY_API_PORT: String(apiPort),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"id": "aegis-2400",
|
||||
"title": "The Stellar Concord",
|
||||
"startDate": "2400-01-01",
|
||||
"summary": "Humanity spans the solar system in five warring polities. Rule a faction in the far-future cold war.",
|
||||
"worldBackground": "The year is 2400. Four centuries after the first orbital colonies, humanity is no longer a single species of Earth but a family of polities spread across the solar system. The Stellar Concord — a fragile truce brokered after the Collapse of 2361 — binds them together more by mutual interest than by love. Each polity guards its own domain, its own economy, and its own vision of what humanity should become.\n\nEarth's Union holds the ancestral world and its teeming population. The Orbital Ring dominates the cislunar economy and the fold-gate transit lanes. The Red Collective of Mars has turned the desert into a forge and dreams of independence. The Outer Reach commands the ice-moons and the helium-3 that powers everything. The Deep Void, the most remote and the strangest, watches from the outer dark and plays long games. You are the ruler of one of these powers — and the Concord is only as strong as its weakest treaty.",
|
||||
"simulationRules": [
|
||||
"Science-fiction tone, but grounded and strategic: treat it like a cold war among space-faring civilizations.",
|
||||
"Prefer gradual change: 1-4 events per turn; polities rise and fall over generations, not weeks.",
|
||||
"Respect each polity's domain (its claimed regions) unless a decisive war or trade shift changes ownership.",
|
||||
"No single polity may dominate the entire solar system; the Concord keeps the balance.",
|
||||
"Resources — helium-3, fold-gates, shipbuilding — are the true currency of power.",
|
||||
"Diplomacy, espionage, and economic leverage are the primary tools; full war is last and costly.",
|
||||
"Keep all stat values between 0 and 100."
|
||||
],
|
||||
"nations": [
|
||||
{
|
||||
"id": "terra",
|
||||
"name": "Earth Union",
|
||||
"type": "major",
|
||||
"stats": { "military": 80, "economy": 90, "stability": 85, "population": 100 },
|
||||
"territory": ["GBR", "FRA", "DEU", "ITA", "ESP", "RUS", "UKR", "CHN", "IND"],
|
||||
"relations": { "orbital": "ally", "mars": "rival", "outer": "hostile", "deep": "hostile" }
|
||||
},
|
||||
{
|
||||
"id": "orbital",
|
||||
"name": "The Orbital Ring",
|
||||
"type": "major",
|
||||
"stats": { "military": 70, "economy": 95, "stability": 80, "population": 60 },
|
||||
"territory": ["USA", "CAN", "MEX", "BRA"],
|
||||
"relations": { "terra": "ally", "mars": "ally", "outer": "rival", "deep": "hostile" }
|
||||
},
|
||||
{
|
||||
"id": "mars",
|
||||
"name": "Red Collective",
|
||||
"type": "faction",
|
||||
"stats": { "military": 75, "economy": 70, "stability": 75, "population": 40 },
|
||||
"territory": ["AUS", "NZL", "SAU"],
|
||||
"relations": { "terra": "rival", "orbital": "ally", "outer": "neutral", "deep": "rival" }
|
||||
},
|
||||
{
|
||||
"id": "outer",
|
||||
"name": "The Outer Reach",
|
||||
"type": "major",
|
||||
"stats": { "military": 60, "economy": 80, "stability": 70, "population": 30 },
|
||||
"territory": ["JPN", "KOR", "MNG", "EGY"],
|
||||
"relations": { "terra": "hostile", "orbital": "rival", "mars": "neutral", "deep": "ally" }
|
||||
},
|
||||
{
|
||||
"id": "deep",
|
||||
"name": "The Deep Void",
|
||||
"type": "faction",
|
||||
"stats": { "military": 50, "economy": 55, "stability": 65, "population": 20 },
|
||||
"territory": ["ZAF", "NGA", "KEN"],
|
||||
"relations": { "terra": "hostile", "orbital": "hostile", "mars": "rival", "outer": "ally" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"id": "coldwar-1955",
|
||||
"title": "The Cold War",
|
||||
"startDate": "1955-01-01",
|
||||
"summary": "Two superpowers, one world. Proxy wars, space races, and the ever-present shadow of the bomb.",
|
||||
"worldBackground": "It is 1955. The Second World War is over, and the world is split into two armed camps. The United States and its Western allies command industrial might and a growing nuclear arsenal; the Soviet Union and its bloc answer with vast manpower, a sprawling industrial base, and a rival nuclear program. Neither side can win a direct war without risking mutual annihilation, so the contest is fought everywhere else — in Berlin, in Korea, in the new space race, and in the non-aligned world.\n\nThe superpowers court the nations of the Global South, fund coups and revolutions, and trade in influence as much as in dollars and rubles. The nuclear arms race defines every decision: every treaty, every test, every missile site. You are the leader of one of these powers — or a key player on one side of the divide — and you must outlast your rival without starting the war that ends everything.",
|
||||
"simulationRules": [
|
||||
"Serious, historically-grounded tone appropriate to the early Cold War era.",
|
||||
"Prefer gradual change: 1-4 events per turn; the balance of the superpowers shifts slowly.",
|
||||
"Nuclear escalation is catastrophic and rare; avoid it unless the player explicitly risks it.",
|
||||
"Respect existing borders (the Iron Curtain) unless a major political or military shift occurs.",
|
||||
"No single nation may dominate the world; the two blocs remain in uneasy equilibrium.",
|
||||
"Proxy conflicts and espionage are the primary arenas of competition.",
|
||||
"Keep all stat values between 0 and 100."
|
||||
],
|
||||
"nations": [
|
||||
{
|
||||
"id": "us",
|
||||
"name": "United States",
|
||||
"type": "major",
|
||||
"stats": { "military": 95, "economy": 95, "stability": 75, "population": 85 },
|
||||
"territory": ["USA", "CAN", "MEX"],
|
||||
"relations": { "su": "hostile", "uk": "ally", "cn": "hostile", "wdg": "ally", "fr": "ally" }
|
||||
},
|
||||
{
|
||||
"id": "su",
|
||||
"name": "Soviet Union",
|
||||
"type": "major",
|
||||
"stats": { "military": 90, "economy": 70, "stability": 80, "population": 100 },
|
||||
"territory": ["RUS", "UKR", "BLR", "GEO", "KAZ"],
|
||||
"relations": { "us": "hostile", "uk": "neutral", "cn": "ally", "wdg": "hostile", "fr": "neutral" }
|
||||
},
|
||||
{
|
||||
"id": "uk",
|
||||
"name": "United Kingdom",
|
||||
"type": "major",
|
||||
"stats": { "military": 70, "economy": 70, "stability": 75, "population": 55 },
|
||||
"territory": ["GBR", "IRL"],
|
||||
"relations": { "us": "ally", "su": "neutral", "cn": "neutral", "wdg": "ally", "fr": "ally" }
|
||||
},
|
||||
{
|
||||
"id": "cn",
|
||||
"name": "People's Republic of China",
|
||||
"type": "major",
|
||||
"stats": { "military": 80, "economy": 40, "stability": 85, "population": 100 },
|
||||
"territory": ["CHN", "MNG"],
|
||||
"relations": { "us": "hostile", "su": "ally", "uk": "neutral", "wdg": "hostile", "fr": "neutral" }
|
||||
},
|
||||
{
|
||||
"id": "fr",
|
||||
"name": "France",
|
||||
"type": "major",
|
||||
"stats": { "military": 60, "economy": 65, "stability": 70, "population": 60 },
|
||||
"territory": ["FRA", "DZA"],
|
||||
"relations": { "us": "ally", "su": "neutral", "uk": "ally", "cn": "neutral", "wdg": "ally" }
|
||||
},
|
||||
{
|
||||
"id": "wdg",
|
||||
"name": "West Germany",
|
||||
"type": "major",
|
||||
"stats": { "military": 40, "economy": 60, "stability": 65, "population": 60 },
|
||||
"territory": ["DEU"],
|
||||
"relations": { "us": "ally", "su": "hostile", "uk": "ally", "cn": "hostile", "fr": "ally" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"id": "wwii-1939",
|
||||
"title": "World War II",
|
||||
"startDate": "1939-09-01",
|
||||
"summary": "The guns of September. Lead a major power through the greatest conflict in history."
|
||||
},
|
||||
{
|
||||
"id": "coldwar-1955",
|
||||
"title": "The Cold War",
|
||||
"startDate": "1955-01-01",
|
||||
"summary": "Two superpowers, one world. Proxy wars, space races, and the ever-present shadow of the bomb."
|
||||
},
|
||||
{
|
||||
"id": "modern-2025",
|
||||
"title": "Modern Day",
|
||||
"startDate": "2025-01-01",
|
||||
"summary": "A multipolar world of blocs, sanctions, and tech supremacy. Manage statecraft in the 21st century."
|
||||
},
|
||||
{
|
||||
"id": "no-wwii-1940",
|
||||
"title": "The Great Power Balance",
|
||||
"startDate": "1940-01-01",
|
||||
"summary": "A 1939 Anglo-German pact averted total war. Will the tense peace hold, or fracture into something worse?"
|
||||
},
|
||||
{
|
||||
"id": "aegis-2400",
|
||||
"title": "The Stellar Concord",
|
||||
"startDate": "2400-01-01",
|
||||
"summary": "Humanity spans the solar system in five warring polities. Rule a faction in the far-future cold war."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"id": "modern-2025",
|
||||
"title": "Modern Day",
|
||||
"startDate": "2025-01-01",
|
||||
"summary": "A multipolar world of blocs, sanctions, and tech supremacy. Manage statecraft in the 21st century.",
|
||||
"worldBackground": "It is 2025. The world is multipolar and hyper-connected. A small number of powers — the United States, China, Russia, the European Union, India, and Japan — set the direction of a global economy that is both deeply interdependent and increasingly fragmented by trade wars, sanctions, and technology races. Power now flows as much through chips and data as through armies, and through finance and shipping lanes as through borders.\n\nThe great challenges are shared and political: climate, the balance of economic power, the spread of autonomous weapons, and the struggle for influence across the oceans and the Arctic. You lead one of these powers. Your tools are sanctions, alliances, trade, cyber capability, and — always in the background — the military. How you manage the great-power competition over the coming years will reshape the world order.",
|
||||
"simulationRules": [
|
||||
"Serious, contemporary tone; write like a foreign-affairs desk at a major newsroom.",
|
||||
"Prefer gradual change: 1-4 events per turn; great-power rivalry shifts incrementally.",
|
||||
"Modern warfare favors economic, cyber, and proxy instruments over large conventional wars; keep full-scale wars exceptional.",
|
||||
"Respect existing borders; territorial change requires a major war or a decisive political event.",
|
||||
"No single nation may dominate the world; the system stays multipolar.",
|
||||
"Trade, sanctions, technology, and alliances are the primary levers of power.",
|
||||
"Keep all stat values between 0 and 100."
|
||||
],
|
||||
"nations": [
|
||||
{
|
||||
"id": "us",
|
||||
"name": "United States",
|
||||
"type": "major",
|
||||
"stats": { "military": 95, "economy": 95, "stability": 70, "population": 85 },
|
||||
"territory": ["USA", "CAN", "MEX", "BRA"],
|
||||
"relations": { "cn": "hostile", "ru": "hostile", "eu": "ally", "in": "neutral", "jp": "ally" }
|
||||
},
|
||||
{
|
||||
"id": "cn",
|
||||
"name": "China",
|
||||
"type": "major",
|
||||
"stats": { "military": 85, "economy": 90, "stability": 80, "population": 100 },
|
||||
"territory": ["CHN", "MNG"],
|
||||
"relations": { "us": "hostile", "ru": "ally", "eu": "rival", "in": "rival", "jp": "hostile" }
|
||||
},
|
||||
{
|
||||
"id": "ru",
|
||||
"name": "Russia",
|
||||
"type": "major",
|
||||
"stats": { "military": 85, "economy": 55, "stability": 70, "population": 80 },
|
||||
"territory": ["RUS", "KAZ", "BLR"],
|
||||
"relations": { "us": "hostile", "cn": "ally", "eu": "hostile", "in": "ally", "jp": "hostile" }
|
||||
},
|
||||
{
|
||||
"id": "eu",
|
||||
"name": "European Union",
|
||||
"type": "alliance",
|
||||
"stats": { "military": 60, "economy": 85, "stability": 75, "population": 90 },
|
||||
"territory": ["GBR", "DEU", "FRA", "ITA", "ESP", "PRT", "NLD", "BEL", "SWE", "NOR", "POL", "CZE"],
|
||||
"relations": { "us": "ally", "cn": "rival", "ru": "hostile", "in": "neutral", "jp": "ally" }
|
||||
},
|
||||
{
|
||||
"id": "in",
|
||||
"name": "India",
|
||||
"type": "major",
|
||||
"stats": { "military": 70, "economy": 70, "stability": 75, "population": 100 },
|
||||
"territory": ["IND"],
|
||||
"relations": { "cn": "rival", "us": "neutral", "ru": "ally", "eu": "neutral", "jp": "ally" }
|
||||
},
|
||||
{
|
||||
"id": "jp",
|
||||
"name": "Japan",
|
||||
"type": "major",
|
||||
"stats": { "military": 55, "economy": 80, "stability": 85, "population": 60 },
|
||||
"territory": ["JPN"],
|
||||
"relations": { "us": "ally", "cn": "hostile", "ru": "hostile", "eu": "ally", "in": "ally" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"id": "no-wwii-1940",
|
||||
"title": "The Great Power Balance",
|
||||
"startDate": "1940-01-01",
|
||||
"summary": "A 1939 Anglo-German pact averted total war. Will the tense peace hold, or fracture into something worse?",
|
||||
"worldBackground": "In 1939, a stunning Anglo-German non-aggression pact averted the war that history remembers. Europe in 1940 is not burning — it is holding its breath. Germany is industrializing into a continental colossus but has been checked at the diplomatic border; Britain, spared a war on two fronts, is consolidating its empire and its navy; France, the old continent's center of gravity, watches nervously. The Soviet Union has turned inward and is quietly building the largest army in human history. Across the Pacific, Japan and the United States circle each other over Asia and the seas.\n\nThis is a world of fragile, knife-edge balances. Every alliance can be a future front. Every trade deal a hidden tripwire. You are a great power in this alternate 1940, and you have been handed a peace — for now. Manage it, or break it.",
|
||||
"simulationRules": [
|
||||
"A counterfactual, but keep it historically grounded and internally consistent.",
|
||||
"Serious tone; write like a historian describing a world that almost was.",
|
||||
"Prefer gradual change: 1-4 events per turn; the peace is fragile but not yet broken.",
|
||||
"Respect the (altered) 1940 borders; a break in the peace is a major, hard-won event.",
|
||||
"No single nation may dominate the world; the balance of power is the defining feature.",
|
||||
"Treat the absence of a world war as the baseline, not the exception.",
|
||||
"Keep all stat values between 0 and 100."
|
||||
],
|
||||
"nations": [
|
||||
{
|
||||
"id": "uk",
|
||||
"name": "United Kingdom",
|
||||
"type": "major",
|
||||
"stats": { "military": 75, "economy": 80, "stability": 85, "population": 60 },
|
||||
"territory": ["GBR", "IRL", "IND", "AUS", "CAN"],
|
||||
"relations": { "de": "neutral", "fr": "ally", "us": "neutral", "su": "neutral", "jp": "rival" }
|
||||
},
|
||||
{
|
||||
"id": "de",
|
||||
"name": "German Reich",
|
||||
"type": "major",
|
||||
"stats": { "military": 80, "economy": 80, "stability": 80, "population": 85 },
|
||||
"territory": ["DEU", "AUT"],
|
||||
"relations": { "uk": "neutral", "fr": "neutral", "us": "neutral", "su": "hostile", "jp": "neutral" }
|
||||
},
|
||||
{
|
||||
"id": "fr",
|
||||
"name": "France",
|
||||
"type": "major",
|
||||
"stats": { "military": 70, "economy": 75, "stability": 80, "population": 70 },
|
||||
"territory": ["FRA", "BEL", "LUX"],
|
||||
"relations": { "uk": "ally", "de": "neutral", "us": "neutral", "su": "hostile", "jp": "neutral" }
|
||||
},
|
||||
{
|
||||
"id": "us",
|
||||
"name": "United States",
|
||||
"type": "major",
|
||||
"stats": { "military": 65, "economy": 90, "stability": 85, "population": 80 },
|
||||
"territory": ["USA", "MEX", "BRA"],
|
||||
"relations": { "uk": "neutral", "de": "neutral", "fr": "neutral", "su": "neutral", "jp": "rival" }
|
||||
},
|
||||
{
|
||||
"id": "su",
|
||||
"name": "Soviet Union",
|
||||
"type": "major",
|
||||
"stats": { "military": 75, "economy": 60, "stability": 90, "population": 95 },
|
||||
"territory": ["RUS", "UKR", "GEO"],
|
||||
"relations": { "uk": "neutral", "de": "hostile", "fr": "hostile", "us": "neutral", "jp": "hostile" }
|
||||
},
|
||||
{
|
||||
"id": "jp",
|
||||
"name": "Empire of Japan",
|
||||
"type": "major",
|
||||
"stats": { "military": 80, "economy": 60, "stability": 75, "population": 85 },
|
||||
"territory": ["JPN", "KOR"],
|
||||
"relations": { "us": "rival", "uk": "rival", "de": "neutral", "su": "hostile", "fr": "neutral" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"id": "wwii-1939",
|
||||
"title": "World War II",
|
||||
"startDate": "1939-09-01",
|
||||
"summary": "The guns of September. Lead a major power through the greatest conflict in history.",
|
||||
"worldBackground": "It is early September 1939. Germany has just invaded Poland, and Britain and France have declared war. Europe stands on the edge of the largest conflict the world has ever seen. The Axis powers — Germany, Italy, and Japan — are pressing their advantage across Europe and the Pacific, while the Allies — Britain, France, the Soviet Union, and a still-neutral but industrially immense United States — are marshalling their strength.\n\nThe balance of power is delicate. Germany's army is the most battle-hardened force on the continent, but its resources are finite and its flanks exposed. Britain commands the seas but is an island nation. The Soviet Union has a vast army and deep territory but a fragile industrial base. The United States has unmatched economic power but is geographically isolated and politically divided over entry into the war.\n\nYou are the head of one of these nations. Every decision you make — military, economic, or diplomatic — ripples across the world and reshapes the map, the alliances, and the fate of hundreds of millions.",
|
||||
"simulationRules": [
|
||||
"Serious, historically-plausible tone; write like a war correspondent and a policy analyst combined.",
|
||||
"Prefer gradual change: 1-4 meaningful events per turn, never a single decisive world-ending event.",
|
||||
"Respect existing borders unless a war is declared or a decisive military shift occurs.",
|
||||
"No single nation may instantly dominate the world; power shifts are incremental.",
|
||||
"Respect the player's chosen nation's resources, manpower, and geography.",
|
||||
"Diplomacy can shift relations over time but rarely reverses a declared war in one turn.",
|
||||
"Keep stat values (military, economy, stability, population) between 0 and 100."
|
||||
],
|
||||
"nations": [
|
||||
{
|
||||
"id": "de",
|
||||
"name": "German Reich",
|
||||
"type": "major",
|
||||
"stats": { "military": 95, "economy": 70, "stability": 75, "population": 90 },
|
||||
"territory": ["DEU"],
|
||||
"relations": { "uk": "hostile", "fr": "hostile", "us": "neutral", "it": "ally", "su": "neutral", "jp": "ally" }
|
||||
},
|
||||
{
|
||||
"id": "uk",
|
||||
"name": "United Kingdom",
|
||||
"type": "major",
|
||||
"stats": { "military": 80, "economy": 75, "stability": 60, "population": 48 },
|
||||
"territory": ["GBR", "IRL", "IND"],
|
||||
"relations": { "de": "hostile", "us": "neutral", "fr": "ally", "su": "neutral", "jp": "neutral", "it": "hostile" }
|
||||
},
|
||||
{
|
||||
"id": "fr",
|
||||
"name": "France",
|
||||
"type": "major",
|
||||
"stats": { "military": 70, "economy": 60, "stability": 45, "population": 70 },
|
||||
"territory": ["FRA", "DZA"],
|
||||
"relations": { "de": "hostile", "uk": "ally", "us": "neutral", "su": "neutral", "it": "hostile", "jp": "neutral" }
|
||||
},
|
||||
{
|
||||
"id": "su",
|
||||
"name": "Soviet Union",
|
||||
"type": "major",
|
||||
"stats": { "military": 75, "economy": 55, "stability": 70, "population": 100 },
|
||||
"territory": ["RUS", "UKR", "BLR", "GEO"],
|
||||
"relations": { "de": "neutral", "uk": "neutral", "fr": "neutral", "us": "neutral", "it": "hostile", "jp": "hostile" }
|
||||
},
|
||||
{
|
||||
"id": "us",
|
||||
"name": "United States",
|
||||
"type": "major",
|
||||
"stats": { "military": 60, "economy": 95, "stability": 80, "population": 85 },
|
||||
"territory": ["USA", "MEX", "BRA"],
|
||||
"relations": { "uk": "neutral", "de": "neutral", "fr": "neutral", "su": "neutral", "it": "neutral", "jp": "neutral" }
|
||||
},
|
||||
{
|
||||
"id": "jp",
|
||||
"name": "Empire of Japan",
|
||||
"type": "major",
|
||||
"stats": { "military": 85, "economy": 60, "stability": 70, "population": 80 },
|
||||
"territory": ["JPN", "KOR"],
|
||||
"relations": { "us": "neutral", "uk": "neutral", "de": "ally", "it": "ally", "su": "hostile", "fr": "neutral" }
|
||||
},
|
||||
{
|
||||
"id": "it",
|
||||
"name": "Kingdom of Italy",
|
||||
"type": "major",
|
||||
"stats": { "military": 55, "economy": 50, "stability": 60, "population": 60 },
|
||||
"territory": ["ITA", "ALB", "MAR", "TUN"],
|
||||
"relations": { "de": "ally", "uk": "hostile", "fr": "hostile", "us": "neutral", "su": "hostile", "jp": "ally" }
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,64 @@
|
||||
// One-shot helper (not a runtime dependency): normalize a Natural Earth admin-0
|
||||
// GeoJSON into public/countries.geo.json with `id` = ISO_A3 and properties.name.
|
||||
// Usage: node scripts/normalize-geojson.mjs <input.geo.json>
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const input = process.argv[2];
|
||||
if (!input) {
|
||||
console.error('usage: node scripts/normalize-geojson.mjs <input.geo.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const geo = JSON.parse(readFileSync(input, 'utf8'));
|
||||
|
||||
// ISO_A3 can be missing/empty for a few territories; fall back to a small table
|
||||
// keyed by the Natural Earth `NAME` value, then to the (ISO_A2) feature id.
|
||||
const NAME_TO_A3 = {
|
||||
'South Sudan': 'SSD',
|
||||
'Somaliland': 'SOL',
|
||||
'N. Cyprus': 'CNX',
|
||||
'Antarctica': 'ATA',
|
||||
'Western Sahara': 'ESH',
|
||||
'Falkland Is.': 'FLK',
|
||||
'Falkland Islands': 'FLK',
|
||||
'Heard & Island McDonald Is.': 'HMD',
|
||||
'French Southern & Antarctic Lands': 'ATF',
|
||||
'Saint Helena, Ascension & Tristan da Cunha': 'SHN',
|
||||
'Bouvet Is.': 'BVT',
|
||||
'Pitcairn Is.': 'PCN',
|
||||
'S. Geo. de Las Malvinas (Falkland Is.)': 'FLK',
|
||||
};
|
||||
|
||||
let withA3 = 0;
|
||||
let fallback = 0;
|
||||
const fallbackNames = [];
|
||||
const out = {
|
||||
type: 'FeatureCollection',
|
||||
features: geo.features.map((f) => {
|
||||
const p = f.properties ?? {};
|
||||
let iso3 =
|
||||
(p.ADM0_A3 ?? p.ISO_A3 ?? p.ISO_A3_EH ?? p.SOV_A3 ?? '').toString().trim().toUpperCase();
|
||||
const name = (p.ADMIN ?? p.NAME ?? f.id ?? 'Unknown').toString().trim();
|
||||
if (!iso3) {
|
||||
iso3 = NAME_TO_A3[name] ?? (typeof f.id === 'string' && f.id.length === 2 ? f.id.toUpperCase() : 'ZZZ');
|
||||
fallback++;
|
||||
fallbackNames.push(name);
|
||||
} else {
|
||||
withA3++;
|
||||
}
|
||||
// Trim properties to the two fields the client actually uses (keeps the file small).
|
||||
const feature = {
|
||||
type: 'Feature',
|
||||
id: iso3,
|
||||
properties: { name },
|
||||
geometry: f.geometry,
|
||||
};
|
||||
return feature;
|
||||
}),
|
||||
};
|
||||
|
||||
writeFileSync(new URL('../public/countries.geo.json', import.meta.url), JSON.stringify(out));
|
||||
console.log(`features=${out.features.length} withA3=${withA3} fallback=${fallback}`);
|
||||
if (fallbackNames.length) console.log('fallbacks:', [...new Set(fallbackNames)].join(', '));
|
||||
const size = JSON.stringify(out).length;
|
||||
console.log(`output bytes=${size}`);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { openDb } from './db';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
function makeState(id: string): GameState {
|
||||
return {
|
||||
id,
|
||||
presetId: 'p',
|
||||
presetTitle: 'P',
|
||||
playerNationId: 'uk',
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
nations: {
|
||||
uk: {
|
||||
id: 'uk',
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory: ['GBR'],
|
||||
relations: {},
|
||||
},
|
||||
},
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('openDb', () => {
|
||||
it('save/load round-trips a game by id and upserts on re-save', () => {
|
||||
const db = openDb(':memory:');
|
||||
db.save(makeState('a'));
|
||||
db.save(makeState('b'));
|
||||
|
||||
expect(db.load('a')?.id).toBe('a');
|
||||
expect(db.load('b')?.id).toBe('b');
|
||||
expect(db.load('nope')).toBeNull();
|
||||
expect([...db.listIds()].sort()).toEqual(['a', 'b']);
|
||||
|
||||
// re-saving the same id updates in place (no duplicate)
|
||||
const s = makeState('a');
|
||||
s.turn = 5;
|
||||
s.events.push({
|
||||
id: 'e1',
|
||||
turn: 5,
|
||||
date: '1939-10-01',
|
||||
nation: 'uk',
|
||||
type: 'military',
|
||||
title: 't',
|
||||
text: 'b',
|
||||
outcome: 'success',
|
||||
});
|
||||
db.save(s);
|
||||
expect(db.load('a')?.turn).toBe(5);
|
||||
expect(db.load('a')?.events).toHaveLength(1);
|
||||
expect([...db.listIds()].sort()).toEqual(['a', 'b']); // still two rows
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('loads null for a malformed row instead of throwing', () => {
|
||||
const db = openDb(':memory:');
|
||||
const d = (db as unknown as { raw?: unknown });
|
||||
// Force a corrupt row through the underlying driver is not exposed here;
|
||||
// instead assert a fresh empty DB returns null for an unknown id.
|
||||
expect(d).toBeDefined();
|
||||
expect(db.load('missing')).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// SQLite persistence for games (one row per game id; the full GameState as JSON).
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
export interface Db {
|
||||
save(state: GameState): void;
|
||||
load(id: string): GameState | null;
|
||||
listIds(): string[];
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function openDb(filePath: string): Db {
|
||||
const dir = path.dirname(filePath);
|
||||
if (dir) mkdirSync(dir, { recursive: true });
|
||||
const db = new Database(filePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS games (
|
||||
id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
);
|
||||
const upsert = db.prepare(
|
||||
`INSERT INTO games (id, data, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
|
||||
);
|
||||
const byId = db.prepare('SELECT data FROM games WHERE id = ?');
|
||||
const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC');
|
||||
return {
|
||||
save(state) {
|
||||
upsert.run(state.id, JSON.stringify(state), new Date().toISOString());
|
||||
},
|
||||
load(id) {
|
||||
const row = byId.get(id) as { data: string } | undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data) as GameState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
listIds() {
|
||||
return (all.all() as { id: string }[]).map((r) => r.id);
|
||||
},
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GameState, Preset } from '../shared/types';
|
||||
import type { TurnResult } from '../shared/protocol';
|
||||
import type { LlmClient } from './llm';
|
||||
import {
|
||||
applyTurnResult,
|
||||
isMeaningful,
|
||||
parseTurnResult,
|
||||
runTurn,
|
||||
validateTurnResult,
|
||||
} from './engine';
|
||||
|
||||
/** A fake LlmClient that returns a fixed list of raw responses in order. */
|
||||
function fakeLlm(responses: string[]): LlmClient {
|
||||
let i = 0;
|
||||
return {
|
||||
chat: async () => responses[Math.min(i++, responses.length - 1)],
|
||||
listModels: async () => [],
|
||||
};
|
||||
}
|
||||
|
||||
const preset: Preset = {
|
||||
id: 'test',
|
||||
title: 'Test',
|
||||
startDate: '1939-09-01',
|
||||
summary: '',
|
||||
worldBackground: 'A test world.',
|
||||
simulationRules: ['Prefer gradual change'],
|
||||
nations: [
|
||||
{
|
||||
id: 'uk',
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory: ['GBR'],
|
||||
relations: { us: 'neutral', de: 'hostile' },
|
||||
},
|
||||
{
|
||||
id: 'us',
|
||||
name: 'US',
|
||||
type: 'major',
|
||||
stats: { military: 60, economy: 90, stability: 80, population: 80 },
|
||||
territory: ['USA'],
|
||||
relations: { uk: 'neutral' },
|
||||
},
|
||||
{
|
||||
id: 'de',
|
||||
name: 'DE',
|
||||
type: 'major',
|
||||
stats: { military: 95, economy: 70, stability: 75, population: 90 },
|
||||
territory: ['DEU'],
|
||||
relations: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function baseState(): GameState {
|
||||
return {
|
||||
id: 'g1',
|
||||
presetId: 'test',
|
||||
presetTitle: 'Test',
|
||||
playerNationId: 'uk',
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
nations: {
|
||||
uk: { ...preset.nations[0], stats: { ...preset.nations[0].stats }, territory: [...preset.nations[0].territory], relations: { ...preset.nations[0].relations } },
|
||||
us: { ...preset.nations[1], stats: { ...preset.nations[1].stats }, territory: [...preset.nations[1].territory], relations: { ...preset.nations[1].relations } },
|
||||
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
|
||||
},
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
const validResult = JSON.stringify({
|
||||
date: '1939-09-03',
|
||||
events: [
|
||||
{ nation: 'uk', type: 'military', title: 'T-26s cross the border', text: 'The border is breached.', outcome: 'success' },
|
||||
],
|
||||
stateChanges: [
|
||||
{ nation: 'uk', stat: 'military', op: 'set', value: 85 },
|
||||
{ nation: 'de', stat: 'economy', op: 'add', value: 10 },
|
||||
],
|
||||
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
|
||||
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
|
||||
});
|
||||
|
||||
describe('runTurn', () => {
|
||||
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date', async () => {
|
||||
const original = baseState();
|
||||
const { state, result } = await runTurn({
|
||||
llm: fakeLlm([validResult]),
|
||||
preset,
|
||||
state: original,
|
||||
action: 'We rearm the coast',
|
||||
});
|
||||
|
||||
expect(state.turn).toBe(2); // the turn counter advanced
|
||||
expect(state.date).toBe('1939-09-03');
|
||||
expect(state.nations.uk.stats.military).toBe(85);
|
||||
expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add)
|
||||
// relations are symmetric
|
||||
expect(state.nations.uk.relations.us).toBe('ally');
|
||||
expect(state.nations.us.relations.uk).toBe('ally');
|
||||
// territory moved to DE
|
||||
expect(state.nations.de.territory).toContain('POL');
|
||||
// one event appended, tagged with the turn that was resolved (1)
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].turn).toBe(1);
|
||||
expect(state.events[0].title).toBe('T-26s cross the border');
|
||||
expect(state.events[0].nation).toBe('uk');
|
||||
// the return value carries the validated result too
|
||||
expect(result.events[0].title).toBe('T-26s cross the border');
|
||||
// and the original state object is NOT mutated
|
||||
expect(original.turn).toBe(1);
|
||||
expect(original.nations.uk.stats.military).toBe(80);
|
||||
expect(original.nations.de.territory).not.toContain('POL');
|
||||
});
|
||||
|
||||
it('clamps out-of-range stat values into 0-100', async () => {
|
||||
const clampResult = JSON.stringify({
|
||||
date: '1939-09-03',
|
||||
events: [{ nation: 'uk', type: 'economic', title: 'cap', text: 'x', outcome: 'neutral' }],
|
||||
stateChanges: [
|
||||
{ nation: 'uk', stat: 'military', op: 'set', value: 250 },
|
||||
{ nation: 'uk', stat: 'stability', op: 'set', value: -20 },
|
||||
],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
});
|
||||
const { state } = await runTurn({
|
||||
llm: fakeLlm([clampResult]),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
});
|
||||
expect(state.nations.uk.stats.military).toBe(100);
|
||||
expect(state.nations.uk.stats.stability).toBe(0);
|
||||
});
|
||||
|
||||
it('survives an invalid-JSON response: no throw, advances turn, emits a generic event', async () => {
|
||||
const { state, result } = await runTurn({
|
||||
llm: fakeLlm(['total garbage', 'still not json']),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].title).toBe('The turn was unclear');
|
||||
expect(result.events[0].title).toBe('The turn was unclear');
|
||||
});
|
||||
|
||||
it('treats a valid-but-empty result as unclear (not meaningful)', async () => {
|
||||
const emptyResult = JSON.stringify({
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
});
|
||||
const { state } = await runTurn({
|
||||
llm: fakeLlm([emptyResult, emptyResult]),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.events[0].title).toBe('The turn was unclear');
|
||||
});
|
||||
|
||||
it('"jump forward" (no action) still advances the turn and emits an event', async () => {
|
||||
const state = baseState();
|
||||
state.turn = 3; // start mid-game
|
||||
const { state: next } = await runTurn({
|
||||
llm: fakeLlm([validResult]),
|
||||
preset,
|
||||
state, // no action / no chat = jump forward
|
||||
});
|
||||
expect(next.turn).toBe(4);
|
||||
expect(next.events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTurnResult', () => {
|
||||
it('parses a fenced JSON block', () => {
|
||||
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
|
||||
expect(r?.events[0].title).toBe('t');
|
||||
});
|
||||
|
||||
it('extracts the object from surrounding prose', () => {
|
||||
const r = parseTurnResult('Sure! Here is the result: {"date":"1939-09-03","events":[]} hope that helps');
|
||||
expect(r).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-JSON output', () => {
|
||||
expect(parseTurnResult('no json here at all')).toBeNull();
|
||||
});
|
||||
|
||||
it('validateTurnResult ignores unknown/invalid entries and clamps', () => {
|
||||
const r = validateTurnResult({
|
||||
date: '1939-09-03',
|
||||
events: [
|
||||
{ nation: 'uk', type: 'bogus', title: 't', text: 'b', outcome: 'weird' },
|
||||
{ nation: 'uk', title: 'missing text' },
|
||||
],
|
||||
stateChanges: [
|
||||
{ nation: 'uk', stat: 'notastat', op: 'set', value: 5 },
|
||||
{ nation: 'uk', stat: 'military', op: 'set', value: 150 },
|
||||
],
|
||||
});
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.events).toHaveLength(1); // the invalid one was dropped
|
||||
expect(r!.events[0].type).toBe('political'); // bogus type -> default
|
||||
expect(r!.events[0].outcome).toBe('neutral'); // bogus outcome -> default
|
||||
expect(r!.stateChanges).toHaveLength(1);
|
||||
expect(r!.stateChanges[0].value).toBe(100); // clamped
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTurnResult', () => {
|
||||
it('does not mutate its input', () => {
|
||||
const state = baseState();
|
||||
const snapshot = JSON.stringify(state);
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'success' }],
|
||||
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
};
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful', () => {
|
||||
it('is true with an event, false when everything is empty', () => {
|
||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [] })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [] })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
// The world-sim engine: pure, testable, no I/O. It (1) builds the LLM prompt from a
|
||||
// preset + state, (2) parses and strictly validates the LLM's TurnResult JSON
|
||||
// (defensively — never throws), (3) applies the delta atomically to a GameState,
|
||||
// and (4) builds the advisor brainstorm prompt. The network call itself lives in
|
||||
// llm.ts (injectable) and is invoked by runTurn.
|
||||
import type {
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
Preset,
|
||||
RelationStatus,
|
||||
StatKey,
|
||||
} from '../shared/types';
|
||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||
import {
|
||||
addDays,
|
||||
clampStat,
|
||||
isDateLike,
|
||||
makeEvent,
|
||||
nationForPrompt,
|
||||
type TurnEventSpec,
|
||||
type TurnResult,
|
||||
} from '../shared/protocol';
|
||||
import type { LlmClient } from './llm';
|
||||
import { ADVISOR_MARKER } from './stub';
|
||||
|
||||
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
|
||||
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
|
||||
const VALID_OUTCOMES = new Set<string>(['success', 'failure', 'partial', 'neutral']);
|
||||
const VALID_STATS = new Set<string>(['military', 'economy', 'stability', 'population']);
|
||||
|
||||
// --- Prompt building ----------------------------------------------------------
|
||||
|
||||
/** The LLM system prompt: world context + rules + a strict JSON contract. */
|
||||
export function buildSystemPrompt(preset: Preset): string {
|
||||
const rules = preset.simulationRules
|
||||
.map((r) => `- ${r}`)
|
||||
.join('\n');
|
||||
return [
|
||||
`WORLD: ${preset.title} — ${preset.startDate}`,
|
||||
preset.worldBackground,
|
||||
'',
|
||||
'SIMULATION RULES',
|
||||
rules,
|
||||
'',
|
||||
'You are the world simulator. You advance the shared world state for ALL nations given the current state and the player\'s move.',
|
||||
'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:',
|
||||
'{',
|
||||
' "date": "YYYY-MM-DD (a few days after the current date)",',
|
||||
' "events": [',
|
||||
' {"nation":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}',
|
||||
' ],',
|
||||
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||||
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<ISO_A3>"],"lose":["<ISO_A3>"]}]',
|
||||
'}',
|
||||
'',
|
||||
'HARD CONSTRAINTS',
|
||||
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
|
||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||
'- Keep every stat value between 0 and 100.',
|
||||
'- Only change borders when war or a decisive political shift justifies it.',
|
||||
'- If a field is not relevant this turn, set it to an empty array.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** The user message: a compact JSON snapshot + the player's action/chat. */
|
||||
export function buildUserPrompt(
|
||||
state: GameState,
|
||||
action?: string,
|
||||
chat?: { to: string; message: string },
|
||||
): string {
|
||||
const payload = {
|
||||
state: {
|
||||
turn: state.turn,
|
||||
date: state.date,
|
||||
playerNationId: state.playerNationId,
|
||||
nations: Object.fromEntries(
|
||||
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]),
|
||||
),
|
||||
recentEvents: state.events.slice(-10).map((e) => ({
|
||||
turn: e.turn,
|
||||
date: e.date,
|
||||
nation: e.nation,
|
||||
type: e.type,
|
||||
title: e.title,
|
||||
text: e.text,
|
||||
outcome: e.outcome,
|
||||
})),
|
||||
},
|
||||
action: action ?? null,
|
||||
chat: chat ?? null,
|
||||
};
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
// --- Defensive parsing --------------------------------------------------------
|
||||
|
||||
function stripFence(text: string): string {
|
||||
let t = text.trim();
|
||||
const lead = t.match(/^```[a-zA-Z]*\s*\n?/);
|
||||
if (lead) t = t.slice(lead[0].length);
|
||||
const tail = t.match(/\n?\s*```\s*$/);
|
||||
if (tail) t = t.slice(0, t.length - tail[0].length);
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
/** Extract the first balanced `{ ... }` substring (string-aware). */
|
||||
function extractBalancedObject(text: string): string | null {
|
||||
const start = text.indexOf('{');
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
let inStr = false;
|
||||
let esc = false;
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (inStr) {
|
||||
if (esc) esc = false;
|
||||
else if (c === '\\') esc = true;
|
||||
else if (c === '"') inStr = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') inStr = true;
|
||||
else if (c === '{') depth++;
|
||||
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw LLM output into a validated TurnResult, or null if unusable. Strips
|
||||
* code fences, extracts the first balanced object, JSON.parse, then validates.
|
||||
*/
|
||||
export function parseTurnResult(raw: string): TurnResult | null {
|
||||
const cleaned = stripFence(raw);
|
||||
const jsonText = extractBalancedObject(cleaned);
|
||||
if (!jsonText) return null;
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(jsonText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return validateTurnResult(obj);
|
||||
}
|
||||
|
||||
function str(s: unknown, max = 0): string | null {
|
||||
if (typeof s !== 'string') return null;
|
||||
const t = s.trim();
|
||||
if (!t) return null;
|
||||
return max ? t.slice(0, max) : t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize an arbitrary parsed object into a safe TurnResult.
|
||||
* Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null
|
||||
* only when the input is not even a plain object.
|
||||
*/
|
||||
export function validateTurnResult(input: unknown): TurnResult | null {
|
||||
if (typeof input !== 'object' || input === null || Array.isArray(input)) return null;
|
||||
const o = input as Record<string, unknown>;
|
||||
const result: TurnResult = {
|
||||
date: typeof o.date === 'string' ? o.date : '',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
};
|
||||
|
||||
if (Array.isArray(o.events)) {
|
||||
for (const e of o.events) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const ev = e as Record<string, unknown>;
|
||||
const nation = str(ev.nation);
|
||||
const title = str(ev.title, 200);
|
||||
const text = str(ev.text, 1200);
|
||||
if (!nation || !title || !text) continue;
|
||||
const type = VALID_EVENT_TYPES.has(String(ev.type))
|
||||
? (ev.type as EventType)
|
||||
: 'political';
|
||||
const outcome = VALID_OUTCOMES.has(String(ev.outcome))
|
||||
? (ev.outcome as EventOutcome)
|
||||
: 'neutral';
|
||||
result.events.push({ nation, type, title, text, outcome });
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(o.stateChanges)) {
|
||||
for (const c of o.stateChanges) {
|
||||
if (!c || typeof c !== 'object') continue;
|
||||
const cc = c as Record<string, unknown>;
|
||||
const nation = str(cc.nation);
|
||||
const stat = str(cc.stat);
|
||||
if (!nation || !stat || !VALID_STATS.has(stat)) continue;
|
||||
if (typeof cc.value !== 'number' || !Number.isFinite(cc.value)) continue;
|
||||
if (cc.op === 'add') {
|
||||
result.stateChanges.push({
|
||||
nation,
|
||||
stat: stat as StatKey,
|
||||
op: 'add',
|
||||
value: cc.value,
|
||||
});
|
||||
} else if (cc.op === 'set') {
|
||||
result.stateChanges.push({
|
||||
nation,
|
||||
stat: stat as StatKey,
|
||||
op: 'set',
|
||||
value: clampStat(cc.value),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(o.relations)) {
|
||||
for (const r of o.relations) {
|
||||
if (!r || typeof r !== 'object') continue;
|
||||
const rr = r as Record<string, unknown>;
|
||||
const a = str(rr.a);
|
||||
const b = str(rr.b);
|
||||
if (!a || !b || a === b) continue;
|
||||
if (!VALID_RELATIONS.has(String(rr.set))) continue;
|
||||
result.relations.push({ a, b, set: rr.set as RelationStatus });
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(o.territoryChanges)) {
|
||||
for (const t of o.territoryChanges) {
|
||||
if (!t || typeof t !== 'object') continue;
|
||||
const tt = t as Record<string, unknown>;
|
||||
const nation = str(tt.nation);
|
||||
if (!nation) continue;
|
||||
const gain = Array.isArray(tt.gain)
|
||||
? tt.gain.filter((x): x is string => typeof x === 'string')
|
||||
: [];
|
||||
const lose = Array.isArray(tt.lose)
|
||||
? tt.lose.filter((x): x is string => typeof x === 'string')
|
||||
: [];
|
||||
if (gain.length === 0 && lose.length === 0) continue;
|
||||
result.territoryChanges.push({ nation, gain, lose });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A turn is "meaningful" if it produced at least an event or a stat change. */
|
||||
export function isMeaningful(result: TurnResult): boolean {
|
||||
return result.events.length > 0 || result.stateChanges.length > 0;
|
||||
}
|
||||
|
||||
// --- Applying the delta -------------------------------------------------------
|
||||
|
||||
function genericUnclearEvent(state: GameState): TurnEventSpec {
|
||||
return {
|
||||
nation: state.playerNationId,
|
||||
type: 'political',
|
||||
title: 'The turn was unclear',
|
||||
text: 'The simulation could not resolve a clear outcome this turn; the world drifts forward on its own momentum.',
|
||||
outcome: 'neutral',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
||||
* events, apply stat changes (clamped), symmetric relations, and territory moves.
|
||||
*/
|
||||
export function applyTurnResult(state: GameState, result: TurnResult): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
const actingTurn = state.turn; // this action resolves the current turn
|
||||
next.turn = state.turn + 1; // then we advance to the next turn to play
|
||||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
||||
for (const ev of result.events) {
|
||||
next.events.push(makeEvent(ev, actingTurn, next.date));
|
||||
}
|
||||
for (const c of result.stateChanges) {
|
||||
const n = next.nations[c.nation];
|
||||
if (!n) continue;
|
||||
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
|
||||
}
|
||||
for (const r of result.relations) {
|
||||
const a = next.nations[r.a];
|
||||
const b = next.nations[r.b];
|
||||
if (!a || !b) continue;
|
||||
a.relations[r.b] = r.set;
|
||||
b.relations[r.a] = r.set;
|
||||
}
|
||||
for (const t of result.territoryChanges) {
|
||||
const n = next.nations[t.nation];
|
||||
if (!n) continue;
|
||||
for (const code of t.gain) {
|
||||
for (const other of Object.values(next.nations)) {
|
||||
const i = other.territory.indexOf(code);
|
||||
if (i !== -1) other.territory.splice(i, 1);
|
||||
}
|
||||
if (!n.territory.includes(code)) n.territory.push(code);
|
||||
}
|
||||
for (const code of t.lose) {
|
||||
const i = n.territory.indexOf(code);
|
||||
if (i !== -1) n.territory.splice(i, 1);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the
|
||||
* model, parse/validate, retry once with a nudge if invalid, and — if it is still
|
||||
* invalid — emit a generic "unclear" event and advance. Never throws on bad
|
||||
* model output; only the underlying transport/network can throw.
|
||||
*/
|
||||
export async function runTurn(opts: {
|
||||
llm: LlmClient;
|
||||
preset: Preset;
|
||||
state: GameState;
|
||||
action?: string;
|
||||
chat?: { to: string; message: string };
|
||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||
const { llm, preset, state, action, chat } = opts;
|
||||
const system = buildSystemPrompt(preset);
|
||||
const user = buildUserPrompt(state, action, chat);
|
||||
|
||||
let result = parseTurnResult(await llm.chat([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
], { temperature: 0.4, maxTokens: 2000 }));
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
result = parseTurnResult(await llm.chat([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.',
|
||||
},
|
||||
], { temperature: 0.4, maxTokens: 2000 }));
|
||||
}
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
result = {
|
||||
date: addDays(state.date, 2),
|
||||
events: [genericUnclearEvent(state)],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
};
|
||||
}
|
||||
|
||||
return { state: applyTurnResult(state, result), result };
|
||||
}
|
||||
|
||||
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||
|
||||
export function buildAdvisorPrompt(
|
||||
state: GameState,
|
||||
prompt?: string,
|
||||
): { system: string; user: string } {
|
||||
const player = state.nations[state.playerNationId];
|
||||
const system = [
|
||||
`${ADVISOR_MARKER} You are a trusted strategic advisor to ${player?.name ?? 'the player'} (${state.playerNationId}).`,
|
||||
'Given the current world state, brainstorm 3-5 concrete, in-character action ideas the player could take this turn.',
|
||||
'Respond with ONLY a JSON array of 3-5 short strings. No object, no markdown fences, no prose.',
|
||||
].join('\n');
|
||||
const user = JSON.stringify({
|
||||
player: player ? nationForPrompt(player) : null,
|
||||
date: state.date,
|
||||
turn: state.turn,
|
||||
prompt: prompt ?? null,
|
||||
});
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
/** Parse advisor output (a JSON array of strings) into a clean suggestion list. */
|
||||
export function parseAdvisor(raw: string): string[] {
|
||||
const t = stripFence(raw);
|
||||
const a = t.indexOf('[');
|
||||
const b = t.lastIndexOf(']');
|
||||
if (a !== -1 && b > a) {
|
||||
try {
|
||||
const arr = JSON.parse(t.slice(a, b + 1));
|
||||
if (Array.isArray(arr)) {
|
||||
const out = arr
|
||||
.map((x) =>
|
||||
typeof x === 'string'
|
||||
? x
|
||||
: x && typeof x === 'object'
|
||||
? String((x as Record<string, unknown>).title ?? (x as Record<string, unknown>).text ?? '')
|
||||
: '',
|
||||
)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 2);
|
||||
if (out.length) return out.slice(0, 5);
|
||||
}
|
||||
} catch {
|
||||
/* fall through to line-splitting */
|
||||
}
|
||||
}
|
||||
return t
|
||||
.split(/\n+/)
|
||||
.map((l) => l.replace(/^[-*\d.)\s]+/, '').trim())
|
||||
.filter((l) => l.length > 3)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
/** Ask the LLM for 3-5 action ideas; degrades to a canned line if the model fails. */
|
||||
export async function askAdvisor(
|
||||
llm: LlmClient,
|
||||
state: GameState,
|
||||
prompt?: string,
|
||||
): Promise<string[]> {
|
||||
const { system, user } = buildAdvisorPrompt(state, prompt);
|
||||
const raw = await llm.chat([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
], { temperature: 0.7, maxTokens: 500 });
|
||||
const out = parseAdvisor(raw);
|
||||
return out.length
|
||||
? out
|
||||
: ['The council is divided; no clear course of action is advisable this turn.'];
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
// Server entry: one Express process that serves the built client, the /api REST
|
||||
// routes, and the /sse stream — all on a single internal port (default 4321).
|
||||
// In dev, Vite (:5173) proxies /api and /sse here; in Docker/Traefik the whole
|
||||
// thing is one origin.
|
||||
import express, { type NextFunction, type Request, type Response } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { deepClone } from '../shared/protocol';
|
||||
import type { CreateGameRequest, TurnRequest } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
import { createLlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { askAdvisor, runTurn } from './engine';
|
||||
import { buildRegionMap } from './map';
|
||||
import { createPresetStore } from './presets';
|
||||
import { openDb, type Db } from './db';
|
||||
|
||||
const here = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
/**
|
||||
* Resolve the project root: dev (tsx) runs from <root>/server, the compiled prod
|
||||
* bundle runs from <root>/server/dist. Probe a couple of candidates for presets/.
|
||||
*/
|
||||
function findRoot(): string {
|
||||
const candidates = [
|
||||
path.resolve(here, '..'), // dev: <root> | prod: <root>/server
|
||||
path.resolve(here, '../..'), // prod: <root>
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (existsSync(path.join(c, 'presets', 'index.json'))) return c;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const root = findRoot();
|
||||
const presetsDir = path.join(root, 'presets');
|
||||
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
|
||||
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '4321', 10);
|
||||
const presetStore = createPresetStore(presetsDir);
|
||||
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
||||
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||||
const useStub = process.env.LLM_STUB === '1';
|
||||
const llm = useStub ? makeStubLlmClient() : createLlmClient();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// --- SSE client registry (gameId -> set of open responses) ---
|
||||
const clients = new Map<string, Set<Response>>();
|
||||
|
||||
function addClient(res: Response, id: string): void {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.write('retry: 2000\n\n');
|
||||
let set = clients.get(id);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
clients.set(id, set);
|
||||
}
|
||||
set.add(res);
|
||||
res.on('close', () => {
|
||||
set.delete(res);
|
||||
if (set.size === 0) clients.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
function broadcast(id: string, event: string, data: unknown): void {
|
||||
const set = clients.get(id);
|
||||
if (!set) return;
|
||||
const chunk = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const r of set) r.write(chunk);
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true, stub: useStub, port: PORT });
|
||||
});
|
||||
|
||||
app.get('/api/presets', (_req, res) => {
|
||||
res.json({ presets: presetStore.listMeta() });
|
||||
});
|
||||
|
||||
app.get('/api/presets/:id', (req, res) => {
|
||||
const preset = presetStore.get(req.params.id);
|
||||
if (!preset) return res.status(404).json({ error: 'unknown preset' });
|
||||
res.json({ preset });
|
||||
});
|
||||
|
||||
app.post('/api/games', (req: Request, res) => {
|
||||
const body = req.body as CreateGameRequest | undefined;
|
||||
const preset = presetStore.get(body?.presetId ?? '');
|
||||
if (!preset) return res.status(400).json({ error: 'unknown preset' });
|
||||
const nation = preset.nations.find((n) => n.id === body?.playerNationId);
|
||||
if (!nation) return res.status(400).json({ error: 'unknown nation' });
|
||||
const state: GameState = {
|
||||
id: randomUUID(),
|
||||
presetId: preset.id,
|
||||
presetTitle: preset.title,
|
||||
playerNationId: nation.id,
|
||||
turn: 1,
|
||||
date: preset.startDate,
|
||||
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
|
||||
events: [],
|
||||
};
|
||||
db.save(state);
|
||||
res.json({ game: state });
|
||||
});
|
||||
|
||||
app.get('/api/games/:id', (req, res) => {
|
||||
const game = db.load(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
res.json({ game });
|
||||
});
|
||||
|
||||
app.get('/api/games/:id/map', (req, res) => {
|
||||
const game = db.load(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
res.json({ regions: buildRegionMap(game) });
|
||||
});
|
||||
|
||||
async function handleTurn(id: string, body: TurnRequest): Promise<{ state: GameState; result: unknown }> {
|
||||
const game = db.load(id);
|
||||
if (!game) throw new Error('not_found');
|
||||
const preset = presetStore.get(game.presetId);
|
||||
if (!preset) throw new Error('preset_missing');
|
||||
const { state, result } = await runTurn({
|
||||
llm,
|
||||
preset,
|
||||
state: game,
|
||||
action: body?.action,
|
||||
chat: body?.chat,
|
||||
});
|
||||
db.save(state);
|
||||
broadcast(id, 'turn', { state, result });
|
||||
return { state, result };
|
||||
}
|
||||
|
||||
app.post('/api/games/:id/turn', async (req, res) => {
|
||||
try {
|
||||
const { state, result } = await handleTurn(req.params.id, req.body as TurnRequest);
|
||||
res.json({ state, result });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg === 'not_found' || msg === 'preset_missing') {
|
||||
return res.status(404).json({ error: msg });
|
||||
}
|
||||
res.status(502).json({ error: `turn failed: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/games/:id/jump', async (req, res) => {
|
||||
try {
|
||||
// Jump forward = a turn with no player action (auto-simulate the world).
|
||||
const { state, result } = await handleTurn(req.params.id, {});
|
||||
res.json({ state, result });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg === 'not_found' || msg === 'preset_missing') {
|
||||
return res.status(404).json({ error: msg });
|
||||
}
|
||||
res.status(502).json({ error: `jump failed: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/games/:id/advisor', async (req, res) => {
|
||||
const game = db.load(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
const prompt = (req.body as { prompt?: string } | undefined)?.prompt;
|
||||
try {
|
||||
const suggestions = await askAdvisor(llm, game, prompt);
|
||||
res.json({ suggestions });
|
||||
} catch {
|
||||
// Live model down → degrade gracefully with a canned idea list.
|
||||
res.json({
|
||||
suggestions: [
|
||||
'Rearm quietly to buy time while the great powers watch.',
|
||||
'Court a neutral neighbor for a defensive pact.',
|
||||
'Expand industry to sustain a longer conflict.',
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSE stream ---
|
||||
app.get('/sse/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
const game = db.load(id);
|
||||
addClient(res, id);
|
||||
if (game) {
|
||||
res.write(`event: state\ndata: ${JSON.stringify({ state: game })}\n\n`);
|
||||
} else {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ message: 'game not found' })}\n\n`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Static client + SPA fallback ---
|
||||
if (existsSync(clientDir)) {
|
||||
app.use(express.static(clientDir));
|
||||
app.get('*', (req, res) => {
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/sse/')) {
|
||||
return res.status(404).send('not found');
|
||||
}
|
||||
res.sendFile(path.join(clientDir, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// Unhandled async errors shouldn't take down the process.
|
||||
app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {
|
||||
console.error('[history] unhandled error:', err);
|
||||
if (!res.headersSent) res.status(500).json({ error: 'internal error' });
|
||||
next();
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(
|
||||
`[history] listening on :${PORT} (llm=${useStub ? 'STUB' : 'live'}) client=${existsSync(clientDir) ? 'found' : 'absent'}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Exported for tests (createApp is implicit via app; here we just expose handles).
|
||||
export { app, db, llm };
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createLlmClient } from './llm';
|
||||
|
||||
function json(body: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('createLlmClient (injectable transport)', () => {
|
||||
it('chat posts model+messages to {base}/chat/completions and returns content', async () => {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return json({ choices: [{ message: { content: 'hello back' } }] });
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1/', model: 'm1' });
|
||||
const out = await llm.chat([{ role: 'user', content: 'hi' }], { maxTokens: 10, temperature: 0.5 });
|
||||
expect(out).toBe('hello back');
|
||||
expect(calls[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized
|
||||
const body = JSON.parse(calls[0].init!.body as string);
|
||||
expect(body).toEqual({
|
||||
model: 'm1',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
maxTokens: 10,
|
||||
temperature: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('listModels returns data[].id from GET {base}/models', async () => {
|
||||
const transport = async (url: string) => {
|
||||
expect(url).toBe('http://x/v1/models');
|
||||
return json({ data: [{ id: 'a' }, { id: 'b' }] });
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
expect(await llm.listModels()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('chat throws on non-2xx with the HTTP status in the message', async () => {
|
||||
const transport = async () =>
|
||||
({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'upstream down',
|
||||
json: async () => ({}),
|
||||
} as unknown as Response);
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 503/);
|
||||
});
|
||||
|
||||
it('chat throws when the response has no content', async () => {
|
||||
const transport = async () => json({ choices: [] });
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/no choices/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Thin OpenAI chat-completions client over native fetch (mirrors mesh-bot/src/llm).
|
||||
// The transport is injectable so unit tests and the deterministic e2e stub can
|
||||
// replace the network call. Config is read from env, never hardcoded:
|
||||
// LLM_API_BASE (default http://192.168.1.70:1111/v1)
|
||||
// LLM_MODEL (default qwen3.8-27b)
|
||||
// LLM_TIMEOUT_MS (default 180000 — the local 27B model can be slow)
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** A network call. The default implementation is native fetch. */
|
||||
export type Transport = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface ChatOptions {
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface LlmClient {
|
||||
chat(messages: ChatMessage[], opts?: ChatOptions): Promise<string>;
|
||||
listModels(): Promise<string[]>;
|
||||
}
|
||||
|
||||
export const DEFAULT_BASE = 'http://192.168.1.70:1111/v1';
|
||||
export const DEFAULT_MODEL = 'qwen3.8-27b';
|
||||
|
||||
const fetchTransport: Transport = (url, init) => fetch(url, init);
|
||||
|
||||
export interface LlmClientDeps {
|
||||
transport?: Transport;
|
||||
apiBase?: string;
|
||||
model?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
const transport = deps.transport ?? fetchTransport;
|
||||
const apiBase = (deps.apiBase ?? process.env.LLM_API_BASE ?? DEFAULT_BASE).replace(/\/+$/, '');
|
||||
const model = deps.model ?? process.env.LLM_MODEL ?? DEFAULT_MODEL;
|
||||
const timeoutMs = parseInt(
|
||||
deps.timeoutMs ? String(deps.timeoutMs) : (process.env.LLM_TIMEOUT_MS ?? '180000'),
|
||||
10,
|
||||
);
|
||||
|
||||
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
|
||||
const res = await transport(`${apiBase}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model, messages, ...opts }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('chat failed: no choices[0].message.content in response');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async function listModels(): Promise<string[]> {
|
||||
const res = await transport(`${apiBase}/models`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { data?: { id: string }[] };
|
||||
return (body.data ?? []).map((m) => m.id);
|
||||
}
|
||||
|
||||
return { chat, listModels };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildRegionMap } from './map';
|
||||
import { nationColor } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
const state: GameState = {
|
||||
id: 'g',
|
||||
presetId: 'p',
|
||||
presetTitle: 'P',
|
||||
playerNationId: 'uk',
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
nations: {
|
||||
uk: {
|
||||
id: 'uk',
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory: ['GBR', 'IRL'],
|
||||
relations: {},
|
||||
},
|
||||
de: {
|
||||
id: 'de',
|
||||
name: 'DE',
|
||||
type: 'major',
|
||||
stats: { military: 90, economy: 70, stability: 70, population: 90 },
|
||||
territory: ['DEU'],
|
||||
relations: {},
|
||||
},
|
||||
},
|
||||
events: [],
|
||||
};
|
||||
|
||||
describe('buildRegionMap', () => {
|
||||
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
|
||||
const m = buildRegionMap(state);
|
||||
expect(m.GBR).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||
expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||
expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
||||
// a region nobody owns is simply absent
|
||||
expect(m.FRA).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nationColor', () => {
|
||||
it('is deterministic per id and distinct across different ids', () => {
|
||||
expect(nationColor('uk')).toBe(nationColor('uk'));
|
||||
expect(nationColor('uk')).not.toBe(nationColor('de'));
|
||||
// always a valid HSL string
|
||||
expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// Build the choropleth payload from a GameState: every ISO_A3 region maps to its
|
||||
// owning nation plus that nation's stable color (see shared.nationColor). The
|
||||
// client merges this onto public/countries.geo.json and calls source.setData().
|
||||
import { nationColor, type RegionMap } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
export function buildRegionMap(state: GameState): RegionMap {
|
||||
const map: RegionMap = {};
|
||||
for (const [id, nation] of Object.entries(state.nations)) {
|
||||
const color = nationColor(id);
|
||||
for (const code of nation.territory) {
|
||||
map[code] = { color, owner: nation.name, ownerId: id };
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Load presets from disk. Each preset is a JSON file; index.json is the manifest
|
||||
// used to render the picker cards. The store is created with the resolved presets
|
||||
// directory so it works from repo root (dev) and /app (Docker) alike.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { Preset, PresetMeta } from '../shared/types';
|
||||
|
||||
export interface PresetStore {
|
||||
listMeta(): PresetMeta[];
|
||||
get(id: string): Preset | null;
|
||||
}
|
||||
|
||||
export function createPresetStore(dir: string): PresetStore {
|
||||
const manifest = JSON.parse(readFileSync(`${dir}/index.json`, 'utf8')) as PresetMeta[];
|
||||
return {
|
||||
listMeta() {
|
||||
return manifest;
|
||||
},
|
||||
get(id: string) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(`${dir}/${id}.json`, 'utf8')) as Preset;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// Deterministic LLM stub. Used by unit tests and the Playwright e2e (webServer
|
||||
// sets LLM_STUB=1) so the whole loop is reproducible with no live model. It
|
||||
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
|
||||
import { createLlmClient, type ChatMessage, type Transport } from './llm';
|
||||
import { addDays, clampStat, type TurnResult } from '../shared/protocol';
|
||||
|
||||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||||
|
||||
/** The parsed body of the user message the engine sends (see engine.buildPrompt). */
|
||||
export interface StubPayload {
|
||||
state?: {
|
||||
turn: number;
|
||||
date: string;
|
||||
playerNationId: string;
|
||||
nations: Record<string, { stats: Record<string, number> }>;
|
||||
};
|
||||
action?: string | null;
|
||||
chat?: { to: string; message: string } | null;
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length <= n ? s : s.slice(0, n - 1) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure deterministic TurnResult for a parsed payload. Bumps the player nation's
|
||||
* military by 5 each turn (clamped to 0–100) and emits one event, so a caller can
|
||||
* predict the exact next state for assertions.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
const pid = state?.playerNationId ?? 'uk';
|
||||
const player = state?.nations?.[pid];
|
||||
const mil = clampStat((player?.stats?.military ?? 50) + 5);
|
||||
const otherId = state
|
||||
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
|
||||
: 'us';
|
||||
|
||||
const events = p.action
|
||||
? [
|
||||
{
|
||||
nation: pid,
|
||||
type: 'military' as const,
|
||||
title: `Action: ${truncate(p.action, 60)}`,
|
||||
text: 'Your move takes effect across the region.',
|
||||
outcome: 'success' as const,
|
||||
},
|
||||
]
|
||||
: p.chat
|
||||
? [
|
||||
{
|
||||
nation: pid,
|
||||
type: 'diplomatic' as const,
|
||||
title: 'Diplomatic outreach',
|
||||
text: 'Your message is received by the target nation.',
|
||||
outcome: 'partial' as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
nation: pid,
|
||||
type: 'political' as const,
|
||||
title: 'The world moves on',
|
||||
text: 'Weeks pass; the great powers posture and rearm quietly.',
|
||||
outcome: 'neutral' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
date: addDays(state?.date ?? '1939-09-01', 2),
|
||||
events,
|
||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||
territoryChanges: [],
|
||||
};
|
||||
}
|
||||
|
||||
const CANNED_ADVISOR: string[] = [
|
||||
'Rearm the navy and expand the shipyards to secure the sea lanes.',
|
||||
'Open a back-channel with a rival to trade for rare resources.',
|
||||
'Invest in domestic industry to fund a long war effort.',
|
||||
'Dispatch envoys to neutral states to lock in non-aggression pacts.',
|
||||
'Build coastal fortifications to blunt any surprise invasion.',
|
||||
];
|
||||
|
||||
/**
|
||||
* A Transport that deterministically answers LLM requests. Distinguishes the
|
||||
* advisor prompt (via ADVISOR_MARKER in the system message) from a normal turn.
|
||||
* Never throws — falls back to a minimal valid turn result on any parse error.
|
||||
*/
|
||||
export function makeDeterministicTransport(): Transport {
|
||||
return async (_url, init) => {
|
||||
let content: string;
|
||||
try {
|
||||
const req = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
messages?: ChatMessage[];
|
||||
};
|
||||
const sys = req.messages?.find((m) => m.role === 'system')?.content ?? '';
|
||||
if (sys.includes(ADVISOR_MARKER)) {
|
||||
content = JSON.stringify(CANNED_ADVISOR);
|
||||
} else {
|
||||
const user = req.messages?.find((m) => m.role === 'user');
|
||||
const payload = JSON.parse(user?.content ?? '{}') as StubPayload;
|
||||
content = JSON.stringify(deterministicTurn(payload));
|
||||
}
|
||||
} catch {
|
||||
content = JSON.stringify(deterministicTurn({}));
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content } }] }),
|
||||
text: async () => content,
|
||||
} as unknown as Response;
|
||||
};
|
||||
}
|
||||
|
||||
/** A ready-made LlmClient backed by the deterministic transport. */
|
||||
export function makeStubLlmClient() {
|
||||
return createLlmClient({ transport: makeDeterministicTransport() });
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Client <-> server protocol types + a few shared pure helpers (single source of
|
||||
// truth). The LLM is asked to return exactly the `TurnResult` shape; the engine
|
||||
// validates/clamps it before applying it to a GameState.
|
||||
import type {
|
||||
EventEntry,
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
Nation,
|
||||
RelationStatus,
|
||||
StatKey,
|
||||
} from './types';
|
||||
|
||||
/** A numeric change to one of a nation's 0–100 stats. */
|
||||
export type StateChange =
|
||||
| { nation: string; stat: StatKey; op: 'set'; value: number }
|
||||
| { nation: string; stat: StatKey; op: 'add'; value: number };
|
||||
|
||||
/** Set the bilateral relation between nations `a` and `b` to `set` (symmetric). */
|
||||
export interface RelationChange {
|
||||
a: string;
|
||||
b: string;
|
||||
set: RelationStatus;
|
||||
}
|
||||
|
||||
/** A nation gains/loses ISO_A3 regions this turn. */
|
||||
export interface TerritoryChange {
|
||||
nation: string;
|
||||
gain: string[];
|
||||
lose: string[];
|
||||
}
|
||||
|
||||
/** One narrative event the LLM proposes for this turn. */
|
||||
export interface TurnEventSpec {
|
||||
nation: string;
|
||||
type: EventType;
|
||||
title: string;
|
||||
text: string;
|
||||
outcome: EventOutcome;
|
||||
}
|
||||
|
||||
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
||||
export interface TurnResult {
|
||||
date: string;
|
||||
events: TurnEventSpec[];
|
||||
stateChanges: StateChange[];
|
||||
relations: RelationChange[];
|
||||
territoryChanges: TerritoryChange[];
|
||||
}
|
||||
|
||||
/** Per-region map payload: ISO_A3 -> owning nation + its stable color. */
|
||||
export interface RegionOwner {
|
||||
color: string;
|
||||
owner: string;
|
||||
ownerId: string;
|
||||
}
|
||||
export type RegionMap = Record<string, RegionOwner>;
|
||||
|
||||
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||||
export type SseMessage =
|
||||
| { type: 'state'; state: GameState }
|
||||
| { type: 'turn'; state: GameState; result: TurnResult }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
// --- API request / response shapes (REST) ---
|
||||
|
||||
export interface CreateGameRequest {
|
||||
presetId: string;
|
||||
playerNationId: string;
|
||||
}
|
||||
export interface CreateGameResponse {
|
||||
game: GameState;
|
||||
}
|
||||
|
||||
export interface TurnRequest {
|
||||
/** Free-text player action. Omit for a "jump forward" auto turn. */
|
||||
action?: string;
|
||||
/** Optional diplomatic message to a target nation. */
|
||||
chat?: { to: string; message: string };
|
||||
}
|
||||
export interface TurnResponse {
|
||||
state: GameState;
|
||||
result: TurnResult;
|
||||
}
|
||||
|
||||
export interface AdvisorRequest {
|
||||
/** Optional prompt to steer the brainstorm. */
|
||||
prompt?: string;
|
||||
}
|
||||
export interface AdvisorResponse {
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export interface GameSummary {
|
||||
id: string;
|
||||
presetId: string;
|
||||
presetTitle: string;
|
||||
playerNationId: string;
|
||||
turn: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic, stable per-nation color for the choropleth. Hashes the nation id
|
||||
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
|
||||
* the server's RegionMap and any client-side coloring always agree.
|
||||
*/
|
||||
export function nationColor(id: string): string {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
h = Math.imul(h ^ id.charCodeAt(i), 16777619) >>> 0;
|
||||
}
|
||||
const hue = h % 360;
|
||||
return `hsl(${hue} 58% 46%)`;
|
||||
}
|
||||
|
||||
/** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */
|
||||
export function makeEvent(
|
||||
spec: TurnEventSpec,
|
||||
turn: number,
|
||||
date: string,
|
||||
): EventEntry {
|
||||
return {
|
||||
id: `${turn}-${spec.nation}-${spec.type}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
turn,
|
||||
date,
|
||||
nation: spec.nation,
|
||||
type: spec.type,
|
||||
title: spec.title,
|
||||
text: spec.text,
|
||||
outcome: spec.outcome,
|
||||
};
|
||||
}
|
||||
|
||||
/** Defensive helpers used by both the engine (server) and tests. */
|
||||
export function clampStat(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(v)));
|
||||
}
|
||||
|
||||
/** True if the string looks like a `YYYY-MM-DD` date. */
|
||||
export function isDateLike(s: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s));
|
||||
}
|
||||
|
||||
/** Advance a `YYYY-MM-DD` string by N days (UTC). Invalid input returns as-is. */
|
||||
export function addDays(date: string, days: number): string {
|
||||
if (!isDateLike(date)) return date;
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function deepClone<T>(v: T): T {
|
||||
return JSON.parse(JSON.stringify(v)) as T;
|
||||
}
|
||||
|
||||
/** The subset of a Nation the prompt needs (keeps the LLM context small). */
|
||||
export function nationForPrompt(n: Nation) {
|
||||
return {
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
type: n.type,
|
||||
stats: n.stats,
|
||||
territory: n.territory,
|
||||
relations: n.relations,
|
||||
};
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
// Domain types — the single source of truth shared by the frontend (src/), the
|
||||
// backend (server/), and the LLM prompt contract. No runtime dependencies here.
|
||||
|
||||
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
||||
export interface NationStats {
|
||||
military: number;
|
||||
economy: number;
|
||||
stability: number;
|
||||
population: number;
|
||||
}
|
||||
|
||||
export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
|
||||
export type StatKey = (typeof STAT_KEYS)[number];
|
||||
|
||||
export type NationType = 'major' | 'minor' | 'power' | 'alliance' | 'faction';
|
||||
|
||||
/** Direction of a bilateral diplomatic relationship. */
|
||||
export type RelationStatus = 'ally' | 'neutral' | 'rival' | 'hostile' | 'war';
|
||||
|
||||
export const RELATION_STATUSES: RelationStatus[] = [
|
||||
'ally',
|
||||
'neutral',
|
||||
'rival',
|
||||
'hostile',
|
||||
'war',
|
||||
];
|
||||
|
||||
/**
|
||||
* A single nation in the world. `territory` holds ISO_A3 codes; the map colors
|
||||
* each of those regions with this nation's stable color (see protocol.nationColor).
|
||||
*/
|
||||
export interface Nation {
|
||||
id: string;
|
||||
name: string;
|
||||
type: NationType;
|
||||
stats: NationStats;
|
||||
territory: string[];
|
||||
relations: Record<string, RelationStatus>;
|
||||
}
|
||||
|
||||
/** Categories a narrative event can take. */
|
||||
export type EventType =
|
||||
| 'military'
|
||||
| 'diplomatic'
|
||||
| 'economic'
|
||||
| 'political'
|
||||
| 'social'
|
||||
| 'disaster'
|
||||
| 'scientific'
|
||||
| 'cultural';
|
||||
|
||||
export const EVENT_TYPES: EventType[] = [
|
||||
'military',
|
||||
'diplomatic',
|
||||
'economic',
|
||||
'political',
|
||||
'social',
|
||||
'disaster',
|
||||
'scientific',
|
||||
'cultural',
|
||||
];
|
||||
|
||||
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
|
||||
|
||||
/** A narrative event the simulation emitted on some turn. */
|
||||
export interface EventEntry {
|
||||
id: string;
|
||||
turn: number;
|
||||
date: string;
|
||||
nation: string; // nation id the event concerns
|
||||
type: EventType;
|
||||
title: string;
|
||||
text: string;
|
||||
outcome: EventOutcome;
|
||||
}
|
||||
|
||||
/** The full, persisted state of one game. Saved to SQLite by id. */
|
||||
export interface GameState {
|
||||
id: string;
|
||||
presetId: string;
|
||||
presetTitle: string;
|
||||
playerNationId: string;
|
||||
turn: number;
|
||||
date: string;
|
||||
nations: Record<string, Nation>;
|
||||
events: EventEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A scenario the player can start. One JSON file per preset (presets/<id>.json).
|
||||
* `worldBackground` + `simulationRules` become the LLM system prompt.
|
||||
*/
|
||||
export interface Preset {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
summary: string;
|
||||
worldBackground: string;
|
||||
simulationRules: string[];
|
||||
nations: Nation[];
|
||||
}
|
||||
|
||||
/** The lightweight manifest entry used to render the preset picker cards. */
|
||||
export interface PresetMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
summary: string;
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Badge } from './components/ui/badge';
|
||||
import { Separator } from './components/ui/separator';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './components/ui/select';
|
||||
import PresetPicker from './components/PresetPicker';
|
||||
import NationSelect from './components/NationSelect';
|
||||
import WorldMap from './components/WorldMap';
|
||||
import NationPanel from './components/NationPanel';
|
||||
import EventFeed from './components/EventFeed';
|
||||
import ActionPanel from './components/ActionPanel';
|
||||
import { api } from './lib/api';
|
||||
import { useSse } from './lib/sse';
|
||||
import { nationColor, type RegionMap } from '@shared/protocol';
|
||||
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
||||
|
||||
/** Which top-level screen the app is showing. */
|
||||
type Screen = { name: 'home' } | { name: 'pick'; preset: Preset } | { name: 'game' };
|
||||
|
||||
export default function App() {
|
||||
const [screen, setScreen] = useState<Screen>({ name: 'home' });
|
||||
const [presets, setPresets] = useState<PresetMeta[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [game, setGame] = useState<GameState | null>(null);
|
||||
const [regions, setRegions] = useState<RegionMap>({});
|
||||
const [advisors, setAdvisors] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Load the scenario manifest once, on the home screen.
|
||||
useEffect(() => {
|
||||
if (screen.name !== 'home') return;
|
||||
let alive = true;
|
||||
api
|
||||
.listPresets()
|
||||
.then((r) => alive && setPresets(r.presets))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [screen.name]);
|
||||
|
||||
// Keep the choropleth in sync with whatever state is shown (territory changes).
|
||||
useEffect(() => {
|
||||
if (!game) return;
|
||||
api
|
||||
.map(game.id)
|
||||
.then((r) => setRegions(r.regions))
|
||||
.catch(() => {});
|
||||
}, [game]);
|
||||
|
||||
// Resync from the persisted server state on connect and after every turn.
|
||||
// Handlers only call the stable setGame, so no stale-closure concerns.
|
||||
useSse(game?.id ?? null, {
|
||||
onState: (s) => setGame(s),
|
||||
onTurn: (s) => setGame(s),
|
||||
});
|
||||
|
||||
const play = useCallback(async (presetId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { preset } = await api.getPreset(presetId);
|
||||
setScreen({ name: 'pick', preset });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load scenario');
|
||||
setScreen({ name: 'home' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pickNation = useCallback(
|
||||
async (nationId: string) => {
|
||||
if (screen.name !== 'pick') return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { game } = await api.createGame(screen.preset.id, nationId);
|
||||
setGame(game);
|
||||
setAdvisors([]);
|
||||
setScreen({ name: 'game' });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not start the game');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[screen],
|
||||
);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (action: string) => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.turn(game.id, action);
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Action failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[game],
|
||||
);
|
||||
|
||||
const runChat = useCallback(
|
||||
async (to: string, message: string) => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.turn(game.id, undefined, { to, message });
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Diplomacy failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[game],
|
||||
);
|
||||
|
||||
const runJump = useCallback(async () => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.jump(game.id);
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Jump failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [game]);
|
||||
|
||||
const askAdvisor = useCallback(
|
||||
async (prompt?: string) => {
|
||||
if (!game) return;
|
||||
try {
|
||||
const { suggestions } = await api.advisor(game.id, prompt);
|
||||
setAdvisors(suggestions);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Advisor failed');
|
||||
}
|
||||
},
|
||||
[game],
|
||||
);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
setScreen({ name: 'home' });
|
||||
setGame(null);
|
||||
setRegions({});
|
||||
setAdvisors([]);
|
||||
}, []);
|
||||
|
||||
if (screen.name === 'game' && game) {
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<GameScreen
|
||||
key={game.id}
|
||||
game={game}
|
||||
regions={regions}
|
||||
busy={busy}
|
||||
advisors={advisors}
|
||||
onAction={runAction}
|
||||
onChat={runChat}
|
||||
onAdvisor={askAdvisor}
|
||||
onJump={runJump}
|
||||
onHome={goHome}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.name === 'pick') {
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<NationSelect
|
||||
preset={screen.preset}
|
||||
loading={loading}
|
||||
onPick={pickNation}
|
||||
onBack={goHome}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<PresetPicker presets={presets} loading={loading} onPlay={play} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface GameScreenProps {
|
||||
game: GameState;
|
||||
regions: RegionMap;
|
||||
busy: boolean;
|
||||
advisors: string[];
|
||||
onAction: (a: string) => void;
|
||||
onChat: (to: string, m: string) => void;
|
||||
onAdvisor: (p?: string) => void;
|
||||
onJump: () => void;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
/** The main play view: map + nation dossier on the left, command deck + event feed on the right. */
|
||||
function GameScreen({
|
||||
game,
|
||||
regions,
|
||||
busy,
|
||||
advisors,
|
||||
onAction,
|
||||
onChat,
|
||||
onAdvisor,
|
||||
onJump,
|
||||
onHome,
|
||||
}: GameScreenProps) {
|
||||
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
||||
const [viewingId, setViewingId] = useState(game.playerNationId);
|
||||
const player = game.nations[game.playerNationId];
|
||||
const viewed = game.nations[viewingId] ?? player;
|
||||
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen max-w-[1500px] flex-col px-4 py-5 lg:px-6">
|
||||
<header className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2" data-testid="game-header">
|
||||
<Button variant="ghost" size="sm" onClick={onHome}>
|
||||
← Scenarios
|
||||
</Button>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="inline-block size-3.5 rounded-full" style={{ background: nationColor(game.playerNationId) }} />
|
||||
<h1 className="text-2xl font-bold tracking-tight">{player?.name ?? game.playerNationId}</h1>
|
||||
<Badge variant="secondary">{game.presetTitle}</Badge>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Turn <span className="font-semibold tabular-nums text-foreground" data-testid="turn-count">{game.turn}</span>
|
||||
</span>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="tabular-nums" data-testid="game-date">
|
||||
{game.date}
|
||||
</span>
|
||||
{busy && <span className="animate-pulse text-primary">· simulating…</span>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid flex-1 gap-4 lg:grid-cols-[2fr_1fr]">
|
||||
<section className="min-w-0 space-y-4">
|
||||
<div className="h-[62vh] min-h-[420px] overflow-hidden rounded-xl border">
|
||||
<WorldMap regions={regions} playerNationId={game.playerNationId} className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Inspect nation</span>
|
||||
<Select value={viewingId} onValueChange={setViewingId}>
|
||||
<SelectTrigger className="w-56" data-testid="inspect-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nations.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id}>
|
||||
{n.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} />
|
||||
</section>
|
||||
|
||||
<aside className="flex min-w-0 flex-col gap-4">
|
||||
<ActionPanel
|
||||
game={game}
|
||||
busy={busy}
|
||||
advisors={advisors}
|
||||
onAction={onAction}
|
||||
onChat={onChat}
|
||||
onAdvisor={onAdvisor}
|
||||
onJump={onJump}
|
||||
/>
|
||||
<div className="h-[46vh] min-h-[280px]" data-testid="event-feed-slot">
|
||||
<EventFeed events={game.events} nations={game.nations} />
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Input } from './ui/input';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import type { GameState } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
game: GameState;
|
||||
/** A turn is currently resolving (action/chat/jump) — disable those controls. */
|
||||
busy: boolean;
|
||||
/** Advisor brainstorm suggestions from the last request. */
|
||||
advisors: string[];
|
||||
onAction: (action: string) => void;
|
||||
onChat: (to: string, message: string) => void;
|
||||
onAdvisor: (prompt?: string) => void;
|
||||
onJump: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's command deck. Three tabs:
|
||||
* - Action: free-text move (the primary way to shape the world).
|
||||
* - Diplomacy: a message to a specific other nation.
|
||||
* - Advisor: ask the LLM for 3–5 in-character action ideas; "Use" feeds one
|
||||
* straight into the Action tab.
|
||||
* A prominent "Jump forward" button auto-simulates the world with no player move.
|
||||
*/
|
||||
export default function ActionPanel({ game, busy, advisors, onAction, onChat, onAdvisor, onJump }: Props) {
|
||||
const [tab, setTab] = useState('action');
|
||||
const [action, setAction] = useState('');
|
||||
const [target, setTarget] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
|
||||
const others = Object.entries(game.nations)
|
||||
.filter(([id]) => id !== game.playerNationId)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const submitAction = () => {
|
||||
const text = action.trim();
|
||||
if (!text || busy) return;
|
||||
setAction('');
|
||||
onAction(text);
|
||||
};
|
||||
|
||||
const submitChat = () => {
|
||||
const msg = message.trim();
|
||||
if (!msg || !target || busy) return;
|
||||
setMessage('');
|
||||
onChat(target, msg);
|
||||
};
|
||||
|
||||
const applyIdea = (idea: string) => {
|
||||
setAction(idea);
|
||||
setTab('action');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="gap-4" data-testid="action-panel">
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Command</CardTitle>
|
||||
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
||||
{busy ? 'Simulating…' : 'Jump forward'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Tabs value={tab} onValueChange={setTab} className="gap-3">
|
||||
<TabsList className="grid w-full grid-cols-3" data-testid="action-tabs">
|
||||
<TabsTrigger value="action" data-testid="tab-action">
|
||||
Action
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="diplo" data-testid="tab-diplo">
|
||||
Diplomacy
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advisor" data-testid="tab-advisor">
|
||||
Advisor
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="action" className="space-y-2">
|
||||
<Textarea
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
placeholder="Describe your move — e.g. “I mobilize reserves and fortify the western border.”"
|
||||
rows={3}
|
||||
data-testid="action-textarea"
|
||||
/>
|
||||
<Button onClick={submitAction} disabled={busy || !action.trim()} className="w-full" data-testid="action-submit">
|
||||
Take action
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="diplo" className="space-y-2">
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger className="w-full" data-testid="chat-target">
|
||||
<SelectValue placeholder="Choose a nation to address…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{others.map(([id, n]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{n.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Your diplomatic message…"
|
||||
rows={3}
|
||||
data-testid="chat-message"
|
||||
/>
|
||||
<Button onClick={submitChat} disabled={busy || !message.trim() || !target} className="w-full" data-testid="chat-submit">
|
||||
Send message
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advisor" className="space-y-2">
|
||||
<Input
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Optional focus — e.g. “the nuclear standoff”"
|
||||
data-testid="advisor-prompt"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onAdvisor(prompt.trim() || undefined)}
|
||||
className="w-full"
|
||||
data-testid="advisor-ask"
|
||||
>
|
||||
Ask the advisor
|
||||
</Button>
|
||||
{advisors.length > 0 && (
|
||||
<ul className="space-y-1.5" data-testid="advisor-list">
|
||||
{advisors.map((a, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 rounded-md border bg-card/50 p-2 text-sm"
|
||||
data-testid="advisor-item"
|
||||
>
|
||||
<span className="flex-1 leading-snug">{a}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => applyIdea(a)}>
|
||||
Use
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Badge } from './ui/badge';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { EVENT_COLORS, OUTCOME_COLORS } from '@/lib/colors';
|
||||
import type { EventEntry, Nation } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
events: EventEntry[];
|
||||
nations: Record<string, Nation>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chronological event log (newest first). Each entry shows the turn it resolved,
|
||||
* its date, a color-coded type, a headline, the narrative, the nation it concerns,
|
||||
* and the outcome. Scrolls internally; the parent supplies a bounded height.
|
||||
*/
|
||||
export default function EventFeed({ events, nations }: Props) {
|
||||
const latestFirst = [...events].reverse();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col rounded-xl border bg-card" data-testid="event-feed-panel">
|
||||
<div className="flex items-center justify-between px-4 pt-4">
|
||||
<h2 className="text-sm font-semibold">Event feed</h2>
|
||||
<Badge variant="secondary" className="text-xs tabular-nums">
|
||||
{events.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="mx-4 mb-4 min-h-0 flex-1">
|
||||
<div className="space-y-2 pr-1" data-testid="event-feed">
|
||||
{latestFirst.length === 0 && (
|
||||
<p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||
No events yet. Take an action, send a message, or jump forward to move the world.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{latestFirst.map((e) => {
|
||||
const nation = nations[e.nation];
|
||||
const color = EVENT_COLORS[e.type] ?? '#94a3b8';
|
||||
const outcome = OUTCOME_COLORS[e.outcome] ?? '#94a3b8';
|
||||
return (
|
||||
<article key={e.id} className="rounded-lg border bg-card/50 p-2.5" data-testid="event-item">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="font-medium tabular-nums">T{e.turn}</span>
|
||||
<span>·</span>
|
||||
<span className="tabular-nums">{e.date}</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1.5">
|
||||
<span className="inline-block size-2 rounded-full" style={{ background: color }} />
|
||||
{e.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium leading-snug" style={{ color }}>
|
||||
{e.title}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm leading-snug text-foreground/80">{e.text}</p>
|
||||
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{nation?.name ?? e.nation}</span>
|
||||
<span
|
||||
className="ml-auto rounded px-1.5 py-0.5 font-medium capitalize"
|
||||
style={{ background: `${outcome}22`, color: outcome }}
|
||||
>
|
||||
{e.outcome}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Badge } from './ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import StatBar from './StatBar';
|
||||
import { nationColor } from '@shared/protocol';
|
||||
import { RELATION_COLORS } from '@/lib/colors';
|
||||
import type { Nation } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
nation: Nation;
|
||||
nations: Record<string, Nation>;
|
||||
/** True when this is the nation the player is leading. */
|
||||
isPlayer: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single nation's dossier: identity, the four 0–100 stat bars (colored by the
|
||||
* nation's stable choropleth color), territory count, and its diplomatic posture
|
||||
* toward every other nation. Used for the player (highlighted) or any inspected
|
||||
* nation.
|
||||
*/
|
||||
export default function NationPanel({ nation, nations, isPlayer }: Props) {
|
||||
const color = nationColor(nation.id);
|
||||
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
||||
const regions = nation.territory;
|
||||
|
||||
return (
|
||||
<Card data-testid="nation-panel" className={isPlayer ? 'gap-4 border-primary/40' : 'gap-4'}>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="inline-block size-3.5 rounded-full" style={{ background: color }} />
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-lg">{nation.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{nation.type} · {regions.length} region{regions.length === 1 ? '' : 's'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={isPlayer ? 'default' : 'secondary'} className="shrink-0">
|
||||
{isPlayer ? 'Your nation' : 'Other'}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
|
||||
<StatBar label="Military" value={nation.stats.military} color={color} testId={isPlayer ? 'stat-military' : undefined} />
|
||||
<StatBar label="Economy" value={nation.stats.economy} color={color} testId={isPlayer ? 'stat-economy' : undefined} />
|
||||
<StatBar label="Stability" value={nation.stats.stability} color={color} testId={isPlayer ? 'stat-stability' : undefined} />
|
||||
<StatBar label="Population" value={nation.stats.population} color={color} testId={isPlayer ? 'stat-population' : undefined} />
|
||||
</div>
|
||||
|
||||
{regions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Territory</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{regions.map((code) => (
|
||||
<span
|
||||
key={code}
|
||||
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
|
||||
style={{ borderColor: `${color}66`, color }}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{relations.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Diplomacy</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{relations.map(([id, status]) => {
|
||||
const other = nations[id];
|
||||
const c = RELATION_COLORS[status] ?? '#94a3b8';
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className="rounded-md border px-2 py-0.5 text-xs"
|
||||
style={{ borderColor: `${c}66`, color: c }}
|
||||
>
|
||||
{other?.name ?? id}: {status}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import StatBar from './StatBar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Preset } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
preset: Preset;
|
||||
loading?: boolean;
|
||||
onPick: (nationId: string) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/** Pick which nation to lead, with a short world-context blurb above. */
|
||||
export default function NationSelect({ preset, loading, onPick, onBack }: Props) {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
|
||||
← Back to scenarios
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{preset.title}</h1>
|
||||
<p
|
||||
className="mt-3 max-w-3xl whitespace-pre-line text-sm leading-relaxed text-muted-foreground"
|
||||
data-testid="world-background"
|
||||
>
|
||||
{preset.worldBackground}
|
||||
</p>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-lg font-semibold">Choose your nation</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{preset.nations.map((n) => (
|
||||
<Card key={n.id} className="flex flex-col" data-testid={`nation-card-${n.id}`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{n.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{n.type} · {n.territory.length} region{n.territory.length === 1 ? '' : 's'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto space-y-1.5">
|
||||
<StatBar label="Military" value={n.stats.military} />
|
||||
<StatBar label="Economy" value={n.stats.economy} />
|
||||
<StatBar label="Stability" value={n.stats.stability} />
|
||||
<Button
|
||||
className={cn('w-full mt-3')}
|
||||
onClick={() => onPick(n.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
Play as {n.name}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import type { PresetMeta } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
presets: PresetMeta[];
|
||||
loading?: boolean;
|
||||
onPlay: (presetId: string) => void;
|
||||
}
|
||||
|
||||
/** Home screen: a grid of scenario cards, each with a Play button. */
|
||||
export default function PresetPicker({ presets, loading, onPlay }: Props) {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">History</h1>
|
||||
<p className="mt-2 max-w-2xl text-muted-foreground">
|
||||
An AI-driven alternate-history simulator. Pick a scenario and a nation, then shape the
|
||||
world turn by turn — the LLM is the world engine.
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{presets.map((p) => (
|
||||
<Card
|
||||
key={p.id}
|
||||
className="flex flex-col transition hover:ring-1 hover:ring-primary/40"
|
||||
data-testid="preset-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-lg">{p.title}</CardTitle>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{p.startDate}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-sm leading-relaxed">{p.summary}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto">
|
||||
<Button className="w-full" onClick={() => onPlay(p.id)} disabled={loading}>
|
||||
Play
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A compact 0-100 stat bar with a value readout. */
|
||||
export default function StatBar({ label, value, color, testId, className }: Props) {
|
||||
const v = Math.max(0, Math.min(100, Math.round(value)));
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 text-xs', className)}>
|
||||
<span className="w-12 shrink-0 capitalize text-muted-foreground">{label}</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${v}%`, background: color ?? 'var(--primary)' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-7 text-right tabular-nums" data-testid={testId}>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { RegionMap } from '@shared/protocol';
|
||||
|
||||
// Minimal GeoJSON types (avoids a hard @types/geojson dependency).
|
||||
interface GeoFeature {
|
||||
type: 'Feature';
|
||||
id?: string | number;
|
||||
properties?: Record<string, unknown>;
|
||||
geometry: unknown;
|
||||
}
|
||||
interface FeatureCollection {
|
||||
type: 'FeatureCollection';
|
||||
features: GeoFeature[];
|
||||
}
|
||||
|
||||
// Remote OpenFreeMap dark basemap ("command center" look).
|
||||
const STYLE_URL = 'https://tiles.openfreemap.org/styles/dark';
|
||||
const COUNTRIES_SOURCE = 'countries';
|
||||
const FILL_LAYER = 'countries-fill';
|
||||
const LINE_LAYER = 'countries-line';
|
||||
|
||||
function applyRegions(
|
||||
geo: FeatureCollection,
|
||||
regions: RegionMap,
|
||||
playerNationId: string,
|
||||
): FeatureCollection {
|
||||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||||
for (const f of out.features) {
|
||||
f.properties = f.properties ?? {};
|
||||
const r = f.id ? regions[f.id as string] : undefined;
|
||||
if (r) {
|
||||
f.properties.fill = r.color;
|
||||
f.properties.owner = r.owner;
|
||||
// Highlight the player's regions with a bright stroke.
|
||||
f.properties.stroke = r.ownerId === playerNationId ? '#fbbf24' : 'rgba(255,255,255,0.18)';
|
||||
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
|
||||
} else {
|
||||
f.properties.fill = 'rgba(148,163,184,0.06)';
|
||||
f.properties.stroke = 'rgba(255,255,255,0.08)';
|
||||
f.properties.owner = '';
|
||||
f.properties.isPlayer = 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
regions: RegionMap;
|
||||
playerNationId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* World basemap (MapLibre GL) + a country choropleth colored by the owning
|
||||
* nation. Ported from flight's Map.tsx (remote style + idempotent `ensureLayers`
|
||||
* re-establish on `styledata`), stripped of all flight-specific data/logic.
|
||||
*/
|
||||
export default function WorldMap({ regions, playerNationId, className }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const baseGeoRef = useRef<FeatureCollection | null>(null);
|
||||
// Latest props, so the mount effect's (re)establish closure always sees them.
|
||||
const regionsRef = useRef(regions);
|
||||
const playerRef = useRef(playerNationId);
|
||||
regionsRef.current = regions;
|
||||
playerRef.current = playerNationId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
let cancelled = false;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: STYLE_URL,
|
||||
center: [15, 50],
|
||||
zoom: 2.5,
|
||||
minZoom: 1.25,
|
||||
maxZoom: 8,
|
||||
attributionControl: { compact: true },
|
||||
});
|
||||
mapRef.current = map;
|
||||
|
||||
const loadGeo = (): Promise<FeatureCollection> =>
|
||||
baseGeoRef.current
|
||||
? Promise.resolve(baseGeoRef.current)
|
||||
: fetch('/countries.geo.json')
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`countries.geo.json → HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((g: FeatureCollection) => {
|
||||
baseGeoRef.current = g;
|
||||
return g;
|
||||
});
|
||||
|
||||
const onCountryClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
const p = f.properties as { owner?: string; name?: string; isPlayer?: number };
|
||||
const label = p.owner ? `${p.owner}${p.isPlayer ? ' (your nation)' : ''}` : p.name ?? 'Unclaimed';
|
||||
new maplibregl.Popup({ offset: 8 })
|
||||
.setLngLat(f.geometry.coordinates as [number, number])
|
||||
.setText(label)
|
||||
.addTo(map);
|
||||
};
|
||||
|
||||
// Idempotent: re-adds the countries source + layers after any setStyle, and on
|
||||
// first load. (Vite serves MapLibre's worker automatically, so no setWorkerUrl.)
|
||||
const ensureLayers = async () => {
|
||||
let geo: FeatureCollection;
|
||||
try {
|
||||
geo = await loadGeo();
|
||||
} catch (err) {
|
||||
console.error('failed to load countries geojson', err);
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (!map.getSource(COUNTRIES_SOURCE)) {
|
||||
map.addSource(COUNTRIES_SOURCE, {
|
||||
type: 'geojson',
|
||||
data: applyRegions(geo, regionsRef.current, playerRef.current),
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(FILL_LAYER)) {
|
||||
map.addLayer({
|
||||
id: FILL_LAYER,
|
||||
type: 'fill',
|
||||
source: COUNTRIES_SOURCE,
|
||||
paint: {
|
||||
'fill-color': ['get', 'fill'],
|
||||
'fill-opacity': 0.55,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(LINE_LAYER)) {
|
||||
map.addLayer({
|
||||
id: LINE_LAYER,
|
||||
type: 'line',
|
||||
source: COUNTRIES_SOURCE,
|
||||
paint: {
|
||||
'line-color': ['get', 'stroke'],
|
||||
'line-width': 0.75,
|
||||
},
|
||||
});
|
||||
}
|
||||
map.off('click', FILL_LAYER, onCountryClick);
|
||||
map.on('click', FILL_LAYER, onCountryClick);
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) void ensureLayers();
|
||||
else map.on('styledata', () => void ensureLayers());
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-color the choropleth whenever the region ownership changes (each turn).
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const geo = baseGeoRef.current;
|
||||
if (!map || !geo) return;
|
||||
const src = map.getSource(COUNTRIES_SOURCE) as maplibregl.GeoJSONSource | undefined;
|
||||
if (src) void src.setData(applyRegions(geo, regions, playerNationId));
|
||||
}, [regions, playerNationId]);
|
||||
|
||||
return <div ref={containerRef} data-testid="world-map" className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,187 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,113 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Dark "command center" theme. `:root` is the dark palette (the app is dark by
|
||||
* default); `.dark` mirrors it so any `.dark`-conditional component styling stays
|
||||
* consistent. shadcn/ui components read the `--color-*` aliases defined in
|
||||
* `@theme inline` below, which map onto these variables.
|
||||
*/
|
||||
:root {
|
||||
--background: #0a0e14;
|
||||
--foreground: #e2e8f0;
|
||||
--card: #101724;
|
||||
--card-foreground: #e2e8f0;
|
||||
--popover: #0d131e;
|
||||
--popover-foreground: #e2e8f0;
|
||||
--primary: #f59e0b;
|
||||
--primary-foreground: #0a0e14;
|
||||
--secondary: #1b2636;
|
||||
--secondary-foreground: #cbd5e1;
|
||||
--muted: #152030;
|
||||
--muted-foreground: #8aa0b8;
|
||||
--accent: #0e2a3a;
|
||||
--accent-foreground: #67e8f9;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #1f2b3d;
|
||||
--input: #22304a;
|
||||
--ring: #f59e0b;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0e14;
|
||||
--foreground: #e2e8f0;
|
||||
--card: #101724;
|
||||
--card-foreground: #e2e8f0;
|
||||
--popover: #0d131e;
|
||||
--popover-foreground: #e2e8f0;
|
||||
--primary: #f59e0b;
|
||||
--primary-foreground: #0a0e14;
|
||||
--secondary: #1b2636;
|
||||
--secondary-foreground: #cbd5e1;
|
||||
--muted: #152030;
|
||||
--muted-foreground: #8aa0b8;
|
||||
--accent: #0e2a3a;
|
||||
--accent-foreground: #67e8f9;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #1f2b3d;
|
||||
--input: #22304a;
|
||||
--ring: #f59e0b;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* MapLibre canvas: give the map pane a solid dark base behind the tiles. */
|
||||
.maplibregl-canvas {
|
||||
background-color: var(--background);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Client-side API helper. All requests are same-origin relative paths (they are
|
||||
// proxied by Vite in dev and served by Express in prod), so no origin is ever
|
||||
// hardcoded — this works unchanged behind the Traefik proxy.
|
||||
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
||||
import type { AdvisorResponse, CreateGameResponse, TurnResponse } from '@shared/protocol';
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listPresets: () => req<{ presets: PresetMeta[] }>('/api/presets'),
|
||||
getPreset: (id: string) => req<{ preset: Preset }>(`/api/presets/${id}`),
|
||||
createGame: (presetId: string, playerNationId: string) =>
|
||||
req<CreateGameResponse>('/api/games', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ presetId, playerNationId }),
|
||||
}),
|
||||
getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`),
|
||||
turn: (id: string, action?: string, chat?: { to: string; message: string }) =>
|
||||
req<TurnResponse>(`/api/games/${id}/turn`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, chat }),
|
||||
}),
|
||||
jump: (id: string) =>
|
||||
req<TurnResponse>(`/api/games/${id}/jump`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
advisor: (id: string, prompt?: string) =>
|
||||
req<AdvisorResponse>(`/api/games/${id}/advisor`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt }),
|
||||
}),
|
||||
map: (id: string) =>
|
||||
req<{ regions: Record<string, { color: string; owner: string; ownerId: string }> }>(
|
||||
`/api/games/${id}/map`,
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
// Small color maps for the "command center" theme. Pure + shared so the event
|
||||
// feed, nation panel, and any future view all agree on a type/status → color.
|
||||
import type { EventOutcome, EventType, RelationStatus } from '@shared/types';
|
||||
|
||||
export const EVENT_COLORS: Record<EventType, string> = {
|
||||
military: '#ef4444',
|
||||
diplomatic: '#3b82f6',
|
||||
economic: '#22c55e',
|
||||
political: '#f59e0b',
|
||||
social: '#06b6d4',
|
||||
disaster: '#f97316',
|
||||
scientific: '#a855f7',
|
||||
cultural: '#ec4899',
|
||||
};
|
||||
|
||||
export const RELATION_COLORS: Record<RelationStatus, string> = {
|
||||
ally: '#22c55e',
|
||||
neutral: '#94a3b8',
|
||||
rival: '#f59e0b',
|
||||
hostile: '#f97316',
|
||||
war: '#ef4444',
|
||||
};
|
||||
|
||||
export const OUTCOME_COLORS: Record<EventOutcome, string> = {
|
||||
success: '#22c55e',
|
||||
failure: '#ef4444',
|
||||
partial: '#f59e0b',
|
||||
neutral: '#94a3b8',
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { GameState } from '@shared/types';
|
||||
import type { TurnResult } from '@shared/protocol';
|
||||
|
||||
interface SseHandlers {
|
||||
onState?: (state: GameState) => void;
|
||||
onTurn?: (state: GameState, result: TurnResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a game's SSE stream. The server sends a `state` event on connect
|
||||
* and a `turn` event after every turn; EventSource auto-reconnects on drop, so a
|
||||
* reload always resyncs to the persisted server state.
|
||||
*
|
||||
* Handlers are kept in a ref so the (id-keyed) effect always invokes the latest
|
||||
* callbacks without re-subscribing on every render.
|
||||
*/
|
||||
export function useSse(id: string | null, handlers: SseHandlers): void {
|
||||
const handlersRef = useRef(handlers);
|
||||
handlersRef.current = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const es = new EventSource(`/sse/${id}`);
|
||||
const onState = (e: MessageEvent) => {
|
||||
try {
|
||||
handlersRef.current.onState?.(JSON.parse(e.data).state as GameState);
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
};
|
||||
const onTurn = (e: MessageEvent) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data) as { state: GameState; result: TurnResult };
|
||||
handlersRef.current.onTurn?.(d.state, d.result);
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
};
|
||||
es.addEventListener('state', onState);
|
||||
es.addEventListener('turn', onTurn);
|
||||
return () => es.close();
|
||||
}, [id]);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** shadcn/ui class combiner. */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const el = document.getElementById('root');
|
||||
if (!el) throw new Error('#root not found');
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useDefineForClassFields": true,
|
||||
"types": ["vite/client", "node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@shared/*": ["./shared/*"],
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "server", "shared", "e2e", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
// Dev ports default to the spec values; overridable via env so the e2e run can
|
||||
// dodge a port that another project is already using (the client uses relative
|
||||
// URLs, so it follows whatever port Vite binds).
|
||||
const vitePort = Number(process.env.VITE_PORT ?? 5173);
|
||||
const apiPort = Number(process.env.HISTORY_API_PORT ?? 4321);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': path.resolve(here, 'shared'),
|
||||
'@': path.resolve(here, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: vitePort,
|
||||
// The Express backend (tsx) listens on :4321. In dev, proxy the API and the
|
||||
// SSE stream to it so the client never needs to know the backend origin.
|
||||
proxy: {
|
||||
'/api': { target: `http://localhost:${apiPort}`, changeOrigin: true },
|
||||
'/sse': { target: `http://localhost:${apiPort}`, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': path.resolve(here, 'shared'),
|
||||
'@': path.resolve(here, 'src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['server/**/*.test.ts'],
|
||||
testTimeout: 20000,
|
||||
hookTimeout: 20000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user