Files
History/server/db.ts
T
kamilkrzywda 15a1642eec refactor(#74) P4: wire engine/stub/db through the ABILITIES registry
Behavior-preserving rewire: every per-ability code path now iterates the
ordered ABILITIES registry instead of hand-enumerating the eight fields.

- engine.ts: buildSystemPrompt (schema lines + constraint bullets),
  buildUserPrompt (payload keys at their historical positions),
  validateTurnResult (per-field validate), applyTurnResult (ordered apply
  block), isMeaningful, and the runTurn "unclear" fallback all drive off
  ABILITIES. The eight moved ability bodies (~1,249 lines) are deleted;
  the names the engine and its tests import are re-exported from
  ./abilities.
- abilities/types.ts: Ability.stub is a zero-arg method (the stub has no
  GameState/AbilityCtx to pass; it needs none) — all eight implementations
  were already arg-less.
- stub.ts: deterministicTurn fills the eight ability fields from the
  registry (each contributes its inert default), between the core fields
  and markers — JSON output stays byte-identical (verified against
  origin/master).
- db.ts: the legacy-row migration for ability-owned GameState fields
  (wars/trades/embargoes/industry) iterates the registry's stateFields,
  only-if-undefined, instead of a hardcoded field list.

No client changes, no new deps, no shared/ runtime deps. typecheck + lint
+ 511 unit tests green; stub JSON byte-identical to origin/master.

Fixes #74
2026-10-16 07:23:11 +00:00

90 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { migrateRegionStats } from '../shared/protocol';
import type { GameState } from '../shared/types';
import { ABILITIES } from './abilities';
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 {
let state = JSON.parse(row.data) as GameState;
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
// with it defaulted: a legacy game has no no-man's-land regions.
if (state.unowned === undefined) state.unowned = [];
// (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game
// has none awaiting confirm/reject.
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
// (v0.27, #44) Same for map markers: a legacy game has none placed.
if (state.markers === undefined) state.markers = [];
// (v0.33.0 #56, v0.35.0 #58, v0.36 #59, v0.40.0 #67) Same for every
// ability-owned GameState field (wars / trades / embargoes / industry):
// a legacy game has none of them. The ABILITIES registry is the single
// source of truth for which fields the LLM abilities own — each is a
// list, so the default is `[]`, applied only-if-undefined so a saved
// value is never wiped. (Any pre-v0.33 `war` relation stays a
// bare/implicit relation — see syncWarRelations, which never
// force-creates a War entity.)
for (const a of ABILITIES) {
const fields = a.stateFields;
if (!fields) continue;
const row = state as unknown as Record<string, unknown>;
for (const f of fields) if (row[f] === undefined) row[f] = [];
}
// (v0.28, #46) Rows saved before per-region stats existed are migrated
// ONCE at load time (and never re-migrated afterwards — the field is
// then present): each owned region is seeded with a copy of its owner's
// nation-level stats and the nations' stats are recomputed as the
// (uniform-weight) area×population-weighted median, so no data is lost
// and a single-region nation keeps exactly its old stats.
state = migrateRegionStats(state);
return state;
} 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();
},
};
}