CI / build + unit + e2e (pull_request) Successful in 2m11s
- scripts/build-region-resources.mjs: deterministic, idempotent builder →
public/region-resources.json (1,800 entries; FNV-1a hash × area factor, 0–1, 2dp)
- server/regions.ts: hasResources()/resourceOf() optional-manifest loader
- shared/types.ts: RegionResources, IndustryType/INDUSTRY_TYPES, IndustryProject,
GameState.industry
- shared/protocol.ts: IndustryOps, TurnResult.industry, deriveNationResources
(area×population-weighted average, 2dp)
- server/engine.ts: isIndustryType, validateIndustryOps, applyIndustryOps
(per-region SET, first-declared-type wins), applyIndustryEffects
(INDUSTRY_ECONOMY_BONUS=1, +1 when region industry≥0.5; sole-owner only;
0/2+ owner regions kept but no boost; re-derive after), system+user prompt
contract lines, runTurn resourceOf opt, nationResources in user payload
- server/db.ts: legacy-row industry default migration
- server/stub.ts: industry:{projects:[]} in stub result; resources/industry
in StubPayload (never read)
- server/index.ts: resourceOf wired to jump route
- All test fixtures updated; new describe blocks in engine.test.ts,
protocol.test.ts, regions.test.ts, db.test.ts
- CHANGELOG.md v0.36.0, AGENTS.md docs, package.json 0.36.0
Fixes #59
86 lines
3.5 KiB
TypeScript
86 lines
3.5 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 { 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.35.0, #58) Same for the trade & embargo economy layer: a
|
||
// legacy game has no active trades or embargoes.
|
||
if (state.trades === undefined) state.trades = [];
|
||
if (state.embargoes === undefined) state.embargoes = [];
|
||
// (v0.36, #59) Same for the industry layer: a legacy game has no
|
||
// active industry projects.
|
||
if (state.industry === undefined) state.industry = [];
|
||
// (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();
|
||
},
|
||
};
|
||
}
|