Move territory from whole-country (ISO_A3) to individual sub-region id granularity (PR 3 of #16): - new server/regions.ts (turf-free): loadRegions (boot), expandTerritory (game creation), migrateTerritoryToRegions (legacy games on db.load), regionNamesFor / regionLabel (LLM prompt), graceful ISO_A3 fallback - game creation expands each preset nation's ISO_A3 list to its sub-region ids; pre-v0.19 saved games migrate transparently on read (idempotent) - LLM contract: territoryChanges gain/lose sub-region ids (one region at a time; gain moves out of the prior owner; lost-without-gainer = unowned); user prompt carries a top-level regions reference table ({id: 'name (parent)'} over the state's territory ids) - WorldMap: choropleth fill per sub-region (countries can split between nations), ownership via properties.regionId through the shared mapFeatureOwner, flash lights exactly the changed features (featuresByRegionIds) - deterministic stub: one region-level move per non-empty round (player annexes the lexicographically-first region of the first other nation) - tests: new server/regions.test.ts (real geo + fallback + seams); map/engine tests re-pinned to region ids; scripts/regions.test.ts stays green Measured full wwii-1939 user prompt (411 regions): ~24.7k chars / ~6.2k tokens — far under the 80k frontier limit, so the full regions table is always sent (no frontier-limited mode needed). Part of #16
63 lines
2.0 KiB
TypeScript
63 lines
2.0 KiB
TypeScript
// 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';
|
|
import { migrateTerritoryToRegions } from './regions';
|
|
|
|
export interface Db {
|
|
save(state: GameState): void;
|
|
load(id: string): GameState | null;
|
|
listIds(): string[];
|
|
delete(id: string): boolean;
|
|
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');
|
|
const del = db.prepare('DELETE FROM games WHERE id = ?');
|
|
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 {
|
|
// v0.19: migrate any pre-v0.19 (whole-country ISO_A3) territory to
|
|
// sub-region ids on read. A no-op when the region geo is not loaded,
|
|
// and idempotent once a state has been migrated (the next save then
|
|
// persists the region-level territory).
|
|
return migrateTerritoryToRegions(JSON.parse(row.data) as GameState);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
listIds() {
|
|
return (all.all() as { id: string }[]).map((r) => r.id);
|
|
},
|
|
delete(id) {
|
|
return del.run(id).changes > 0;
|
|
},
|
|
close() {
|
|
db.close();
|
|
},
|
|
};
|
|
}
|