Files
History/server/db.ts
kamilkrzywda b5e2229ee2
CI / build + unit + e2e (pull_request) Successful in 2m32s
feat(#56): War as a first-class state — declare/end ops, mutual-defense drag-in, relation sync
Promote the bare 'war' relation to a named War entity in GameState.wars:
- War: { id, sides[], declaredTurn, declaredDate?, aims?, status, front? }
- TurnResult.wars: { declare: WarDeclareOp[], end: WarEndOp[] }
- syncWarRelations: derive 'war' from active wars, fallback to 'hostile' on end
- Mutual-defense drag-in: belligerent allies added to sides (one level)
- Bare 'war' back-compat: never downgraded, never force-created
- Stub: empty wars (e2e unaffected); ~30 new unit tests

v0.33.0
2026-10-15 15:47:06 +00:00

79 lines
3.0 KiB
TypeScript
Raw Permalink 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';
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) Same for named wars: a legacy game has none declared
// (any pre-v0.33 `war` relation stays a bare/implicit relation — see
// syncWarRelations, which never force-creates a War entity).
if (state.wars === undefined) state.wars = [];
// (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();
},
};
}