M1: backend (Express+ws+better-sqlite3) + integration tests
- server/db.js: SQLite schema mirroring Firestore doc tree - server/handlers.js: action -> shared turn fn -> tx persist -> broadcast - server/index.js: REST endpoints + WebSocket real-time push - server/server.test.js: 7 integration tests (REST CRUD + combat flow) - --forceExit for jest (open WS handles) Backend boots, serves state, persists to SQLite.
This commit is contained in:
+126
@@ -0,0 +1,126 @@
|
||||
// server/db.js — SQLite persistence layer.
|
||||
// Owns the DB file. Only writer. Synchronous via better-sqlite3.
|
||||
//
|
||||
// Schema mirrors the Firestore doc tree used by src/App.js:
|
||||
// artifacts/{APP_ID}/public/data/
|
||||
// campaigns/{id} -> name, bg, ownerId, createdAt, players[]
|
||||
// campaigns/{id}/encounters/{eid} -> name, participants[], round, currentTurnParticipantId, isStarted, isPaused, turnOrderIds[]
|
||||
// activeDisplay/status -> activeCampaignId, activeEncounterId, hidePlayerHp
|
||||
// logs/{id} -> timestamp, message, encounterName, undo, undone
|
||||
//
|
||||
// Collections (campaigns, encounters, logs) -> rows with JSON blobs for fields.
|
||||
// Single-row "status" docs (activeDisplay) -> their own tables.
|
||||
|
||||
'use strict';
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS campaigns (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
playerDisplayBackgroundUrl TEXT NOT NULL DEFAULT '',
|
||||
ownerId TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
players TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS encounters (
|
||||
id TEXT NOT NULL,
|
||||
campaignId TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
participants TEXT NOT NULL DEFAULT '[]',
|
||||
round INTEGER NOT NULL DEFAULT 0,
|
||||
currentTurnParticipantId TEXT,
|
||||
isStarted INTEGER NOT NULL DEFAULT 0,
|
||||
isPaused INTEGER NOT NULL DEFAULT 0,
|
||||
turnOrderIds TEXT NOT NULL DEFAULT '[]',
|
||||
createdAt TEXT NOT NULL,
|
||||
PRIMARY KEY (campaignId, id),
|
||||
FOREIGN KEY (campaignId) REFERENCES campaigns(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS active_display (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
activeCampaignId TEXT,
|
||||
activeEncounterId TEXT,
|
||||
hidePlayerHp INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
timestamp INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
encounterName TEXT,
|
||||
undo TEXT,
|
||||
undone INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_encounters_campaign ON encounters(campaignId);
|
||||
`;
|
||||
|
||||
function openDb(dbPath) {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(SCHEMA);
|
||||
// Ensure the single active_display row exists.
|
||||
db.prepare('INSERT OR IGNORE INTO active_display (id, hidePlayerHp) VALUES (1, 1)').run();
|
||||
return db;
|
||||
}
|
||||
|
||||
// --- JSON helpers ---
|
||||
const parseArr = (s) => (s ? JSON.parse(s) : []);
|
||||
const parseObj = (s, fallback = null) => (s ? JSON.parse(s) : fallback);
|
||||
|
||||
// --- Campaign shape (matches Firestore doc) ---
|
||||
function rowToCampaign(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
playerDisplayBackgroundUrl: row.playerDisplayBackgroundUrl,
|
||||
ownerId: row.ownerId,
|
||||
createdAt: row.createdAt,
|
||||
players: parseArr(row.players),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Encounter shape (matches Firestore doc) ---
|
||||
function rowToEncounter(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
campaignId: row.campaignId,
|
||||
name: row.name,
|
||||
participants: parseArr(row.participants),
|
||||
round: row.round,
|
||||
currentTurnParticipantId: row.currentTurnParticipantId,
|
||||
isStarted: !!row.isStarted,
|
||||
isPaused: !!row.isPaused,
|
||||
turnOrderIds: parseArr(row.turnOrderIds),
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Active display shape ---
|
||||
function rowToActiveDisplay(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
activeCampaignId: row.activeCampaignId,
|
||||
activeEncounterId: row.activeEncounterId,
|
||||
hidePlayerHp: !!row.hidePlayerHp,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
openDb,
|
||||
rowToCampaign,
|
||||
rowToEncounter,
|
||||
rowToActiveDisplay,
|
||||
};
|
||||
Reference in New Issue
Block a user