openvibers/openvibe.games Commits
Live legacy import (Wave 12 migration gate): every HoboQuest and canvas table in Live has a recorded decision, and scripts/importLiveLegacy.ts reconciles each one (read = imported + held + excluded). Per-player HoboQuest rows (players, inventory, bank, achievements, recipes, fish, battle stats, structures, farm plots) are archived verbatim under the owner's Network subject in world.db legacy_live_rows (schema 13), because Scraplandia has no equivalent for them. Nothing is mapped into characters. Excluded: the world seed, expired daily quests, empty buff/dungeon/leaderboard tables, the pixel canvas (not a Games feature, and its rows carry IP addresses), and 47 placeholder game_players rows that Live's profile lookup still inserts. Subjects come from the Network identity service (system live) as the games principal, with Live's linked_accounts as a cross-check. Unresolved or conflicting owners are held and listed, never guessed. The importer is a dry run by default and idempotent (keyed by source primary key, never overwrites). --apply needs --backup, which it writes and verifies with integrity_check before one transaction. It refuses Live's production file. The dry run on production copies: 2224 read = 616 imported + 200 held + 1408 excluded. Not applied. docs/legacy-import.md has the inventory, the output and the host commands
Changes
@@@ -97,7 +97,10 @@ Each flush is one transaction, together with the outbox events describingit. First boot seeds from the world definition; afterwards the DB is theworld's source of truth. Platform tables (schema 12): `identity_legacy_map`,`mods`, `mod_grants`, `mod_placements`, `mod_audit`, `media_mirrors`, and the--SDK's `event_outbox`.++SDK's `event_outbox`. Schema 13 adds `legacy_live_rows`: OpenVibe.Live's++HoboQuest rows, archived verbatim under Network subjects by++`scripts/importLiveLegacy.ts` ([docs/legacy-import.md](docs/legacy-import.md)).++The simulation never reads it.## Platform boundary@@@ -141,7 +141,9 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjectsmirror are on. `games.world.saved` events reach OpenVibe.Events; no mod isinstalled, no grant exists and no asset has been mirrored to Media yet.Player progress and canvas rows from Live's old HoboQuest tables have not-- been imported.++ been imported yet. The decisions, the importer++ (`apps/server/scripts/importLiveLegacy.ts`, dry run by default) and the++ host commands are in [docs/legacy-import.md](docs/legacy-import.md).- **Shared chrome.** The portal, `/play` and `/editor` load the networknavbar and footer from `https://openvibe.network/shared/`.- **Persistence proof.** `apps/server/src/game/platformIntegration.test.ts`@@@ -158,6 +158,10 @@ with live world reconciliation.map-editor assets; mod manifest validation, registry (install / grant /enable / disable / revoke, audit) and the capability-checked runtime seamfor declarative `games-content@1` packs++- migration gate: Live's legacy HoboQuest/canvas tables are decided and++ reconciled ([docs/legacy-import.md](docs/legacy-import.md)): per-player++ HoboQuest rows are archived under subjects, and the canvas and expired++ rows are excluded. Dry run on production copies done; apply pending review- waiting on the platform: mod principals and grants in OpenVibe.Network;sandboxed executable mods (OpenVibe.Host Stage C); `games.progress.summary`user-module writes are granted but not implementednnew file mode 100644@@@ -0,0 +1,156 @@++import type Database from 'better-sqlite3';++import type { IdentityClient } from 'openvibe-sdk/identity';++/** A Live user's canonical subject: `usr_<ULID>`. Guest subjects never own Live rows. */++export declare const USER_SUBJECT: RegExp;++/** The production Live data directory; the importer reads snapshots only. */++export declare const LIVE_PRODUCTION_DIR = "/opt/openvibe.live/";++export interface LegacyTableSpec {++ table: string;++ decision: 'import' | 'exclude';++ /** Column naming the owning Live user (for exclude tables: evidence of play only). */++ userColumn?: string;++ /** Primary-key columns of the source row: the idempotency key (import tables only). */++ keyColumns?: string[];++ /** Why the table is excluded, or what the archived rows are. */++ reason: string;++ /** Rows of an import table that are excluded one by one, and why. */++ excludeRows?: {++ reason: string;++ test: (row: Row, ownsOtherRows: boolean) => boolean;++ };++}++type Row = Record<string, unknown>;++/**++ * A `game_players` row nobody played: Live's profile lookup++ * (`game.getPlayer`, still called by Live's chat profile routes) inserts a++ * default row for every profile it shows. Such a row was never acted on++ * (`last_action = created_at`), has no XP, counters, equipment, sleeping bag++ * or cosmetics, and its user owns no row in any other legacy game table.++ */++export declare function isPlaceholderPlayer(row: Row, ownsOtherRows: boolean): boolean;++/** Every legacy game and canvas table in Live, with its decision (docs/legacy-import.md). */++export declare const LEGACY_TABLES: readonly LegacyTableSpec[];++export type HoldReason = 'no-subject' | 'subject-unconfirmed' | 'subject-conflict' | 'no-owner' | 'differs-from-earlier-import';++export declare const HOLD_REASONS: Record<HoldReason, string>;++export interface HeldRow {++ table: string;++ key: string;++ liveUserId: number | null;++ reason: HoldReason;++}++export interface TableReport {++ table: string;++ decision: 'import' | 'exclude';++ /** The table does not exist in the snapshot (counts are 0). */++ missing: boolean;++ read: number;++ /** Rows in world.db after the run (new plus already imported). */++ imported: number;++ /** Rows this run writes (0 on a re-run). */++ written: number;++ held: number;++ excluded: number;++ balanced: boolean;++}++export interface ImportReport {++ dryRun: boolean;++ subjectSource: string;++ tables: TableReport[];++ totals: Omit<TableReport, 'table' | 'decision' | 'missing'>;++ /** Live users owning importable rows, and how their subjects resolved. */++ users: {++ total: number;++ mapped: number;++ held: Record<string, number>;++ };++ held: HeldRow[];++ /** Rows of import tables excluded one by one, per `table: reason`. */++ excludedRows: Record<string, number>;++ /** Legacy-looking tables in the snapshot that no decision covers. */++ unlisted: {++ table: string;++ rows: number;++ }[];++ /** Every table balances and nothing is unlisted. */++ reconciled: boolean;++}++/** Resolves Live user ids to subjects at the Network; null = the Network does not know the user. */++export type NetworkSubjectLookup = (liveUserIds: number[]) => Promise<Map<number, string | null>>;++/** Lookup through the Network identity service: `(system 'live', type 'user', id)` -> subject. */++export declare function networkSubjectLookup(identity: Pick<IdentityClient, 'resolveBatch'>): NetworkSubjectLookup;++/** Lookup from a map exported from the Network: `{ "<live user id>": "usr_…" }`. */++export declare function subjectMapLookup(map: unknown): NetworkSubjectLookup;++/** A row about to be written. */++export interface PlannedRow {++ table: string;++ key: string;++ liveUserId: number;++ subjectId: string;++ payload: string;++}++export interface ImportPlan {++ report: ImportReport;++ rows: PlannedRow[];++}++/** Deterministic JSON of a source row (sorted keys) so re-runs compare byte for byte. */++export declare function canonicalRow(row: Row): string;++export declare function rowKey(row: Row, columns: readonly string[]): string;++/**++ * Refuses the production Live database: the importer reads a `.backup`++ * snapshot, never the file Live has open.++ */++export declare function assertSnapshotPath(path: string, productionDir?: string): void;++export interface RunOptions {++ livePath?: string;++ apply: boolean;++ backupPath?: string;++ subjects?: string;++ /** Whether `backupPath` already exists. */++ backupExists: boolean;++}++/**++ * Why a run must not start, or null. `--apply` needs a fresh `--backup`++ * path (a stale file is not a backup of now) and a Network subject source.++ */++export declare function refusalFor(opts: RunOptions): string | null;++/** Subjects Live itself recorded for its users (linked_accounts, service 'network'). */++export declare function liveLinkedSubjects(live: Database.Database): Map<number, string[]>;++/**++ * The subject a Live user's rows go to, or why they are held. With a Network++ * answer the Network is the authority and Live's own record must agree; with++ * none (an informational dry run) Live's single recorded subject is used.++ */++export declare function decideSubject(liveUserId: number, linked: ReadonlyMap<number, string[]>, network: ReadonlyMap<number, string | null> | null): {++ subjectId: string;++} | {++ hold: HoldReason;++};++export interface PlanOptions {++ /** The Network's answer for each Live user id, or null to rely on Live's own record only. */++ network: NetworkSubjectLookup | null;++ subjectSource: string;++ dryRun: boolean;++}++/**++ * Reads the Live snapshot and decides every row. `world` may be null (a++ * fresh world with nothing imported yet) and is only read here.++ */++export declare function planLegacyImport(live: Database.Database, world: Database.Database | null, opts: PlanOptions): Promise<ImportPlan>;++/**++ * Writes the planned rows in one transaction. Existing keys are never++ * touched (ON CONFLICT DO NOTHING); the write count must equal the plan or++ * the transaction rolls back.++ */++export declare function applyLegacyImport(world: Database.Database, plan: ImportPlan, now: number): number;++/** Archived row counts per source table (post-apply verification). */++export declare function archivedCounts(world: Database.Database): Map<string, number>;++/**++ * Snapshot of world.db to `path` taken before anything is written, then++ * verified: PRAGMA integrity_check must answer `ok` and the snapshot must++ * hold the same players and archive rows as the source.++ */++export declare function backupAndVerify(world: Database.Database, path: string, open: (path: string) => Database.Database): Promise<void>;++/** The reconciliation report as text. */++export declare function formatReport(report: ImportReport): string;++export {};++//# sourceMappingURL=liveLegacyImport.d.ts.map\\ No newline at end of filennew file mode 100644@@@ -0,0 +1 @@++{"version":3,"file":"liveLegacyImport.d.ts","sourceRoot":"","sources":["../../src/platform/liveLegacyImport.ts"],"names":[],"mappings":"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"}\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,2 @@++export {};++//# sourceMappingURL=liveLegacyImport.test.d.ts.map\\ No newline at end of filennew file mode 100644@@@ -0,0 +1 @@++{"version":3,"file":"liveLegacyImport.test.d.ts","sourceRoot":"","sources":["../../src/platform/liveLegacyImport.test.ts"],"names":[],"mappings":""}\\ No newline at end of file@@@ -18,12 +18,14 @@"@openvibe/protocol": "workspace:*","@openvibe/shared": "workspace:*","ajv": "^8.17.1",++ "better-sqlite3": "^11.9.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2","tsx": "^4.19.3","ws": "^8.18.1"},"devDependencies": {++ "@types/better-sqlite3": "^7.6.12","@types/ws": "^8.18.0","esbuild": "^0.25.2"}nnew file mode 100644@@@ -0,0 +1,171 @@++/**++ * Imports OpenVibe.Live's legacy HoboQuest rows into world.db++ * (`legacy_live_rows`) and reconciles every legacy game and canvas table:++ * read = imported + held + excluded. See docs/legacy-import.md.++ *++ * # dry run (default): prints the reconciliation, writes nothing++ * DB_PATH=/opt/openvibe.games/data/world.db \++ * OV_OAUTH_CLIENT_SECRET=… OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000 \++ * node --import tsx apps/server/scripts/importLiveLegacy.ts \++ * --live /path/to/live-snapshot.db --subjects network++ *++ * # apply: snapshot world.db to --backup, verify it, then import in one transaction++ * … importLiveLegacy.ts --live … --subjects network --apply --backup /path/world-before.db++ *++ * Options:++ * --live <path> a `.backup` snapshot of Live's live.db (never the production file)++ * --world <path> world.db (default DB_PATH, else data/world.db)++ * --subjects network resolve Live user ids at the Network identity service++ * (games principal, grant identity.subject.resolve)++ * --subjects <file> or a JSON object { "<live user id>": "usr_…" } exported from it++ * (omitted: Live's own linked_accounts record only; dry run only)++ * --apply write; requires --backup and --subjects++ * --backup <path> where world.db is copied first (must not exist); verified with integrity_check++ * --json print the report as JSON (includes every held row)++ *++ * Exit: 0 reconciled, 1 error, 2 refused (usage), 3 not reconciled.++ * Idempotent: a re-run writes nothing (written = 0) and reports the same counts.++ */++import { existsSync, readFileSync } from 'node:fs'++import Database from 'better-sqlite3'++import { openSqliteStore } from '@openvibe/persistence/sqlite'++import { createIdentityClient } from 'openvibe-sdk/identity'++import { loadConfig } from '../src/config.js'++import {++ applyLegacyImport,++ archivedCounts,++ assertSnapshotPath,++ backupAndVerify,++ formatReport,++ networkSubjectLookup,++ planLegacyImport,++ refusalFor,++ subjectMapLookup,++ type NetworkSubjectLookup,++} from '../src/platform/liveLegacyImport.js'++import { createPlatformClient } from '../src/platform/serviceClient.js'++++class Refusal extends Error {}++++function arg(name: string): string | undefined {++ const i = process.argv.indexOf(name)++ if (i < 0) return undefined++ const v = process.argv[i + 1]++ if (!v || v.startsWith('--')) throw new Refusal(`${name} needs a value`)++ return v++}++++function subjectLookup(++ source: string | undefined,++ config: ReturnType<typeof loadConfig>,++): NetworkSubjectLookup | null {++ if (!source) return null++ if (source === 'network') {++ const platform = createPlatformClient(config.platform)++ if (!platform) throw new Refusal('--subjects network needs OV_OAUTH_CLIENT_SECRET')++ return networkSubjectLookup(createIdentityClient(platform.client))++ }++ try {++ return subjectMapLookup(JSON.parse(readFileSync(source, 'utf8')))++ } catch (err) {++ throw new Refusal(`${source}: ${err instanceof Error ? err.message : String(err)}`)++ }++}++++async function main(): Promise<number> {++ const config = loadConfig(process.env)++ const livePath = arg('--live')++ const worldPath = arg('--world') ?? config.dbPath++ const subjects = arg('--subjects')++ const backupPath = arg('--backup')++ const apply = process.argv.includes('--apply')++ const json = process.argv.includes('--json')++++ const refusal = refusalFor({++ livePath,++ apply,++ backupPath,++ subjects,++ backupExists: backupPath !== undefined && existsSync(backupPath),++ })++ if (refusal || !livePath) throw new Refusal(refusal ?? '--live is required')++ try {++ assertSnapshotPath(livePath)++ } catch (err) {++ throw new Refusal(err instanceof Error ? err.message : String(err))++ }++ if (!existsSync(worldPath)) throw new Refusal(`world database ${worldPath} does not exist`)++++ const lookup = subjectLookup(subjects, config)++ const subjectSource = !subjects++ ? "Live's linked_accounts only"++ : subjects === 'network'++ ? 'the Network identity service'++ : `subject map ${subjects}`++ const live = new Database(livePath, { readonly: true, fileMustExist: true })++ try {++ if (!apply) {++ const world = new Database(worldPath, { readonly: true, fileMustExist: true })++ try {++ const plan = await planLegacyImport(live, world, {++ network: lookup,++ subjectSource,++ dryRun: true,++ })++ console.log(json ? JSON.stringify(plan.report, null, 2) : formatReport(plan.report))++ return plan.report.reconciled ? 0 : 3++ } finally {++ world.close()++ }++ }++++ // Apply: back up world.db before anything (including the schema upgrade) writes to it.++ const raw = new Database(worldPath, { fileMustExist: true })++ try {++ await backupAndVerify(raw, backupPath as string, (p) => new Database(p, { readonly: true }))++ } finally {++ raw.close()++ }++ console.error(`backup written and verified (integrity_check ok): ${backupPath}`)++++ const store = openSqliteStore(worldPath) // upgrades to the schema with legacy_live_rows++ try {++ const plan = await planLegacyImport(live, store.db, {++ network: lookup,++ subjectSource,++ dryRun: false,++ })++ if (!plan.report.reconciled) {++ console.log(json ? JSON.stringify(plan.report, null, 2) : formatReport(plan.report))++ console.error('not reconciled: nothing written')++ return 3++ }++ const written = applyLegacyImport(store.db, plan, Date.now())++ const after = archivedCounts(store.db)++ for (const t of plan.report.tables) {++ if (t.decision === 'import' && (after.get(t.table) ?? 0) < t.imported) {++ throw new Error(`post-apply check: ${t.table} holds fewer rows than imported`)++ }++ }++ console.log(json ? JSON.stringify(plan.report, null, 2) : formatReport(plan.report))++ console.error(`wrote ${written} rows to legacy_live_rows`)++ return 0++ } finally {++ store.close()++ }++ } finally {++ live.close()++ }++}++++main().then(++ (code) => process.exit(code),++ (err: unknown) => {++ if (err instanceof Refusal) {++ console.error(`refused: ${err.message}`)++ process.exit(2)++ }++ console.error('live legacy import failed:', err)++ process.exit(1)++ },++)nnew file mode 100644@@@ -0,0 +1,578 @@++import { spawnSync } from 'node:child_process'++import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'++import { tmpdir } from 'node:os'++import { dirname, join } from 'node:path'++import { fileURLToPath } from 'node:url'++import Database from 'better-sqlite3'++import { openSqliteStore } from '@openvibe/persistence/sqlite'++import { createIdentityClient } from 'openvibe-sdk/identity'++import { createMockPlatform } from 'openvibe-sdk/testing'++import { afterEach, beforeEach, describe, expect, it } from 'vitest'++import { loadPlatformConfig } from '../config.js'++import {++ applyLegacyImport,++ archivedCounts,++ assertSnapshotPath,++ backupAndVerify,++ decideSubject,++ formatReport,++ isPlaceholderPlayer,++ networkSubjectLookup,++ planLegacyImport,++ refusalFor,++ subjectMapLookup,++ type ImportReport,++ type TableReport,++} from './liveLegacyImport.js'++import { createPlatformClient } from './serviceClient.js'++++// Canonical subjects (Crockford base32 ULIDs).++const S1 = 'usr_01KKT9AC60KM7CRTB3WN1Z8P56'++const S2 = 'usr_01KKBC57W8GDSNVJJDQ15PQCDG'++const S4A = 'usr_01KKBC57W8GDSNVJJDQ15PQCD4'++const S4B = 'usr_01KKBC57W8GDSNVJJDQ15PQCD5'++const S5 = 'usr_01KKBC57W8GDSNVJJDQ15PQCD6'++const S6 = 'usr_01KKBC57W8GDSNVJJDQ15PQCD8'++const S7 = 'usr_01KKBC57W8GDSNVJJDQ15PQCD9'++++/**++ * A Live snapshot with Live's real column layout for the tables that matter:++ * user 1 — Live and the Network agree (S1)++ * user 2 — no Live record, the Network knows it (S2)++ * user 3 — nobody knows it (held: no-subject)++ * user 4 — Live says S4A, the Network S4B (held: subject-conflict)++ * user 5 — Live says S5, the Network does not know it (held: subject-unconfirmed)++ * user 6 — a placeholder game_players row Live's profile lookup made (excluded)++ * user 7 — never acted and has no XP, but has a daily-quest row: played (S7)++ */++function makeLive(path: string): void {++ const db = new Database(path)++ db.pragma('synchronous = OFF')++ db.exec(`++ BEGIN;++ CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL);++ CREATE TABLE linked_accounts (++ id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, service TEXT NOT NULL,++ service_user_id TEXT NOT NULL, service_username TEXT, linked_at DATETIME, subject_id TEXT,++ UNIQUE(service, service_user_id)++ );++ CREATE TABLE game_players (++ user_id INTEGER PRIMARY KEY, display_name TEXT, x REAL DEFAULT 4096, y REAL DEFAULT 4096,++ mining_xp INTEGER DEFAULT 0, woodcut_xp INTEGER DEFAULT 0, hp INTEGER DEFAULT 100,++ equip_pickaxe TEXT, sleeping_bag_x REAL, created_at DATETIME, last_action DATETIME++ );++ CREATE TABLE game_inventory (++ id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, item_id TEXT NOT NULL,++ quantity INTEGER DEFAULT 1, UNIQUE(user_id, item_id)++ );++ CREATE TABLE game_bank (++ id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, item_id TEXT NOT NULL,++ quantity INTEGER DEFAULT 1, UNIQUE(user_id, item_id)++ );++ CREATE TABLE game_achievements (++ user_id INTEGER NOT NULL, achievement_id TEXT NOT NULL, completed_at DATETIME,++ PRIMARY KEY (user_id, achievement_id)++ );++ CREATE TABLE game_recipes (++ id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, recipe_id TEXT NOT NULL,++ unlocked_at DATETIME, UNIQUE(user_id, recipe_id)++ );++ CREATE TABLE game_structures (++ id INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER, type TEXT NOT NULL,++ tile_x INTEGER NOT NULL, tile_y INTEGER NOT NULL++ );++ CREATE TABLE game_world_state (key TEXT PRIMARY KEY, value TEXT);++ CREATE TABLE game_daily_quest_progress (++ user_id INTEGER NOT NULL, quest_date TEXT NOT NULL, stat_key TEXT NOT NULL, value REAL,++ PRIMARY KEY (user_id, quest_date, stat_key)++ );++ CREATE TABLE canvas_tiles (++ x INTEGER NOT NULL, y INTEGER NOT NULL, color_index INTEGER NOT NULL, user_id INTEGER,++ ip_address TEXT, PRIMARY KEY (x, y)++ );++ CREATE TABLE canvas_actions (++ id INTEGER PRIMARY KEY AUTOINCREMENT, action_type TEXT NOT NULL, x INTEGER, y INTEGER,++ user_id INTEGER, ip_address TEXT++ );++ INSERT INTO users (id, username) VALUES++ (1, 'ana'), (2, 'bo'), (3, 'cy'), (4, 'di'), (5, 'ed'), (6, 'fi'), (7, 'gu');++ INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES++ (1, 'network', '101', '${S1}'),++ (2, 'network', '102', NULL),++ (4, 'network', '104', '${S4A}'),++ (5, 'network', '105', '${S5}'),++ (3, 'google', 'g-3', NULL);++ INSERT INTO game_players (user_id, display_name, mining_xp, woodcut_xp, equip_pickaxe, created_at, last_action) VALUES++ (1, 'Ana', 1200, 30, 'pick_iron', '2026-03-01 10:00:00', '2026-09-01 10:00:00'),++ (2, 'Bo', 0, 0, NULL, '2026-03-01 10:00:00', '2026-09-02 10:00:00'),++ (3, 'Cy', 5, 5, NULL, '2026-03-01 10:00:00', '2026-09-03 10:00:00'),++ (4, 'Di', 7, 0, NULL, '2026-03-01 10:00:00', '2026-09-04 10:00:00'),++ (5, 'Ed', 9, 0, NULL, '2026-03-01 10:00:00', '2026-09-05 10:00:00'),++ (6, NULL, 0, 0, NULL, '2026-09-19 17:52:12', '2026-09-19 17:52:12'),++ (7, NULL, 0, 0, NULL, '2026-03-10 09:00:00', '2026-03-10 09:00:00');++ INSERT INTO game_inventory (user_id, item_id, quantity) VALUES++ (1, 'ore_copper', 40), (1, 'pick_iron', 1), (2, 'fish_bass', 3), (3, 'loot_lint', 2),++ (4, 'gem_ruby', 1);++ INSERT INTO game_bank (user_id, item_id, quantity) VALUES (1, 'raw_stone', 900), (3, 'raw_stick', 12);++ INSERT INTO game_achievements (user_id, achievement_id, completed_at) VALUES++ (1, 'first_gather', '2026-03-10'), (1, 'level_10_any', '2026-03-11'), (5, 'first_blood', '2026-03-12');++ INSERT INTO game_recipes (user_id, recipe_id) VALUES (2, 'recipe_plank'), (3, 'recipe_iron_bar');++ INSERT INTO game_structures (owner_id, type, tile_x, tile_y) VALUES (1, 'furnace', 4, 190), (NULL, 'ruin', 5, 5);++ INSERT INTO game_world_state VALUES ('world_seed', '1510266867');++ INSERT INTO game_daily_quest_progress VALUES++ (1, '2026-03-10', 'gathered', 5), (2, '2026-03-10', 'caught', 1), (7, '2026-03-10', 'walked', 3);++ INSERT INTO canvas_tiles VALUES (0, 0, 3, 1, '203.0.113.9'), (0, 1, 4, 2, '203.0.113.9');++ INSERT INTO canvas_actions (action_type, x, y, user_id, ip_address) VALUES++ ('place', 0, 0, 1, '203.0.113.9'), ('blocked', 0, 1, 2, '203.0.113.9'), ('place', 0, 1, 2, '203.0.113.9');++ COMMIT;++ `)++ db.close()++}++++/** The Network's answers for the fixture users. */++const NETWORK: Record<number, string> = { 1: S1, 2: S2, 4: S4B, 6: S6, 7: S7 }++const network = subjectMapLookup(NETWORK)++++let dir: string++let livePath: string++let worldPath: string++++beforeEach(() => {++ dir = mkdtempSync(join(tmpdir(), 'live-legacy-'))++ livePath = join(dir, 'live-snapshot.db')++ worldPath = join(dir, 'world.db')++ makeLive(livePath)++ openSqliteStore(worldPath).close()++})++++afterEach(() => {++ rmSync(dir, { recursive: true, force: true })++})++++const table = (r: ImportReport, name: string): TableReport => {++ const t = r.tables.find((x) => x.table === name)++ if (!t) throw new Error(`no ${name} in report`)++ return t++}++++async function plan(++ opts: { network?: typeof network | null; world?: Database.Database | null } = {},++) {++ const live = new Database(livePath, { readonly: true })++ const world = opts.world === undefined ? new Database(worldPath, { readonly: true }) : opts.world++ try {++ return await planLegacyImport(live, world, {++ network: opts.network === undefined ? network : opts.network,++ subjectSource: 'test',++ dryRun: true,++ })++ } finally {++ live.close()++ if (opts.world === undefined) world?.close()++ }++}++++function dumpArchive(): unknown[] {++ const db = new Database(worldPath, { readonly: true })++ try {++ return db.prepare('SELECT * FROM legacy_live_rows ORDER BY source_table, source_key').all()++ } finally {++ db.close()++ }++}++++async function applyOnce(): Promise<number> {++ const live = new Database(livePath, { readonly: true })++ const world = new Database(worldPath)++ try {++ const p = await planLegacyImport(live, world, {++ network,++ subjectSource: 'test',++ dryRun: false,++ })++ return applyLegacyImport(world, p, 1_790_000_000_000)++ } finally {++ live.close()++ world.close()++ }++}++++describe('subject mapping', () => {++ it('uses the Network as the authority and never guesses', () => {++ const linked = new Map<number, string[]>([++ [1, [S1]],++ [4, [S4A]],++ [5, [S5]],++ [6, [S1, S2]],++ ])++ const net = new Map<number, string | null>([++ [1, S1],++ [2, S2],++ [3, null],++ [4, S4B],++ [5, null],++ [7, 'usr_not-a-ulid'],++ ])++ expect(decideSubject(1, linked, net)).toEqual({ subjectId: S1 })++ expect(decideSubject(2, linked, net)).toEqual({ subjectId: S2 })++ expect(decideSubject(3, linked, net)).toEqual({ hold: 'no-subject' })++ expect(decideSubject(4, linked, net)).toEqual({ hold: 'subject-conflict' })++ expect(decideSubject(5, linked, net)).toEqual({ hold: 'subject-unconfirmed' })++ expect(decideSubject(6, linked, net)).toEqual({ hold: 'subject-conflict' })++ expect(decideSubject(7, linked, net)).toEqual({ hold: 'no-subject' })++ // Without a Network answer only Live's single recorded subject counts.++ expect(decideSubject(1, linked, null)).toEqual({ subjectId: S1 })++ expect(decideSubject(2, linked, null)).toEqual({ hold: 'no-subject' })++ expect(decideSubject(6, linked, null)).toEqual({ hold: 'subject-conflict' })++ })++++ it('keys every imported row by the mapped subject and holds the rest', async () => {++ const { report, rows } = await plan()++ expect(report.users).toEqual({++ total: 6,++ mapped: 3,++ held: { 'no-subject': 1, 'subject-conflict': 1, 'subject-unconfirmed': 1 },++ })++ const players = rows.filter((r) => r.table === 'game_players')++ expect(players.map((r) => [r.liveUserId, r.subjectId])).toEqual([++ [1, S1],++ [2, S2],++ [7, S7],++ ])++ expect(new Set(rows.map((r) => r.liveUserId))).toEqual(new Set([1, 2, 7]))++ const heldUsers = new Set(report.held.map((h) => h.liveUserId))++ expect(heldUsers).toEqual(new Set([3, 4, 5, null]))++ expect(report.held).toContainEqual({++ table: 'game_structures',++ key: '2',++ liveUserId: null,++ reason: 'no-owner',++ })++ expect(report.held).toContainEqual({++ table: 'game_achievements',++ key: '5/first_blood',++ liveUserId: 5,++ reason: 'subject-unconfirmed',++ })++ // The payload is the whole source row, canonical JSON.++ const ana = players.find((r) => r.liveUserId === 1)++ expect(JSON.parse(ana?.payload ?? '{}')).toEqual({++ created_at: '2026-03-01 10:00:00',++ display_name: 'Ana',++ equip_pickaxe: 'pick_iron',++ hp: 100,++ last_action: '2026-09-01 10:00:00',++ mining_xp: 1200,++ sleeping_bag_x: null,++ user_id: 1,++ woodcut_xp: 30,++ x: 4096,++ y: 4096,++ })++ })++++ it('resolves through the Network identity service with the games principal', async () => {++ const platform = createMockPlatform({++ clients: {++ games: {++ secret: 's3cret',++ grants: [{ capability: 'identity.subject.resolve', audience: 'openvibe.network' }],++ },++ },++ users: [++ { id: 101, username: 'ana', subject_id: S1, legacy: [{ system: 'live', id: 1 }] },++ { id: 102, username: 'bo', subject_id: S2, legacy: [{ system: 'live', id: 2 }] },++ ],++ })++ const pc = createPlatformClient(++ { ...loadPlatformConfig({}), clientSecret: 's3cret', networkUrl: platform.origins.network },++ platform.fetch,++ )++ if (!pc) throw new Error('no platform client')++ const lookup = networkSubjectLookup(createIdentityClient(pc.client))++ const answers = await lookup([1, 2, 3])++ expect(answers).toEqual(++ new Map([++ [1, S1],++ [2, S2],++ [3, null],++ ]),++ )++ const { report } = await plan({ network: lookup })++ expect(report.users.mapped).toBe(2)++ })++++ it('without a Network source relies on Live’s own record only', async () => {++ const { report } = await plan({ network: null })++ // user 1 (S1), user 4 (S4A) and user 5 (S5) have a Live record; 2, 3 and 7 do not.++ expect(report.users).toEqual({ total: 6, mapped: 3, held: { 'no-subject': 3 } })++ })++})++++describe('placeholder players', () => {++ const base = {++ user_id: 9,++ mining_xp: 0,++ total_deaths: 0,++ battle_wins: 0,++ equip_axe: null,++ sleeping_bag_x: null,++ sprite_skin: 0,++ hp: 100,++ chat_color: '#e8e6e3',++ created_at: '2026-09-19 17:52:12',++ last_action: '2026-09-19 17:52:12',++ }++ it('excludes only rows nobody played', () => {++ expect(isPlaceholderPlayer(base, false)).toBe(true)++ expect(isPlaceholderPlayer(base, true)).toBe(false) // owns other legacy rows++ expect(isPlaceholderPlayer({ ...base, last_action: '2026-09-20 00:00:00' }, false)).toBe(false)++ expect(isPlaceholderPlayer({ ...base, mining_xp: 1 }, false)).toBe(false)++ expect(isPlaceholderPlayer({ ...base, total_deaths: 1 }, false)).toBe(false)++ expect(isPlaceholderPlayer({ ...base, battle_wins: 2 }, false)).toBe(false)++ expect(isPlaceholderPlayer({ ...base, equip_axe: 'axe_stone' }, false)).toBe(false)++ expect(isPlaceholderPlayer({ ...base, sleeping_bag_x: 12 }, false)).toBe(false)++ expect(isPlaceholderPlayer({ ...base, sprite_skin: 3 }, false)).toBe(false)++ expect(isPlaceholderPlayer({ ...base, created_at: null }, false)).toBe(false)++ })++})++++describe('reconciliation', () => {++ it('accounts for every source row: read = imported + held + excluded', async () => {++ const { report } = await plan()++ for (const t of report.tables) {++ expect(t.read, t.table).toBe(t.imported + t.held + t.excluded)++ expect(t.balanced).toBe(true)++ }++ expect(table(report, 'game_players')).toMatchObject({++ read: 7,++ imported: 3,++ written: 3,++ held: 3,++ excluded: 1,++ })++ expect(report.excludedRows).toEqual({++ "game_players: placeholder rows Live's profile lookup created for people who never played": 1,++ })++ expect(table(report, 'game_inventory')).toMatchObject({ read: 5, imported: 3, held: 2 })++ expect(table(report, 'game_bank')).toMatchObject({ read: 2, imported: 1, held: 1 })++ expect(table(report, 'game_achievements')).toMatchObject({ read: 3, imported: 2, held: 1 })++ expect(table(report, 'game_recipes')).toMatchObject({ read: 2, imported: 1, held: 1 })++ expect(table(report, 'game_structures')).toMatchObject({ read: 2, imported: 1, held: 1 })++ expect(table(report, 'game_world_state')).toMatchObject({ read: 1, excluded: 1, imported: 0 })++ expect(table(report, 'game_daily_quest_progress')).toMatchObject({ read: 3, excluded: 3 })++ expect(table(report, 'canvas_tiles')).toMatchObject({ read: 2, excluded: 2 })++ expect(table(report, 'canvas_actions')).toMatchObject({ read: 3, excluded: 3 })++ // Tables the snapshot does not have are reported, with zero rows.++ expect(table(report, 'game_fish_collection')).toMatchObject({ missing: true, read: 0 })++ expect(report.totals).toEqual({++ read: 30,++ imported: 11,++ written: 11,++ held: 9,++ excluded: 10,++ balanced: true,++ })++ expect(report.unlisted).toEqual([])++ expect(report.reconciled).toBe(true)++ const text = formatReport(report)++ expect(text).toContain('read 30 = imported 11 + held 9 + excluded 10: balanced')++ expect(text).toContain(++ 'live user 3 (no-subject): game_players[3] game_inventory[4] game_bank[2]',++ )++ expect(text).toContain('RECONCILED')++ })++++ it('refuses to apply when a legacy table has no recorded decision', async () => {++ const db = new Database(livePath)++ db.exec(++ "CREATE TABLE game_pets (user_id INTEGER, pet TEXT); INSERT INTO game_pets VALUES (1, 'rat');",++ )++ db.close()++ const p = await plan()++ expect(p.report.unlisted).toEqual([{ table: 'game_pets', rows: 1 }])++ expect(p.report.reconciled).toBe(false)++ expect(formatReport(p.report)).toContain('NOT RECONCILED')++ const world = new Database(worldPath)++ try {++ expect(() => applyLegacyImport(world, p, 1)).toThrow(/does not reconcile/)++ } finally {++ world.close()++ }++ expect(dumpArchive()).toEqual([])++ })++})++++describe('apply and idempotency', () => {++ it('writes the plan in one transaction and a re-run changes nothing', async () => {++ expect(await applyOnce()).toBe(11)++ const first = dumpArchive()++ expect(first).toHaveLength(11)++ const world = new Database(worldPath, { readonly: true })++ try {++ expect(Object.fromEntries(archivedCounts(world))).toEqual({++ game_achievements: 2,++ game_bank: 1,++ game_inventory: 3,++ game_players: 3,++ game_recipes: 1,++ game_structures: 1,++ })++ } finally {++ world.close()++ }++++ // Re-run: same counts, nothing written, archive byte-identical.++ const again = await plan()++ expect(again.rows).toEqual([])++ expect(again.report.totals).toEqual({++ read: 30,++ imported: 11,++ written: 0,++ held: 9,++ excluded: 10,++ balanced: true,++ })++ expect(await applyOnce()).toBe(0)++ expect(dumpArchive()).toEqual(first)++ })++++ it('picks up rows whose subject appears later, without touching earlier ones', async () => {++ await applyOnce()++ const first = dumpArchive()++ const later = subjectMapLookup({ ...NETWORK, 3: 'usr_01KKBC57W8GDSNVJJDQ15PQCD7' })++ const p = await plan({ network: later })++ expect(p.report.totals.written).toBe(4) // user 3: player, inventory, bank and recipe rows++ expect(p.rows.every((r) => r.liveUserId === 3)).toBe(true)++ const world = new Database(worldPath)++ try {++ expect(applyLegacyImport(world, p, 2)).toBe(4)++ } finally {++ world.close()++ }++ const after = dumpArchive()++ expect(after).toHaveLength(15)++ for (const row of first) expect(after).toContainEqual(row)++ })++++ it('holds a row that changed since it was imported and never overwrites it', async () => {++ await applyOnce()++ const before = dumpArchive()++ const db = new Database(livePath)++ db.exec("UPDATE game_inventory SET quantity = 41 WHERE user_id = 1 AND item_id = 'ore_copper'")++ db.close()++ const p = await plan()++ expect(p.report.held).toContainEqual({++ table: 'game_inventory',++ key: '1',++ liveUserId: 1,++ reason: 'differs-from-earlier-import',++ })++ expect(table(p.report, 'game_inventory')).toMatchObject({ read: 5, imported: 2, held: 3 })++ expect(p.report.reconciled).toBe(true)++ const world = new Database(worldPath)++ try {++ expect(applyLegacyImport(world, p, 3)).toBe(0)++ } finally {++ world.close()++ }++ expect(dumpArchive()).toEqual(before)++ })++++ it('plans against a world that has no archive table yet (schema 12)', async () => {++ const legacyWorld = new Database(':memory:')++ const p = await plan({ world: legacyWorld })++ expect(p.report.totals.written).toBe(11)++ legacyWorld.close()++ })++})++++describe('safety rails', () => {++ it('refuses --apply without --backup, a reused backup path and a missing subject source', () => {++ const base = { livePath: 'x.db', apply: true, backupExists: false }++ expect(refusalFor({ ...base, subjects: 'network' })).toBe('--apply requires --backup <path>')++ expect(refusalFor({ ...base, backupPath: 'b.db' })).toMatch(/requires --subjects/)++ expect(++ refusalFor({ ...base, backupPath: 'b.db', subjects: 'network', backupExists: true }),++ ).toMatch(/already exists/)++ expect(refusalFor({ ...base, backupPath: 'b.db', subjects: 'network' })).toBeNull()++ expect(refusalFor({ apply: false, backupExists: false })).toMatch(/--live/)++ expect(refusalFor({ livePath: 'x.db', apply: false, backupExists: false })).toBeNull()++ })++++ it('the script exits 2 on --apply without --backup and writes nothing', () => {++ const script = join(++ dirname(fileURLToPath(import.meta.url)),++ '../../scripts/importLiveLegacy.ts',++ )++ const before = readFileSync(worldPath)++ const run = spawnSync(++ process.execPath,++ [++ '--import',++ 'tsx',++ script,++ '--live',++ livePath,++ '--world',++ worldPath,++ '--apply',++ '--subjects',++ 'network',++ ],++ { encoding: 'utf8', env: { ...process.env, OV_OAUTH_CLIENT_SECRET: '' } },++ )++ expect(run.status).toBe(2)++ expect(run.stderr).toContain('refused: --apply requires --backup <path>')++ expect(readFileSync(worldPath).equals(before)).toBe(true)++ })++++ it('the script dry-runs with an exported subject map and prints the reconciliation', () => {++ const script = join(++ dirname(fileURLToPath(import.meta.url)),++ '../../scripts/importLiveLegacy.ts',++ )++ const mapPath = join(dir, 'subjects.json')++ writeFileSync(mapPath, JSON.stringify(NETWORK))++ const run = spawnSync(++ process.execPath,++ ['--import', 'tsx', script, '--live', livePath, '--world', worldPath, '--subjects', mapPath],++ { encoding: 'utf8' },++ )++ expect(run.stderr).toBe('')++ expect(run.status).toBe(0)++ expect(run.stdout).toContain('DRY RUN')++ expect(run.stdout).toContain('read 30 = imported 11 + held 9 + excluded 10: balanced')++ expect(dumpArchive()).toEqual([])++ })++++ it('backs up world.db and verifies the copy with integrity_check', async () => {++ const world = new Database(worldPath)++ const backup = join(dir, 'world-before.db')++ try {++ await backupAndVerify(world, backup, (p) => new Database(p, { readonly: true }))++ expect(existsSync(backup)).toBe(true)++ // A copy that does not verify is refused.++ const bad = join(dir, 'world-bad.db')++ await expect(++ backupAndVerify(world, bad, (p) => {++ const buf = readFileSync(p)++ buf.fill(0x5a, 100, buf.length) // garble every page after the header++ writeFileSync(p, buf)++ return new Database(p, { readonly: true })++ }),++ ).rejects.toThrow()++ } finally {++ world.close()++ }++ })++++ it('refuses to read the production Live database', () => {++ const prod = join(dir, 'opt-live')++ mkdirSync(prod)++ const file = join(prod, 'live.db')++ writeFileSync(file, '')++ expect(() => assertSnapshotPath(file, `${prod}/`)).toThrow(/production database/)++ expect(() => assertSnapshotPath(livePath, `${prod}/`)).not.toThrow()++ })++})nnew file mode 100644@@@ -0,0 +1,746 @@++/**++ * Import of OpenVibe.Live's legacy game data (roadmap Wave 12 migration gate,++ * docs/legacy-import.md).++ *++ * Live ran HoboQuest (a 2D tile RPG: `game_*` tables) and a pixel canvas++ * (`canvas_*`) until both were retired. Scraplandia has no equivalent for++ * HoboQuest's items, recipes, achievements or skills, so the per-player rows++ * are kept verbatim in `legacy_live_rows`, keyed by the owner's canonical++ * Network subject; world-level, expired and derived rows and the canvas are++ * excluded with a recorded reason. Nothing here touches players or the world.++ *++ * Every source row ends in exactly one bucket — imported, held or excluded —++ * and the report checks `read = imported + held + excluded` per table.++ * Rows whose Live user has no Network subject (or an ambiguous one) are held++ * and listed, never guessed. The import is idempotent: rows are keyed by the++ * source primary key and never overwritten.++ */++import { realpathSync } from 'node:fs'++import type Database from 'better-sqlite3'++import type { IdentityClient } from 'openvibe-sdk/identity'++++/** A Live user's canonical subject: `usr_<ULID>`. Guest subjects never own Live rows. */++export const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/++++/** The production Live data directory; the importer reads snapshots only. */++export const LIVE_PRODUCTION_DIR = '/opt/openvibe.live/'++++export interface LegacyTableSpec {++ table: string++ decision: 'import' | 'exclude'++ /** Column naming the owning Live user (for exclude tables: evidence of play only). */++ userColumn?: string++ /** Primary-key columns of the source row: the idempotency key (import tables only). */++ keyColumns?: string[]++ /** Why the table is excluded, or what the archived rows are. */++ reason: string++ /** Rows of an import table that are excluded one by one, and why. */++ excludeRows?: {++ reason: string++ test: (row: Row, ownsOtherRows: boolean) => boolean++ }++}++++type Row = Record<string, unknown>++++/**++ * A `game_players` row nobody played: Live's profile lookup++ * (`game.getPlayer`, still called by Live's chat profile routes) inserts a++ * default row for every profile it shows. Such a row was never acted on++ * (`last_action = created_at`), has no XP, counters, equipment, sleeping bag++ * or cosmetics, and its user owns no row in any other legacy game table.++ */++export function isPlaceholderPlayer(row: Row, ownsOtherRows: boolean): boolean {++ if (ownsOtherRows) return false++ if (row.created_at == null || row.last_action !== row.created_at) return false++ for (const [col, v] of Object.entries(row)) {++ const zero = v === null || v === 0++ if (/_xp$|^total_|^battle_(wins|losses)$|^structures_built$|^resources_gathered$/.test(col)) {++ if (!zero) return false++ } else if (/^equip_|^sleeping_bag_|^name_effect$|^particle_effect$/.test(col)) {++ if (v !== null) return false++ } else if (col === 'sprite_skin' && !zero) {++ return false++ }++ }++ return true++}++++/** Every legacy game and canvas table in Live, with its decision (docs/legacy-import.md). */++export const LEGACY_TABLES: readonly LegacyTableSpec[] = [++ {++ table: 'game_players',++ decision: 'import',++ userColumn: 'user_id',++ keyColumns: ['user_id'],++ reason: 'HoboQuest profile: skill XP, combat stats, equipment, cosmetics, lifetime counters',++ excludeRows: {++ reason: "placeholder rows Live's profile lookup created for people who never played",++ test: isPlaceholderPlayer,++ },++ },++ {++ table: 'game_inventory',++ decision: 'import',++ userColumn: 'user_id',++ keyColumns: ['id'],++ reason: 'HoboQuest item stacks carried by the player',++ },++ {++ table: 'game_bank',++ decision: 'import',++ userColumn: 'user_id',++ keyColumns: ['id'],++ reason: 'HoboQuest item stacks in the bank',++ },++ {++ table: 'game_achievements',++ decision: 'import',++ userColumn: 'user_id',++ keyColumns: ['user_id', 'achievement_id'],++ reason: 'HoboQuest achievements completed',++ },++ {++ table: 'game_recipes',++ decision: 'import',++ userColumn: 'user_id',++ keyColumns: ['id'],++ reason: 'HoboQuest recipes unlocked',++ },++ {++ table: 'game_fish_collection',++ decision: 'import',++ userColumn: 'user_id',++ keyColumns: ['user_id', 'fish_id'],++ reason: 'HoboQuest fish album',++ },++ {++ table: 'game_battle_stats',++ decision: 'import',++ userColumn: 'user_id',++ keyColumns: ['user_id'],++ reason: 'HoboQuest PvP record',++ },++ {++ table: 'game_structures',++ decision: 'import',++ userColumn: 'owner_id',++ keyColumns: ['id'],++ reason: 'HoboQuest buildings owned (a record of ownership; the tile world is gone)',++ },++ {++ table: 'game_farm_plots',++ decision: 'import',++ userColumn: 'user_id',++ keyColumns: ['id'],++ reason: 'HoboQuest farm plots',++ },++ {++ table: 'game_world_state',++ decision: 'exclude',++ reason:++ "the seed of HoboQuest's procedural 2D world; Scraplandia's world is its own definition",++ },++ {++ table: 'game_effects',++ decision: 'exclude',++ userColumn: 'user_id',++ reason: 'timed buffs; every one has expired with the game',++ },++ {++ table: 'game_dungeon_runs',++ decision: 'exclude',++ userColumn: 'leader_id',++ reason: 'in-progress dungeon sessions; there is no dungeon to resume',++ },++ {++ table: 'game_leaderboard',++ decision: 'exclude',++ userColumn: 'user_id',++ reason: 'a cache derived from game_players',++ },++ {++ table: 'game_daily_quest_progress',++ decision: 'exclude',++ userColumn: 'user_id',++ reason: 'progress on daily quests of past days (they reset daily and can no longer be claimed)',++ },++ {++ table: 'game_daily_quest_claims',++ decision: 'exclude',++ userColumn: 'user_id',++ reason: 'claims of past daily quests',++ },++ {++ table: 'canvas_tiles',++ decision: 'exclude',++ reason: "Live's pixel canvas is not a Games feature; its rows carry IP addresses",++ },++ {++ table: 'canvas_actions',++ decision: 'exclude',++ reason: 'the pixel canvas placement and moderation log (with IP addresses)',++ },++ { table: 'canvas_settings', decision: 'exclude', reason: 'pixel canvas configuration' },++ { table: 'canvas_snapshots', decision: 'exclude', reason: 'pixel canvas snapshots' },++ { table: 'canvas_bans', decision: 'exclude', reason: 'pixel canvas moderation' },++ { table: 'canvas_region_locks', decision: 'exclude', reason: 'pixel canvas moderation' },++ { table: 'canvas_user_overrides', decision: 'exclude', reason: 'pixel canvas rate limits' },++]++++/** Tables that belong to the legacy game or canvas: every one must be listed above. */++const LEGACY_TABLE_PATTERN = /^(game|canvas)_/++++export type HoldReason =++ | 'no-subject'++ | 'subject-unconfirmed'++ | 'subject-conflict'++ | 'no-owner'++ | 'differs-from-earlier-import'++++export const HOLD_REASONS: Record<HoldReason, string> = {++ 'no-subject': 'the Live user has no Network subject',++ 'subject-unconfirmed': 'Live records a subject but the Network does not confirm it',++ 'subject-conflict': 'Live and the Network name different subjects',++ 'no-owner': 'the row names no Live user',++ 'differs-from-earlier-import': 'already imported with different content (never overwritten)',++}++++export interface HeldRow {++ table: string++ key: string++ liveUserId: number | null++ reason: HoldReason++}++++export interface TableReport {++ table: string++ decision: 'import' | 'exclude'++ /** The table does not exist in the snapshot (counts are 0). */++ missing: boolean++ read: number++ /** Rows in world.db after the run (new plus already imported). */++ imported: number++ /** Rows this run writes (0 on a re-run). */++ written: number++ held: number++ excluded: number++ balanced: boolean++}++++export interface ImportReport {++ dryRun: boolean++ subjectSource: string++ tables: TableReport[]++ totals: Omit<TableReport, 'table' | 'decision' | 'missing'>++ /** Live users owning importable rows, and how their subjects resolved. */++ users: { total: number; mapped: number; held: Record<string, number> }++ held: HeldRow[]++ /** Rows of import tables excluded one by one, per `table: reason`. */++ excludedRows: Record<string, number>++ /** Legacy-looking tables in the snapshot that no decision covers. */++ unlisted: { table: string; rows: number }[]++ /** Every table balances and nothing is unlisted. */++ reconciled: boolean++}++++/** Resolves Live user ids to subjects at the Network; null = the Network does not know the user. */++export type NetworkSubjectLookup = (liveUserIds: number[]) => Promise<Map<number, string | null>>++++/** Lookup through the Network identity service: `(system 'live', type 'user', id)` -> subject. */++export function networkSubjectLookup(++ identity: Pick<IdentityClient, 'resolveBatch'>,++): NetworkSubjectLookup {++ return async (ids) => {++ const out = new Map<number, string | null>()++ if (!ids.length) return out++ const results = await identity.resolveBatch({ system: 'live', type: 'user', ids })++ for (const id of ids) out.set(id, results[String(id)]?.subject?.id ?? null)++ return out++ }++}++++/** Lookup from a map exported from the Network: `{ "<live user id>": "usr_…" }`. */++export function subjectMapLookup(map: unknown): NetworkSubjectLookup {++ if (!map || typeof map !== 'object' || Array.isArray(map)) {++ throw new Error('expected a JSON object { "<live user id>": "usr_…" }')++ }++ const entries = map as Record<string, unknown>++ return async (ids) =>++ new Map(++ ids.map((id) => {++ const v = entries[String(id)]++ return [id, typeof v === 'string' ? v : null]++ }),++ )++}++++/** A row about to be written. */++export interface PlannedRow {++ table: string++ key: string++ liveUserId: number++ subjectId: string++ payload: string++}++++export interface ImportPlan {++ report: ImportReport++ rows: PlannedRow[]++}++++const quoteIdent = (name: string): string => `"${name.replace(/"/g, '""')}"`++++function tableExists(db: Database.Database, table: string): boolean {++ return (++ db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !==++ undefined++ )++}++++/** Deterministic JSON of a source row (sorted keys) so re-runs compare byte for byte. */++export function canonicalRow(row: Row): string {++ const out: Row = {}++ for (const key of Object.keys(row).sort()) {++ const v = row[key]++ if (typeof v === 'bigint') out[key] = Number(v)++ else if (v === null || typeof v === 'number' || typeof v === 'string') out[key] = v++ else throw new Error(`unsupported value in column ${key}: ${typeof v}`)++ }++ return JSON.stringify(out)++}++++export function rowKey(row: Row, columns: readonly string[]): string {++ return columns.map((c) => String(row[c])).join('/')++}++++/**++ * Refuses the production Live database: the importer reads a `.backup`++ * snapshot, never the file Live has open.++ */++export function assertSnapshotPath(path: string, productionDir = LIVE_PRODUCTION_DIR): void {++ const real = realpathSync(path)++ if (real.startsWith(productionDir)) {++ throw new Error(++ `${path} is Live's production database; take a snapshot first (sqlite3 <db> ".backup <copy>") and pass the copy`,++ )++ }++}++++export interface RunOptions {++ livePath?: string++ apply: boolean++ backupPath?: string++ subjects?: string++ /** Whether `backupPath` already exists. */++ backupExists: boolean++}++++/**++ * Why a run must not start, or null. `--apply` needs a fresh `--backup`++ * path (a stale file is not a backup of now) and a Network subject source.++ */++export function refusalFor(opts: RunOptions): string | null {++ if (!opts.livePath) return '--live <snapshot of live.db> is required'++ if (opts.apply && !opts.backupPath) return '--apply requires --backup <path>'++ if (opts.apply && !opts.subjects) {++ return '--apply requires --subjects network (or an exported subject map)'++ }++ if (opts.backupPath && opts.backupExists) {++ return `--backup ${opts.backupPath} already exists; choose a new path`++ }++ return null++}++++/** Subjects Live itself recorded for its users (linked_accounts, service 'network'). */++export function liveLinkedSubjects(live: Database.Database): Map<number, string[]> {++ const out = new Map<number, string[]>()++ if (!tableExists(live, 'linked_accounts')) return out++ const cols = live.prepare('PRAGMA table_info(linked_accounts)').all() as { name: string }[]++ if (!cols.some((c) => c.name === 'subject_id')) return out++ const rows = live++ .prepare(++ "SELECT user_id, subject_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NOT NULL",++ )++ .all() as { user_id: number; subject_id: string }[]++ for (const r of rows) {++ if (!USER_SUBJECT.test(r.subject_id)) continue++ const list = out.get(r.user_id) ?? []++ if (!list.includes(r.subject_id)) list.push(r.subject_id)++ out.set(r.user_id, list)++ }++ return out++}++++/**++ * The subject a Live user's rows go to, or why they are held. With a Network++ * answer the Network is the authority and Live's own record must agree; with++ * none (an informational dry run) Live's single recorded subject is used.++ */++export function decideSubject(++ liveUserId: number,++ linked: ReadonlyMap<number, string[]>,++ network: ReadonlyMap<number, string | null> | null,++): { subjectId: string } | { hold: HoldReason } {++ const own = linked.get(liveUserId) ?? []++ if (own.length > 1) return { hold: 'subject-conflict' }++ if (network) {++ const answer = network.get(liveUserId) ?? null++ const n = answer !== null && USER_SUBJECT.test(answer) ? answer : null++ if (!n) return { hold: own.length ? 'subject-unconfirmed' : 'no-subject' }++ if (own.length && own[0] !== n) return { hold: 'subject-conflict' }++ return { subjectId: n }++ }++ return own[0] ? { subjectId: own[0] } : { hold: 'no-subject' }++}++++/** Rows already in world.db's archive, keyed `table\u0000key` -> payload. */++function existingArchive(world: Database.Database | null): Map<string, string> {++ const out = new Map<string, string>()++ if (!world || !tableExists(world, 'legacy_live_rows')) return out++ const rows = world++ .prepare('SELECT source_table, source_key, payload FROM legacy_live_rows')++ .all() as { source_table: string; source_key: string; payload: string }[]++ for (const r of rows) out.set(`${r.source_table}\u0000${r.source_key}`, r.payload)++ return out++}++++export interface PlanOptions {++ /** The Network's answer for each Live user id, or null to rely on Live's own record only. */++ network: NetworkSubjectLookup | null++ subjectSource: string++ dryRun: boolean++}++++/**++ * Reads the Live snapshot and decides every row. `world` may be null (a++ * fresh world with nothing imported yet) and is only read here.++ */++export async function planLegacyImport(++ live: Database.Database,++ world: Database.Database | null,++ opts: PlanOptions,++): Promise<ImportPlan> {++ const sourceRows = new Map<string, Row[]>()++ const excludedCounts = new Map<string, number>()++ const missing = new Set<string>()++ /** Live user id -> the legacy tables that hold a row of theirs. */++ const activity = new Map<number, Set<string>>()++ const owner = (row: Row, spec: LegacyTableSpec): number | null => {++ const uid = spec.userColumn ? row[spec.userColumn] : null++ return typeof uid === 'number' && Number.isInteger(uid) ? uid : null++ }++++ for (const spec of LEGACY_TABLES) {++ if (!tableExists(live, spec.table)) {++ missing.add(spec.table)++ continue++ }++ if (spec.userColumn) {++ const col = quoteIdent(spec.userColumn)++ const owners = live++ .prepare(++ `SELECT DISTINCT ${col} AS uid FROM ${quoteIdent(spec.table)} WHERE ${col} IS NOT NULL`,++ )++ .all() as { uid: unknown }[]++ for (const { uid } of owners) {++ if (typeof uid !== 'number') continue++ activity.set(uid, (activity.get(uid) ?? new Set()).add(spec.table))++ }++ }++ if (spec.decision === 'exclude') {++ const n = live.prepare(`SELECT count(*) AS n FROM ${quoteIdent(spec.table)}`).get() as {++ n: number++ }++ excludedCounts.set(spec.table, n.n)++ continue++ }++ const order = (spec.keyColumns ?? []).map(quoteIdent).join(', ')++ const rows = live++ .prepare(`SELECT * FROM ${quoteIdent(spec.table)} ORDER BY ${order}`)++ .all() as Row[]++ sourceRows.set(spec.table, rows)++ }++++ // Row-level exclusions, then the users whose rows are left to import.++ const excludedRow = new Set<Row>()++ const excludedRows: Record<string, number> = {}++ const userIds = new Set<number>()++ for (const spec of LEGACY_TABLES) {++ for (const row of sourceRows.get(spec.table) ?? []) {++ const uid = owner(row, spec)++ const tables = uid === null ? undefined : activity.get(uid)++ const ownsOtherRows = [...(tables ?? [])].some((t) => t !== spec.table)++ if (spec.excludeRows?.test(row, ownsOtherRows)) {++ excludedRow.add(row)++ const k = `${spec.table}: ${spec.excludeRows.reason}`++ excludedRows[k] = (excludedRows[k] ?? 0) + 1++ continue++ }++ if (uid !== null) userIds.add(uid)++ }++ }++++ const listed = new Set(LEGACY_TABLES.map((t) => t.table))++ const unlisted = (++ live.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() as {++ name: string++ }[]++ )++ .map((r) => r.name)++ .filter((name) => LEGACY_TABLE_PATTERN.test(name) && !listed.has(name))++ .map((table) => ({++ table,++ rows: (live.prepare(`SELECT count(*) AS n FROM ${quoteIdent(table)}`).get() as { n: number })++ .n,++ }))++++ const ids = [...userIds].sort((a, b) => a - b)++ const linked = liveLinkedSubjects(live)++ const network = opts.network ? await opts.network(ids) : null++ const verdicts = new Map(ids.map((id) => [id, decideSubject(id, linked, network)]))++ const usersHeld: Record<string, number> = {}++ let mapped = 0++ for (const v of verdicts.values()) {++ if ('subjectId' in v) mapped++++ else usersHeld[v.hold] = (usersHeld[v.hold] ?? 0) + 1++ }++++ const archive = existingArchive(world)++ const held: HeldRow[] = []++ const planned: PlannedRow[] = []++ const tables: TableReport[] = []++++ for (const spec of LEGACY_TABLES) {++ const t: TableReport = {++ table: spec.table,++ decision: spec.decision,++ missing: missing.has(spec.table),++ read: 0,++ imported: 0,++ written: 0,++ held: 0,++ excluded: 0,++ balanced: true,++ }++ if (spec.decision === 'exclude') {++ t.read = excludedCounts.get(spec.table) ?? 0++ t.excluded = t.read++ } else {++ for (const row of sourceRows.get(spec.table) ?? []) {++ t.read++++ if (excludedRow.has(row)) {++ t.excluded++++ continue++ }++ const key = rowKey(row, spec.keyColumns ?? [])++ const liveUserId = owner(row, spec)++ const verdict = liveUserId === null ? null : verdicts.get(liveUserId)++ if (!verdict || !('subjectId' in verdict)) {++ held.push({++ table: spec.table,++ key,++ liveUserId,++ reason: verdict ? verdict.hold : 'no-owner',++ })++ t.held++++ continue++ }++ const payload = canonicalRow(row)++ const earlier = archive.get(`${spec.table}\u0000${key}`)++ if (earlier !== undefined && earlier !== payload) {++ held.push({ table: spec.table, key, liveUserId, reason: 'differs-from-earlier-import' })++ t.held++++ continue++ }++ t.imported++++ if (earlier === undefined) {++ t.written++++ planned.push({++ table: spec.table,++ key,++ liveUserId: liveUserId as number,++ subjectId: verdict.subjectId,++ payload,++ })++ }++ }++ }++ t.balanced = t.read === t.imported + t.held + t.excluded++ tables.push(t)++ }++++ const totals = tables.reduce(++ (acc, t) => ({++ read: acc.read + t.read,++ imported: acc.imported + t.imported,++ written: acc.written + t.written,++ held: acc.held + t.held,++ excluded: acc.excluded + t.excluded,++ balanced: acc.balanced && t.balanced,++ }),++ { read: 0, imported: 0, written: 0, held: 0, excluded: 0, balanced: true },++ )++ totals.balanced =++ totals.balanced && totals.read === totals.imported + totals.held + totals.excluded++++ return {++ rows: planned,++ report: {++ dryRun: opts.dryRun,++ subjectSource: opts.subjectSource,++ tables,++ totals,++ users: { total: ids.length, mapped, held: usersHeld },++ held,++ excludedRows,++ unlisted,++ reconciled: totals.balanced && unlisted.length === 0,++ },++ }++}++++/**++ * Writes the planned rows in one transaction. Existing keys are never++ * touched (ON CONFLICT DO NOTHING); the write count must equal the plan or++ * the transaction rolls back.++ */++export function applyLegacyImport(world: Database.Database, plan: ImportPlan, now: number): number {++ if (!plan.report.reconciled) throw new Error('refusing to apply: the plan does not reconcile')++ const insert = world.prepare(`++ INSERT INTO legacy_live_rows (source_table, source_key, live_user_id, subject_id, payload, imported_at)++ VALUES (?, ?, ?, ?, ?, ?)++ ON CONFLICT (source_table, source_key) DO NOTHING++ `)++ return world.transaction(() => {++ let written = 0++ for (const r of plan.rows) {++ written += insert.run(r.table, r.key, r.liveUserId, r.subjectId, r.payload, now).changes++ }++ if (written !== plan.rows.length) {++ throw new Error(`wrote ${written} rows, planned ${plan.rows.length}: rolled back`)++ }++ return written++ })()++}++++/** Archived row counts per source table (post-apply verification). */++export function archivedCounts(world: Database.Database): Map<string, number> {++ const out = new Map<string, number>()++ if (!tableExists(world, 'legacy_live_rows')) return out++ const rows = world++ .prepare('SELECT source_table, count(*) AS n FROM legacy_live_rows GROUP BY source_table')++ .all() as { source_table: string; n: number }[]++ for (const r of rows) out.set(r.source_table, r.n)++ return out++}++++/**++ * Snapshot of world.db to `path` taken before anything is written, then++ * verified: PRAGMA integrity_check must answer `ok` and the snapshot must++ * hold the same players and archive rows as the source.++ */++export async function backupAndVerify(++ world: Database.Database,++ path: string,++ open: (path: string) => Database.Database,++): Promise<void> {++ await world.backup(path)++ const copy = open(path)++ try {++ const check = copy.pragma('integrity_check') as { integrity_check: string }[]++ if (check.length !== 1 || check[0]?.integrity_check !== 'ok') {++ throw new Error(`backup ${path} failed integrity_check: ${JSON.stringify(check)}`)++ }++ const count = (db: Database.Database, table: string): number =>++ tableExists(db, table)++ ? (db.prepare(`SELECT count(*) AS n FROM ${quoteIdent(table)}`).get() as { n: number }).n++ : 0++ for (const table of ['players', 'world_entities', 'legacy_live_rows']) {++ if (count(copy, table) !== count(world, table)) {++ throw new Error(`backup ${path} does not match world.db (${table} row count)`)++ }++ }++ } finally {++ copy.close()++ }++}++++/** The reconciliation report as text. */++export function formatReport(report: ImportReport): string {++ const lines: string[] = []++ const mode = report.dryRun ? 'DRY RUN (nothing written)' : 'APPLIED'++ lines.push(`Live legacy import: ${mode}; subjects from ${report.subjectSource}`)++ lines.push('')++ const head = ['table', 'decision', 'read', 'imported', 'written', 'held', 'excluded', 'check']++ const body = report.tables.map((t) => [++ t.table + (t.missing ? ' (absent)' : ''),++ t.decision,++ String(t.read),++ String(t.imported),++ String(t.written),++ String(t.held),++ String(t.excluded),++ t.balanced ? 'ok' : 'MISMATCH',++ ])++ const tot = report.totals++ body.push([++ 'TOTAL',++ '',++ String(tot.read),++ String(tot.imported),++ String(tot.written),++ String(tot.held),++ String(tot.excluded),++ tot.balanced ? 'ok' : 'MISMATCH',++ ])++ const widths = head.map((h, i) => Math.max(h.length, ...body.map((r) => (r[i] ?? '').length)))++ const fmt = (r: string[]): string =>++ r.map((c, i) => (i < 2 ? c.padEnd(widths[i] ?? 0) : c.padStart(widths[i] ?? 0))).join(' ')++ lines.push(fmt(head))++ for (const r of body) lines.push(fmt(r))++ lines.push('')++ lines.push(++ `read ${tot.read} = imported ${tot.imported} + held ${tot.held} + excluded ${tot.excluded}: ${++ tot.balanced ? 'balanced' : 'NOT BALANCED'++ }`,++ )++ const heldUsers = Object.entries(report.users.held)++ .map(([k, v]) => `${v} ${k}`)++ .join(', ')++ lines.push(++ `Live users owning importable rows: ${report.users.total}; mapped to a subject: ${report.users.mapped}; held: ${heldUsers || 'none'}`,++ )++ for (const [what, n] of Object.entries(report.excludedRows)) {++ lines.push(`Excluded rows of import tables: ${what}: ${n}`)++ }++ if (report.unlisted.length) {++ lines.push('')++ lines.push('UNLISTED legacy tables (no decision recorded; apply is refused):')++ for (const u of report.unlisted) lines.push(` ${u.table}: ${u.rows} rows`)++ }++ if (report.held.length) {++ lines.push('')++ lines.push('Held rows (by Live user; table[source keys]):')++ const byUser = new Map<string, HeldRow[]>()++ for (const h of report.held) {++ const k = `${h.liveUserId ?? 'none'}\u0000${h.reason}`++ byUser.set(k, [...(byUser.get(k) ?? []), h])++ }++ const sorted = [...byUser.entries()].sort(++ ([a], [b]) => Number(a.split('\u0000')[0]) - Number(b.split('\u0000')[0]),++ )++ for (const [k, rows] of sorted) {++ const [uid, reason] = k.split('\u0000')++ const perTable = new Map<string, string[]>()++ for (const r of rows) perTable.set(r.table, [...(perTable.get(r.table) ?? []), r.key])++ const detail = [...perTable.entries()].map(([t, keys]) => `${t}[${keys.join(',')}]`).join(' ')++ lines.push(` live user ${uid} (${reason}): ${detail}`)++ }++ const reasons = [...new Set(report.held.map((h) => h.reason))]++ for (const r of reasons) lines.push(` ${r}: ${HOLD_REASONS[r]}`)++ }++ lines.push('')++ lines.push(report.reconciled ? 'RECONCILED' : 'NOT RECONCILED')++ return lines.join('\n')++}nnew file mode 100644@@@ -0,0 +1,379 @@++# Live legacy game data: import, map or exclude++++Roadmap Wave 12 migration gate: OpenVibe.Live's legacy HoboQuest and canvas++data is either imported into OpenVibe.Games or excluded with a recorded++reason, and every source row is reconciled.++++**Status (2026-09-23):** decisions recorded, importer and tests in place,++dry run done on copies of both production databases (below).++**Not applied to production.** The owner runs the apply after review++([host commands](#production-run)).++++- Importer: [`apps/server/scripts/importLiveLegacy.ts`](../apps/server/scripts/importLiveLegacy.ts)++ (CLI) and [`apps/server/src/platform/liveLegacyImport.ts`](../apps/server/src/platform/liveLegacyImport.ts)++ (decisions, mapping, reconciliation).++- Tests: [`apps/server/src/platform/liveLegacyImport.test.ts`](../apps/server/src/platform/liveLegacyImport.test.ts).++- Destination: `legacy_live_rows` in `world.db` (schema 13).++++## What the legacy data is++++Until it was retired, Live ran two things that Games is now supposed to own:++++- **HoboQuest**: a 2D, tile-based RPG with a procedural world (its seed++ is in `game_world_state`). It had eight skills (mining, fishing, woodcut,++ farming, combat, crafting, agility, smithing) with an uncapped curve++ (`level = floor(sqrt(xp / 25)) + 1`). Players hold 253 distinct item ids++ (ores, fish, loot, gems, tools, rods, hats, cosmetic effects, joke items)++ and 128 unlocked recipe ids. It also had achievements, a bank, dungeons,++ daily quests and tile buildings. The game was removed from Live (`server/index.js`: "Game &++ Canvas — migrated to openvibe.games"), but the tables stayed and one path++ still writes to them. [See below](#live-still-creates-game_players-rows).++- **Canvas**: an r/place-style 512×512 pixel board with placement++ cooldowns and moderation. Production data covers one day (2026-03-15), three++ accounts, 429 placements and 417 blocked attempts. Live's `/canvas` still++ 301-redirects to `https://openvibe.games/canvas`, which serves the Games++ portal: there is no canvas in Games.++++Scraplandia (Games) is a different game: a 3D physics sandbox with six++skills (woodcutting, mining, scavenging, farming, crafting, construction,++capped at 50 with `xpToNextLevel = 60 · level^1.45`, 347,327 XP to reach 50),++59 item definitions, blueprint unlocks by recipe id, per-account character++slots, and no achievement, fishing, bank, dungeon or daily-quest system.++++Measured against the production copy:++++- 1 of the 253 distinct HoboQuest item ids in inventories and banks exists++ in Scraplandia's content: `fertilizer`, a name collision (a HoboQuest++ farm consumable versus a Scraplandia crop input). It is not the same item.++- 0 of the 128 HoboQuest recipe ids exist as a Scraplandia recipe or item.++- 22 of the 70 `game_players` rows have any XP; 13 have XP in one of the four++ skills whose names also exist in Scraplandia (mining, woodcut, farming,++ crafting). The largest is 840,227 mining XP (HoboQuest level 184): copied++ 1:1 it would be 2.4× the XP needed to max Scraplandia mining.++++## Decisions++++Three outcomes are possible for a table:++++- **import**: the row is copied, whole and unchanged, into++ `legacy_live_rows` under its owner's Network subject;++- **map**: the row is translated into Scraplandia's gameplay model++ (characters, inventories, skills, unlocks);++- **exclude**: the row is not copied, and the reason is recorded here and in++ `LEGACY_TABLES`.++++**No table is mapped.** Every per-player HoboQuest table is imported as an++archive, and nothing is written into Scraplandia's gameplay state:++++1. Items and recipes have no counterpart (above). A map would drop all but a++ name collision.++2. Skill XP comes from a different game with a different, uncapped curve.++ Scraplandia levels unlock recipes and interactions. A raw copy would max++ the top players on day one. A rescaled copy would still give Scraplandia++ progression for play that did not happen in Scraplandia. Either way this++ is a game-design decision, not a migration step.++3. A Scraplandia character is created by its player (name, slot,++ appearance) under their account key. Making characters for 11 accounts++ that may never have opened Scraplandia would put characters they did++ not create into their slots.++++The archive keeps every column of every imported row, keyed by subject. A++later feature (a "HoboQuest veteran" cosmetic, a legacy profile tab, a++tuned skill grant) can read it without going back to Live. `ADR-0006`++applies: rows are keyed by the canonical `usr_…` subject, never by Live's++integer id (the Live id is stored beside it for audit).++++| Live table | Rows | What it is | Equivalent in Games | Decision | Reason |++| --------------------------------------------------------------------------------- | ---: | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |++| `game_players` | 70 | HoboQuest profile: tile position, 8 skill XPs, HP/attack/defense/stamina, 6 equipment slots, sleeping bag, cosmetics, lifetime counters | Partial: `players` has skills (4 of 8 skill names overlap), armor slot, appearance; no stamina, equipment slots, counters | import (archive) | Different game, curve and cap; no character to attach to (see above). **47 placeholder rows are excluded row by row** ([below](#live-still-creates-game_players-rows)) |++| `game_inventory` | 399 | Carried item stacks `(user, item_id, quantity)` | `players.inventory` (slot-based, Scraplandia item ids) | import (archive) | 252 of 253 item ids have no Scraplandia item; the remaining one is a name collision |++| `game_bank` | 115 | Banked item stacks (safe on death) | None (no bank; storage boxes are world props) | import (archive) | As above |++| `game_achievements` | 122 | Completed achievements (26 ids) | None: Scraplandia has no achievement system (README) | import (archive) | Nothing to map onto; kept for a future legacy badge |++| `game_recipes` | 140 | Unlocked recipes | `players.unlocks` (blueprint recipe ids) | import (archive) | 0 of 128 recipe ids exist in Scraplandia |++| `game_fish_collection` | 13 | Fish album (times caught, best weight, first catch) | None (no fishing) | import (archive) | Nothing to map onto |++| `game_battle_stats` | 3 | PvP record (wins, losses, streaks, stolen/lost) | None persisted (combat exists, no record) | import (archive) | Nothing to map onto |++| `game_structures` | 1 | A furnace on tile (4, 190) | World props are 3D physics entities in a different world | import (archive) | No position or object mapping exists; kept as an ownership record |++| `game_farm_plots` | 0 | Tile farm plots | Planter boxes (world props) | import (archive) | Empty; listed so a non-empty snapshot would still be handled |++| `game_world_state` | 1 | Seed of HoboQuest's procedural world | Scraplandia's world is its own definition (`world_id` in `meta`) | exclude | Describes a world that no longer exists |++| `game_daily_quest_progress` | 131 | Per-day quest counters, 2026-03-10 to 2026-03-17 | None | exclude | Daily quests reset each day; these days' quests can no longer be claimed |++| `game_daily_quest_claims` | 0 | Claimed daily quests | None | exclude | Empty; past days only |++| `game_effects` | 0 | Timed buffs | Status effects are runtime state | exclude | Empty; any buff would have expired |++| `game_dungeon_runs` | 0 | Dungeon sessions | None | exclude | Empty; session state |++| `game_leaderboard` | 0 | Leaderboard cache | None | exclude | Empty; derived from `game_players` |++| `canvas_tiles` | 375 | Current pixel of each painted tile, with painter id, username and **IP address** | None | exclude | Canvas is not a Games feature. The rows carry IP addresses |++| `canvas_actions` | 846 | Placement and "blocked" log, with IP addresses | None | exclude | As above |++| `canvas_settings` | 8 | Board size, palette, cooldowns | None | exclude | Configuration of the excluded board |++| `canvas_snapshots`, `canvas_bans`, `canvas_region_locks`, `canvas_user_overrides` | 0 | Canvas snapshots and moderation | None | exclude | Empty |++++**Canvas.** The pixel board belongs with Live's community features, or with a++future product, not with Scraplandia. Its data stays in Live's database and++backups until the owner decides whether Live drops it. The excluded rows are++counted in every reconciliation. Follow-up for Live: the `/canvas` redirect++now lands on the Games portal, so it should be removed or pointed at a++notice.++++**Legacy tables named in the gate:** `game_players` 70, `game_inventory` 399,++`game_bank` 115, `game_achievements` 122, `game_recipes` 140, `canvas_tiles`++375, `canvas_actions` 846. These match the audit. The importer also covers the++other game and canvas tables. If a `game_*` or `canvas_*` table appears++without a decision, the run is marked not reconciled and `--apply` refuses.++++### What the archive loses++++- **Nothing inside an imported row.** The payload is the full source row as++ canonical JSON (sorted keys). Integers and reals stay JSON numbers, and++ `DATETIME` values stay the text Live stored (UTC, `YYYY-MM-DD HH:MM:SS`).++- **The link to Live's `users` row.** The owner is re-keyed to the Network++ subject. The Live user id is kept in `live_user_id`, and the payload's own++ `user_id`/`owner_id` column still holds it.++- **Held rows are not imported** (listed below) until their owner gets a++ Network subject. Excluded tables, and the placeholder `game_players`++ rows, are not imported at all. The placeholder rows hold only defaults.++- **Nothing in Scraplandia changes.** No character, inventory, skill or++ unlock is created or modified. `live_user_id` and the payload are kept for++ whoever designs a legacy grant.++++### Live still creates `game_players` rows++++Live's chat profile routes (`server/chat/routes.js` and++`server/chat/live-context-routes.js`) still call `game.getPlayer(userId)` to++show game levels on a profile. That function inserts a default row, at the++outpost spawn, for any user who has none. `game_players` therefore grows++with every new profile Live shows. Its newest row is from 2026-09-19, and 47++of the 70 rows are such placeholders.++++The importer excludes a `game_players` row one by one++(`isPlaceholderPlayer`) when all of these hold:++++- it was never acted on (`last_action = created_at`);++- every XP, `total_*`, `battle_*`, `structures_built` and++ `resources_gathered` column is 0;++- it has no equipment, sleeping bag, name/particle effect or sprite skin;++- its user owns no row in any other legacy game table.++++Rows of real players are not affected. The exclusion shows in the report as++`excluded` under `game_players`. A placeholder created after the dry run is++excluded the same way, so the counts still reconcile.++++Follow-up for Live, not done here: make that profile lookup read-only, so++the table stops growing before Live drops it.++++## Identity mapping++++A row's owner is its Live user id (`user_id`, or `owner_id` for++`game_structures`). The Live user id becomes a subject like this:++++1. **The Network is the authority.** The importer calls the Network identity++ service (`POST /internal/identity/resolve-batch` with++ `{ system: 'live', type: 'user', ids }`) as the `games` service principal,++ which already holds the grant `identity.subject.resolve` on++ `openvibe.network`. The Network answers from its `identity_legacy_map`, which++ holds 327 `live/user` rows.++2. **Live's own record is a cross-check.** Live stores the subject a signed-in++ user's token carried in `linked_accounts(service = 'network').subject_id`.++ On production only 1 of 327 Network links has it filled, so on its own it++ is not a usable source.++3. A row is **held**, never guessed, when:++ - the Network does not know the Live user (`no-subject`);++ - Live has a subject that the Network does not confirm (`subject-unconfirmed`);++ - Live and the Network disagree, or Live has two (`subject-conflict`);++ - the row names no user (`no-owner`);++ - the row was already imported with different content++ (`differs-from-earlier-import`: an import is never overwritten).++++ The run lists held rows by Live user, with their source keys.++++For an offline dry run, `--subjects <file>` takes the same answers as a JSON++object `{ "<live user id>": "usr_…" }` exported from the Network (command++below). `--apply` requires `--subjects network` or such a map.++++On 2026-09-23 both sources were checked on the host for the 70 Live users++that own importable rows. The identity service (games principal) and the++exported map agreed on all 70: 49 resolved, 21 unknown, 0 disagreements.++After the placeholder rows are excluded, 23 Live users still own++importable rows: 11 resolve and 12 are held.++++The 12 held users are Live accounts created 2026-03-10 to 2026-03-15 and++last seen the day they were created. They have no linked account of any++kind and no password, so nobody can sign in to them any more (Live is++SSO-only). None of them appears in the Network's `live` or `hobostreamer`++legacy maps. If the Network later records a verified `live/user/<id>`++mapping for one of them (`POST /internal/identity/legacy-map`), re-run the++importer. The test "picks up rows whose subject appears later" checks that++only that user's rows are written.++++## The archive table (`world.db`, schema 13)++++```sql++CREATE TABLE legacy_live_rows (++ source_table TEXT NOT NULL, -- e.g. game_inventory++ source_key TEXT NOT NULL, -- the source primary key, '/'-joined (e.g. '2107', '4/first_blood')++ live_user_id INTEGER NOT NULL,++ subject_id TEXT NOT NULL, -- usr_…++ payload TEXT NOT NULL, -- the whole source row, canonical JSON++ imported_at INTEGER NOT NULL, -- ms epoch++ PRIMARY KEY (source_table, source_key)++);++CREATE INDEX idx_legacy_live_rows_subject ON legacy_live_rows (subject_id);++```++++The table is added by the forward-only migration 12 → 13 in++`packages/persistence/src/sqlite/sqliteStore.ts`. Only the importer writes++it, and nothing in the simulation reads it.++++**Idempotency.** Rows are keyed by `(source_table, source_key)` and written++with `ON CONFLICT DO NOTHING` inside one transaction. The number of rows++written must equal the plan or the transaction rolls back. A re-run of the++same snapshot writes 0 rows and reports the same `imported` counts.++++## Dry run on production copies (2026-09-23)++++Sources:++++- `live.db` and `world.db` were copied on the host with++ `sudo sqlite3 -readonly <db> ".backup /tmp/…"` and then copied to a++ workstation. Both copies passed `integrity_check`.++- The subject map was exported read-only from the Network's++ `identity_legacy_map`.++++Command: `node --import tsx apps/server/scripts/importLiveLegacy.ts --live <live copy>++--world <world copy> --subjects <exported map>`. The exit code was 0 and++the world copy was byte-identical afterwards. The local copies were deleted++after the run.++++```++Live legacy import: DRY RUN (nothing written); subjects from subject map <exported from the Network identity_legacy_map>++++table decision read imported written held excluded check++game_players import 70 11 11 12 47 ok++game_inventory import 399 316 316 83 0 ok++game_bank import 115 64 64 51 0 ok++game_achievements import 122 68 68 54 0 ok++game_recipes import 140 140 140 0 0 ok++game_fish_collection import 13 13 13 0 0 ok++game_battle_stats import 3 3 3 0 0 ok++game_structures import 1 1 1 0 0 ok++game_farm_plots import 0 0 0 0 0 ok++game_world_state exclude 1 0 0 0 1 ok++game_effects exclude 0 0 0 0 0 ok++game_dungeon_runs exclude 0 0 0 0 0 ok++game_leaderboard exclude 0 0 0 0 0 ok++game_daily_quest_progress exclude 131 0 0 0 131 ok++game_daily_quest_claims exclude 0 0 0 0 0 ok++canvas_tiles exclude 375 0 0 0 375 ok++canvas_actions exclude 846 0 0 0 846 ok++canvas_settings exclude 8 0 0 0 8 ok++canvas_snapshots exclude 0 0 0 0 0 ok++canvas_bans exclude 0 0 0 0 0 ok++canvas_region_locks exclude 0 0 0 0 0 ok++canvas_user_overrides exclude 0 0 0 0 0 ok++TOTAL 2224 616 616 200 1408 ok++++read 2224 = imported 616 + held 200 + excluded 1408: balanced++Live users owning importable rows: 23; mapped to a subject: 11; held: 12 no-subject++Excluded rows of import tables: game_players: placeholder rows Live's profile lookup created for people who never played: 47++++Held rows (by Live user; table[source keys]):++ live user 2 (no-subject): game_players[2] game_inventory[285,1376,2847,2849,2850,2854,2859,2862,2863,2867,2874,2890,2931,2939,2949] game_bank[2,12,14,27,44,59,129,189,209,210,211,212,217,225,265,456,471,709,712,759,908,1004,1048,1049,1053,1059,1062,1069,1070,1071,1266,1273,1274,1280,1284,1322] game_achievements[2/chest_open_1,2/chest_open_25,2/dragonite_found,2/explorer_10k,2/explorer_1k,2/first_blood,2/first_gather,2/gather_100,2/gather_500,2/level_10_any,2/level_25_any,2/level_50_any,2/mob_slayer_10,2/mob_slayer_50,2/total_level_100,2/total_level_50]++ live user 4 (no-subject): game_players[4] game_inventory[2107,2108,2147,2149,2163,2173,2175,2177,2178,2180,2183,2188,2198,2199,2219] game_achievements[4/chest_open_1,4/first_blood,4/first_gather,4/mob_slayer_10]++ live user 5 (no-subject): game_players[5] game_inventory[531,532,533,538,2488] game_achievements[5/chest_open_1,5/explorer_1k,5/first_blood,5/level_10_any]++ live user 7 (no-subject): game_players[7] game_inventory[2985,2986,2990,2992,2993,2995,2996,3019,3024,3025,3042,3070,3087,3090,3094,3111,3122,3132,3141,3145,3150,3152] game_achievements[7/chest_open_1,7/crystal_wood,7/explorer_1k,7/first_blood,7/first_gather,7/level_10_any,7/level_25_any,7/mob_slayer_10,7/mob_slayer_50,7/total_level_50]++ live user 10 (no-subject): game_players[10] game_inventory[2489] game_achievements[10/chest_open_1,10/first_blood,10/level_10_any]++ live user 11 (no-subject): game_players[11] game_inventory[2379,2381,2382,2384,2406,2415] game_bank[3677,3683,3684,3685,3687,3693,3695,3698,3701,3720,3727,3732,3736,3741,3742] game_achievements[11/chest_open_1,11/explorer_1k,11/first_blood,11/first_gather,11/level_10_any,11/level_25_any,11/level_50_any,11/mob_slayer_10,11/total_level_100,11/total_level_50]++ live user 23 (no-subject): game_players[23]++ live user 24 (no-subject): game_players[24] game_achievements[24/level_10_any]++ live user 25 (no-subject): game_players[25] game_achievements[25/level_10_any]++ live user 27 (no-subject): game_players[27] game_inventory[37123,37133,37139,37140,37141,37142,37144,37147,37153,37154,37155,37156,37163,37168,37170,37171,38652,38661,38662] game_achievements[27/chest_open_1,27/crystal_wood,27/explorer_1k,27/first_blood,27/first_gather]++ live user 31 (no-subject): game_players[31]++ live user 44 (no-subject): game_players[44]++ no-subject: the Live user has no Network subject++++RECONCILED++```++++Reading it: 616 rows would be archived for 11 subjects. 200 rows of 12++unlinkable Live accounts are held. 1,408 rows are excluded: 47 placeholder++`game_players` rows, the 131 daily-quest rows, the world seed and all 1,229++canvas rows. Every table balances.++++## Production run++++Run these on `openvibe-ovh`, logged in as `ubuntu` (the owner of++`/opt/openvibe.games`; `sqlite3` without `sudo` below runs as `ubuntu` so++no root-owned `-wal`/`-shm` files appear next to the service's database), after this commit is deployed to++`/opt/openvibe.games`. Deploying means pull, `pnpm install++--frozen-lockfile` (the server gained the `better-sqlite3` dependency), then++restart `openvibe-games`. On its first start the server upgrades `world.db`++to schema 13, and Live is not touched. The importer runs as `ubuntu` with++the service's environment, the same way as the service, through a++transient unit.++++```bash++# --- 0. Variables ----------------------------------------------------------++TS=$(date -u +%Y%m%dT%H%M%SZ)++W=/opt/openvibe.games/data/legacy-import++RUN="sudo systemd-run --quiet --wait --pipe --collect --uid=ubuntu --gid=ubuntu \++ -p EnvironmentFile=/etc/openvibe/games.env -p WorkingDirectory=/opt/openvibe.games/apps/server \++ /usr/bin/env node --import tsx scripts/importLiveLegacy.ts"++sudo install -d -o ubuntu -g ubuntu -m 700 "$W"++curl -fsS http://127.0.0.1:8000/api/ready # the deployed build is up++++# --- 1. Snapshot Live (read-only on the production file) -------------------++sudo sqlite3 -readonly /opt/openvibe.live/data/live.db ".backup $W/live-$TS.db"++sudo chown ubuntu:ubuntu "$W/live-$TS.db" && sudo chmod 600 "$W/live-$TS.db"++sqlite3 -readonly "$W/live-$TS.db" "PRAGMA integrity_check" # as ubuntu; expect: ok++++# --- 2. Dry run (writes nothing; exit 0 = reconciled) ----------------------++$RUN --live "$W/live-$TS.db" --world /opt/openvibe.games/data/world.db --subjects network \++ | tee "$W/dry-run-$TS.txt"++# Expect the table above: read 2224 = imported 616 + held 200 + excluded 1408, RECONCILED++# (game_players grows by one placeholder, excluded, for each new profile Live shows).++++# --- 3. Apply: backs up world.db to --backup, verifies it, imports in one transaction++$RUN --live "$W/live-$TS.db" --world /opt/openvibe.games/data/world.db --subjects network \++ --apply --backup "$W/world-before-$TS.db" | tee "$W/apply-$TS.txt"++# stderr: "backup written and verified (integrity_check ok): …" and "wrote 616 rows to legacy_live_rows"++++# --- 4. Verify -------------------------------------------------------------++sqlite3 -readonly /opt/openvibe.games/data/world.db \++ "SELECT source_table, count(*) FROM legacy_live_rows GROUP BY 1 ORDER BY 1"++# game_achievements 68, game_bank 64, game_battle_stats 3, game_fish_collection 13,++# game_inventory 316, game_players 11, game_recipes 140, game_structures 1++sqlite3 -readonly /opt/openvibe.games/data/world.db \++ "SELECT count(DISTINCT subject_id) FROM legacy_live_rows" # 11++sqlite3 -readonly /opt/openvibe.games/data/world.db "PRAGMA integrity_check" # ok++$RUN --live "$W/live-$TS.db" --world /opt/openvibe.games/data/world.db --subjects network \++ | grep -E '^TOTAL|balanced|RECONCILED' # idempotency: written 0 on every row++curl -fsS http://127.0.0.1:8000/api/ready++++# --- 5. Clean up the Live snapshot (user data); keep the world backup until satisfied++sudo rm -f "$W/live-$TS.db" "$W/live-$TS.db-wal" "$W/live-$TS.db-shm"++```++++The importer refuses to start (exit 2) in these cases:++++- `--apply` without `--backup` or `--subjects`;++- a `--backup` path that already exists;++- a `--live` path under `/opt/openvibe.live/`, the production file;++- a world database that does not exist.++++It exits 3 when the plan does not reconcile, and then writes nothing.++++**Rollback.** The import only inserts into `legacy_live_rows`, so it can++be undone without touching the game:++`sqlite3 /opt/openvibe.games/data/world.db "DELETE FROM legacy_live_rows"` (as `ubuntu`).++A full restore of `$W/world-before-$TS.db` (stop the service, replace++`world.db`, remove `world.db-wal`/`-shm`, start) would also discard any++player progress made since the backup. Use it only if `world.db` itself is++damaged.++++**Offline dry run.** This is how the run above was made. It needs no++service secret. Export the map on the host, then pass it with++`--subjects <file>`:++++```bash++sudo sqlite3 -readonly /opt/openvibe.network/data/network.db \++ "SELECT json_group_object(m.source_id, m.subject_id) FROM identity_legacy_map m++ JOIN users u ON u.subject_id = m.subject_id++ WHERE m.source_system = 'live' AND m.source_type = 'user'" > live-subjects.json++```@@@ -11,7 +11,7 @@ import type {import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'/**-- * SQLite repositories for the platform-integration tables (schema 12): the++ * SQLite repositories for the platform-integration tables (schema 12+): the* legacy identity map, the mod registry and the Media mirror queue. The game* simulation never touches these; apps/server's platform adapters do.*/@@@ -153,7 +153,13 @@ describe('sqlite store', () => {const loaded = store.constraints.loadAll()expect(loaded).toHaveLength(2)expect(loaded.find((c) => c.id === 'c2')?.params).toEqual({ length: 2 })-- expect(store.meta.get('schema_version')).toBe('12')++ expect(store.meta.get('schema_version')).toBe('13')++ // v13 legacy archive table exists.++ expect(++ store.db++ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='legacy_live_rows'")++ .get(),++ ).toBeTruthy()// v9 armor column round-trips.store.players.upsert({...store.players.findByToken('tok_11111111')!,@@@ -33,7 +33,7 @@ export interface SqlitePersistenceStore extends PersistenceStore {* a database is upgraded step by step inside a transaction per step.*/--const SCHEMA_VERSION = 12++const SCHEMA_VERSION = 13const BASE_SCHEMA = `CREATE TABLE IF NOT EXISTS world_entities (@@@ -156,6 +156,26 @@ CREATE TABLE IF NOT EXISTS media_mirrors (CREATE INDEX IF NOT EXISTS idx_media_mirrors_due ON media_mirrors (status, next_attempt_at);`++/**++ * Rows imported from OpenVibe.Live's retired HoboQuest game (schema 13),++ * written only by apps/server/scripts/importLiveLegacy.ts. Scraplandia has no++ * equivalent for them, so each source row is kept verbatim (canonical JSON)++ * under its owner's Network subject; `(source_table, source_key)` is the++ * source row's primary key, which makes the import idempotent.++ */++const LEGACY_SCHEMA = `++CREATE TABLE IF NOT EXISTS legacy_live_rows (++ source_table TEXT NOT NULL,++ source_key TEXT NOT NULL,++ live_user_id INTEGER NOT NULL,++ subject_id TEXT NOT NULL,++ payload TEXT NOT NULL,++ imported_at INTEGER NOT NULL,++ PRIMARY KEY (source_table, source_key)++);++CREATE INDEX IF NOT EXISTS idx_legacy_live_rows_subject ON legacy_live_rows (subject_id);++`++/** Migration from version N applies index N-1. Each runs in a transaction. */const MIGRATIONS: Record<number, (db: Database.Database) => void> = {1: (db) => {@@@ -238,6 +258,10 @@ const MIGRATIONS: Record<number, (db: Database.Database) => void> = {db.exec('ALTER TABLE players ADD COLUMN subject_id TEXT;')db.exec(PLATFORM_SCHEMA)},++ 12: (db) => {++ // Archive of OpenVibe.Live's HoboQuest rows (docs/legacy-import.md).++ db.exec(LEGACY_SCHEMA)++ },}interface WorldEntityRow {@@@ -300,6 +324,7 @@ export function openSqliteStore(path: string): SqlitePersistenceStore {// Fresh database: create the full current schema.db.exec(BASE_SCHEMA)db.exec(PLATFORM_SCHEMA)++ db.exec(LEGACY_SCHEMA)db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('schema_version',String(SCHEMA_VERSION),@@@ -107,6 +107,9 @@ importers:ajv:specifier: ^8.17.1version: 8.20.0++ better-sqlite3:++ specifier: ^11.9.1++ version: 11.10.0openvibe-contracts:specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0@@@ -120,6 +123,9 @@ importers:specifier: ^8.18.1version: 8.21.3devDependencies:++ '@types/better-sqlite3':++ specifier: ^7.6.12++ version: 7.6.13'@types/ws':specifier: ^8.18.0version: 8.18.1
@@ -97,7 +97,10 @@ Each flush is one transaction, together with the outbox events describing
@@ -97,7 +97,10 @@ Each flush is one transaction, together with the outbox events describing
it. First boot seeds from the world definition; afterwards the DB is the
it. First boot seeds from the world definition; afterwards the DB is the
world's source of truth. Platform tables (schema 12): `identity_legacy_map`,
world's source of truth. Platform tables (schema 12): `identity_legacy_map`,
`mods`, `mod_grants`, `mod_placements`, `mod_audit`, `media_mirrors`, and the
`mods`, `mod_grants`, `mod_placements`, `mod_audit`, `media_mirrors`, and the
-SDK's `event_outbox`.
+SDK's `event_outbox`. Schema 13 adds `legacy_live_rows`: OpenVibe.Live's
+HoboQuest rows, archived verbatim under Network subjects by
+`scripts/importLiveLegacy.ts` ([docs/legacy-import.md](docs/legacy-import.md)).
+The simulation never reads it.
## Platform boundary
## Platform boundary
@@ -141,7 +141,9 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
@@ -141,7 +141,9 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
mirror are on. `games.world.saved` events reach OpenVibe.Events; no mod is
mirror are on. `games.world.saved` events reach OpenVibe.Events; no mod is
installed, no grant exists and no asset has been mirrored to Media yet.
installed, no grant exists and no asset has been mirrored to Media yet.
Player progress and canvas rows from Live's old HoboQuest tables have not
Player progress and canvas rows from Live's old HoboQuest tables have not
- been imported.
+ been imported yet. The decisions, the importer
+ (`apps/server/scripts/importLiveLegacy.ts`, dry run by default) and the
+ host commands are in [docs/legacy-import.md](docs/legacy-import.md).
- **Shared chrome.** The portal, `/play` and `/editor` load the network
- **Shared chrome.** The portal, `/play` and `/editor` load the network
navbar and footer from `https://openvibe.network/shared/`.
navbar and footer from `https://openvibe.network/shared/`.
- **Persistence proof.** `apps/server/src/game/platformIntegration.test.ts`
- **Persistence proof.** `apps/server/src/game/platformIntegration.test.ts`
@@ -158,6 +158,10 @@ with live world reconciliation.
@@ -158,6 +158,10 @@ with live world reconciliation.
map-editor assets; mod manifest validation, registry (install / grant /
map-editor assets; mod manifest validation, registry (install / grant /
enable / disable / revoke, audit) and the capability-checked runtime seam
enable / disable / revoke, audit) and the capability-checked runtime seam
for declarative `games-content@1` packs
for declarative `games-content@1` packs
+- migration gate: Live's legacy HoboQuest/canvas tables are decided and
+ reconciled ([docs/legacy-import.md](docs/legacy-import.md)): per-player
+ HoboQuest rows are archived under subjects, and the canvas and expired
+ rows are excluded. Dry run on production copies done; apply pending review
- waiting on the platform: mod principals and grants in OpenVibe.Network;
- waiting on the platform: mod principals and grants in OpenVibe.Network;
sandboxed executable mods (OpenVibe.Host Stage C); `games.progress.summary`
sandboxed executable mods (OpenVibe.Host Stage C); `games.progress.summary`
user-module writes are granted but not implemented
user-module writes are granted but not implemented
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+import type Database from 'better-sqlite3';
+import type { IdentityClient } from 'openvibe-sdk/identity';
+/** A Live user's canonical subject: `usr_<ULID>`. Guest subjects never own Live rows. */
+export declare const USER_SUBJECT: RegExp;
+/** The production Live data directory; the importer reads snapshots only. */
+export declare const LIVE_PRODUCTION_DIR = "/opt/openvibe.live/";
+export interface LegacyTableSpec {
+ table: string;
+ decision: 'import' | 'exclude';
+ /** Column naming the owning Live user (for exclude tables: evidence of play only). */
+ userColumn?: string;
+ /** Primary-key columns of the source row: the idempotency key (import tables only). */
+ keyColumns?: string[];
+ /** Why the table is excluded, or what the archived rows are. */
+ reason: string;
+ /** Rows of an import table that are excluded one by one, and why. */
+ excludeRows?: {
+ reason: string;
+ test: (row: Row, ownsOtherRows: boolean) => boolean;
+ };
+}
+type Row = Record<string, unknown>;
+/**
+ * A `game_players` row nobody played: Live's profile lookup
+ * (`game.getPlayer`, still called by Live's chat profile routes) inserts a
+ * default row for every profile it shows. Such a row was never acted on
+ * (`last_action = created_at`), has no XP, counters, equipment, sleeping bag
+ * or cosmetics, and its user owns no row in any other legacy game table.
+ */
+export declare function isPlaceholderPlayer(row: Row, ownsOtherRows: boolean): boolean;
+/** Every legacy game and canvas table in Live, with its decision (docs/legacy-import.md). */
+export declare const LEGACY_TABLES: readonly LegacyTableSpec[];
+export type HoldReason = 'no-subject' | 'subject-unconfirmed' | 'subject-conflict' | 'no-owner' | 'differs-from-earlier-import';
+export declare const HOLD_REASONS: Record<HoldReason, string>;
+export interface HeldRow {
+ table: string;
+ key: string;
+ liveUserId: number | null;
+ reason: HoldReason;
+}
+export interface TableReport {
+ table: string;
+ decision: 'import' | 'exclude';
+ /** The table does not exist in the snapshot (counts are 0). */
+ missing: boolean;
+ read: number;
+ /** Rows in world.db after the run (new plus already imported). */
+ imported: number;
+ /** Rows this run writes (0 on a re-run). */
+ written: number;
+ held: number;
+ excluded: number;
+ balanced: boolean;
+}
+export interface ImportReport {
+ dryRun: boolean;
+ subjectSource: string;
+ tables: TableReport[];
+ totals: Omit<TableReport, 'table' | 'decision' | 'missing'>;
+ /** Live users owning importable rows, and how their subjects resolved. */
+ users: {
+ total: number;
+ mapped: number;
+ held: Record<string, number>;
+ };
+ held: HeldRow[];
+ /** Rows of import tables excluded one by one, per `table: reason`. */
+ excludedRows: Record<string, number>;
+ /** Legacy-looking tables in the snapshot that no decision covers. */
+ unlisted: {
+ table: string;
+ rows: number;
+ }[];
+ /** Every table balances and nothing is unlisted. */
+ reconciled: boolean;
+}
+/** Resolves Live user ids to subjects at the Network; null = the Network does not know the user. */
+export type NetworkSubjectLookup = (liveUserIds: number[]) => Promise<Map<number, string | null>>;
+/** Lookup through the Network identity service: `(system 'live', type 'user', id)` -> subject. */
+export declare function networkSubjectLookup(identity: Pick<IdentityClient, 'resolveBatch'>): NetworkSubjectLookup;
+/** Lookup from a map exported from the Network: `{ "<live user id>": "usr_…" }`. */
+export declare function subjectMapLookup(map: unknown): NetworkSubjectLookup;
+/** A row about to be written. */
+export interface PlannedRow {
+ table: string;
+ key: string;
+ liveUserId: number;
+ subjectId: string;
+ payload: string;
+}
+export interface ImportPlan {
+ report: ImportReport;
+ rows: PlannedRow[];
+}
+/** Deterministic JSON of a source row (sorted keys) so re-runs compare byte for byte. */
+export declare function canonicalRow(row: Row): string;
+export declare function rowKey(row: Row, columns: readonly string[]): string;
+/**
+ * Refuses the production Live database: the importer reads a `.backup`
+ * snapshot, never the file Live has open.
+ */
+export declare function assertSnapshotPath(path: string, productionDir?: string): void;
+export interface RunOptions {
+ livePath?: string;
+ apply: boolean;
+ backupPath?: string;
+ subjects?: string;
+ /** Whether `backupPath` already exists. */
+ backupExists: boolean;
+}
+/**
+ * Why a run must not start, or null. `--apply` needs a fresh `--backup`
+ * path (a stale file is not a backup of now) and a Network subject source.
+ */
+export declare function refusalFor(opts: RunOptions): string | null;
+/** Subjects Live itself recorded for its users (linked_accounts, service 'network'). */
+export declare function liveLinkedSubjects(live: Database.Database): Map<number, string[]>;
+/**
+ * The subject a Live user's rows go to, or why they are held. With a Network
+ * answer the Network is the authority and Live's own record must agree; with
+ * none (an informational dry run) Live's single recorded subject is used.
+ */
+export declare function decideSubject(liveUserId: number, linked: ReadonlyMap<number, string[]>, network: ReadonlyMap<number, string | null> | null): {
+ subjectId: string;
+} | {
+ hold: HoldReason;
+};
+export interface PlanOptions {
+ /** The Network's answer for each Live user id, or null to rely on Live's own record only. */
+ network: NetworkSubjectLookup | null;
+ subjectSource: string;
+ dryRun: boolean;
+}
+/**
+ * Reads the Live snapshot and decides every row. `world` may be null (a
+ * fresh world with nothing imported yet) and is only read here.
+ */
+export declare function planLegacyImport(live: Database.Database, world: Database.Database | null, opts: PlanOptions): Promise<ImportPlan>;
+/**
+ * Writes the planned rows in one transaction. Existing keys are never
+ * touched (ON CONFLICT DO NOTHING); the write count must equal the plan or
+ * the transaction rolls back.
+ */
+export declare function applyLegacyImport(world: Database.Database, plan: ImportPlan, now: number): number;
+/** Archived row counts per source table (post-apply verification). */
+export declare function archivedCounts(world: Database.Database): Map<string, number>;
+/**
+ * Snapshot of world.db to `path` taken before anything is written, then
+ * verified: PRAGMA integrity_check must answer `ok` and the snapshot must
+ * hold the same players and archive rows as the source.
+ */
+export declare function backupAndVerify(world: Database.Database, path: string, open: (path: string) => Database.Database): Promise<void>;
+/** The reconciliation report as text. */
+export declare function formatReport(report: ImportReport): string;
+export {};
+//# sourceMappingURL=liveLegacyImport.d.ts.map
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"version":3,"file":"liveLegacyImport.d.ts","sourceRoot":"","sources":["../../src/platform/liveLegacyImport.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAA;AAC1C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAE3D,yFAAyF;AACzF,eAAO,MAAM,YAAY,QAAiC,CAAA;AAE1D,6EAA6E;AAC7E,eAAO,MAAM,mBAAmB,wBAAwB,CAAA;AAExD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,QAAQ,GAAG,SAAS,CAAA;IAC9B,sFAAsF;IACtF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,uFAAuF;IACvF,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,WAAW,CAAC,EAAE;QACZ,MAAM,EAAE,MAAM,CAAA;QACd,IAAI,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,aAAa,EAAE,OAAO,KAAK,OAAO,CAAA;KACpD,CAAA;CACF;AAED,KAAK,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAElC;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,GAAG,EAAE,aAAa,EAAE,OAAO,GAAG,OAAO,CAc7E;AAED,6FAA6F;AAC7F,eAAO,MAAM,aAAa,EAAE,SAAS,eAAe,EAuHnD,CAAA;AAKD,MAAM,MAAM,UAAU,GAClB,YAAY,GACZ,qBAAqB,GACrB,kBAAkB,GAClB,UAAU,GACV,6BAA6B,CAAA;AAEjC,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAMnD,CAAA;AAED,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,MAAM,EAAE,UAAU,CAAA;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,QAAQ,GAAG,SAAS,CAAA;IAC9B,+DAA+D;IAC/D,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAA;IAChB,4CAA4C;IAC5C,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,OAAO,CAAA;IACf,aAAa,EAAE,MAAM,CAAA;IACrB,MAAM,EAAE,WAAW,EAAE,CAAA;IACrB,MAAM,EAAE,IAAI,CAAC,WAAW,EAAE,OAAO,GAAG,UAAU,GAAG,SAAS,CAAC,CAAA;IAC3D,0EAA0E;IAC1E,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAA;IACtE,IAAI,EAAE,OAAO,EAAE,CAAA;IACf,sEAAsE;IACtE,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACpC,qEAAqE;IACrE,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAC3C,oDAAoD;IACpD,UAAU,EAAE,OAAO,CAAA;CACpB;AAED,oGAAoG;AACpG,MAAM,MAAM,oBAAoB,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,CAAA;AAEjG,kGAAkG;AAClG,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,IAAI,CAAC,cAAc,EAAE,cAAc,CAAC,GAC7C,oBAAoB,CAQtB;AAED,oFAAoF;AACpF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,oBAAoB,CAYnE;AAED,iCAAiC;AACjC,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,YAAY,CAAA;IACpB,IAAI,EAAE,UAAU,EAAE,CAAA;CACnB;AAWD,yFAAyF;AACzF,wBAAgB,YAAY,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAS7C;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,GAAG,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAEnE;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,SAAsB,GAAG,IAAI,CAO1F;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,OAAO,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2CAA2C;IAC3C,YAAY,EAAE,OAAO,CAAA;CACtB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAU1D;AAED,wFAAwF;AACxF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,QAAQ,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAiBjF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,EACrC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,GAAG,IAAI,GACjD;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,UAAU,CAAA;CAAE,CAW9C;AAaD,MAAM,WAAW,WAAW;IAC1B,6FAA6F;IAC7F,OAAO,EAAE,oBAAoB,GAAG,IAAI,CAAA;IACpC,aAAa,EAAE,MAAM,CAAA;IACrB,MAAM,EAAE,OAAO,CAAA;CAChB;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,QAAQ,CAAC,QAAQ,EACvB,KAAK,EAAE,QAAQ,CAAC,QAAQ,GAAG,IAAI,EAC/B,IAAI,EAAE,WAAW,GAChB,OAAO,CAAC,UAAU,CAAC,CAkLrB;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAiBjG;AAED,sEAAsE;AACtE,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAQ5E;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,QAAQ,CAAC,QAAQ,EACxB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,QAAQ,CAAC,QAAQ,GACxC,OAAO,CAAC,IAAI,CAAC,CAoBf;AAED,yCAAyC;AACzC,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CA4EzD"}
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=liveLegacyImport.test.d.ts.map
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"version":3,"file":"liveLegacyImport.test.d.ts","sourceRoot":"","sources":["../../src/platform/liveLegacyImport.test.ts"],"names":[],"mappings":""}
\ No newline at end of file
\ No newline at end of file
@@ -18,12 +18,14 @@
@@ -18,12 +18,14 @@
"@openvibe/protocol": "workspace:*",
"@openvibe/protocol": "workspace:*",
"@openvibe/shared": "workspace:*",
"@openvibe/shared": "workspace:*",
"ajv": "^8.17.1",
"ajv": "^8.17.1",
+ "better-sqlite3": "^11.9.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
"tsx": "^4.19.3",
"tsx": "^4.19.3",
"ws": "^8.18.1"
"ws": "^8.18.1"
},
},
"devDependencies": {
"devDependencies": {
+ "@types/better-sqlite3": "^7.6.12",
"@types/ws": "^8.18.0",
"@types/ws": "^8.18.0",
"esbuild": "^0.25.2"
"esbuild": "^0.25.2"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+/**
+ * Imports OpenVibe.Live's legacy HoboQuest rows into world.db
+ * (`legacy_live_rows`) and reconciles every legacy game and canvas table:
+ * read = imported + held + excluded. See docs/legacy-import.md.
+ *
+ * # dry run (default): prints the reconciliation, writes nothing
+ * DB_PATH=/opt/openvibe.games/data/world.db \
+ * OV_OAUTH_CLIENT_SECRET=… OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000 \
+ * node --import tsx apps/server/scripts/importLiveLegacy.ts \
+ * --live /path/to/live-snapshot.db --subjects network
+ *
+ * # apply: snapshot world.db to --backup, verify it, then import in one transaction
+ * … importLiveLegacy.ts --live … --subjects network --apply --backup /path/world-before.db
+ *
+ * Options:
+ * --live <path> a `.backup` snapshot of Live's live.db (never the production file)
+ * --world <path> world.db (default DB_PATH, else data/world.db)
+ * --subjects network resolve Live user ids at the Network identity service
+ * (games principal, grant identity.subject.resolve)
+ * --subjects <file> or a JSON object { "<live user id>": "usr_…" } exported from it
+ * (omitted: Live's own linked_accounts record only; dry run only)
+ * --apply write; requires --backup and --subjects
+ * --backup <path> where world.db is copied first (must not exist); verified with integrity_check
+ * --json print the report as JSON (includes every held row)
+ *
+ * Exit: 0 reconciled, 1 error, 2 refused (usage), 3 not reconciled.
+ * Idempotent: a re-run writes nothing (written = 0) and reports the same counts.
+ */
+import { existsSync, readFileSync } from 'node:fs'
+import Database from 'better-sqlite3'
+import { openSqliteStore } from '@openvibe/persistence/sqlite'
+import { createIdentityClient } from 'openvibe-sdk/identity'
+import { loadConfig } from '../src/config.js'
+import {
+ applyLegacyImport,
+ archivedCounts,
+ assertSnapshotPath,
+ backupAndVerify,
+ formatReport,
+ networkSubjectLookup,
+ planLegacyImport,
+ refusalFor,
+ subjectMapLookup,
+ type NetworkSubjectLookup,
+} from '../src/platform/liveLegacyImport.js'
+import { createPlatformClient } from '../src/platform/serviceClient.js'
+
+class Refusal extends Error {}
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name)
+ if (i < 0) return undefined
+ const v = process.argv[i + 1]
+ if (!v || v.startsWith('--')) throw new Refusal(`${name} needs a value`)
+ return v
+}
+
+function subjectLookup(
+ source: string | undefined,
+ config: ReturnType<typeof loadConfig>,
+): NetworkSubjectLookup | null {
+ if (!source) return null
+ if (source === 'network') {
+ const platform = createPlatformClient(config.platform)
+ if (!platform) throw new Refusal('--subjects network needs OV_OAUTH_CLIENT_SECRET')
+ return networkSubjectLookup(createIdentityClient(platform.client))
+ }
+ try {
+ return subjectMapLookup(JSON.parse(readFileSync(source, 'utf8')))
+ } catch (err) {
+ throw new Refusal(`${source}: ${err instanceof Error ? err.message : String(err)}`)
+ }
+}
+
+async function main(): Promise<number> {
+ const config = loadConfig(process.env)
+ const livePath = arg('--live')
+ const worldPath = arg('--world') ?? config.dbPath
+ const subjects = arg('--subjects')
+ const backupPath = arg('--backup')
+ const apply = process.argv.includes('--apply')
+ const json = process.argv.includes('--json')
+
+ const refusal = refusalFor({
+ livePath,
+ apply,
+ backupPath,
+ subjects,
+ backupExists: backupPath !== undefined && existsSync(backupPath),
+ })
+ if (refusal || !livePath) throw new Refusal(refusal ?? '--live is required')
+ try {
+ assertSnapshotPath(livePath)
+ } catch (err) {
+ throw new Refusal(err instanceof Error ? err.message : String(err))
+ }
+ if (!existsSync(worldPath)) throw new Refusal(`world database ${worldPath} does not exist`)
+
+ const lookup = subjectLookup(subjects, config)
+ const subjectSource = !subjects
+ ? "Live's linked_accounts only"
+ : subjects === 'network'
+ ? 'the Network identity service'
+ : `subject map ${subjects}`
+ const live = new Database(livePath, { readonly: true, fileMustExist: true })
+ try {
+ if (!apply) {
+ const world = new Database(worldPath, { readonly: true, fileMustExist: true })
+ try {
+ const plan = await planLegacyImport(live, world, {
+ network: lookup,
+ subjectSource,
+ dryRun: true,
+ })
+ console.log(json ? JSON.stringify(plan.report, null, 2) : formatReport(plan.report))
+ return plan.report.reconciled ? 0 : 3
+ } finally {
+ world.close()
+ }
+ }
+
+ // Apply: back up world.db before anything (including the schema upgrade) writes to it.
+ const raw = new Database(worldPath, { fileMustExist: true })
+ try {
+ await backupAndVerify(raw, backupPath as string, (p) => new Database(p, { readonly: true }))
+ } finally {
+ raw.close()
+ }
+ console.error(`backup written and verified (integrity_check ok): ${backupPath}`)
+
+ const store = openSqliteStore(worldPath) // upgrades to the schema with legacy_live_rows
+ try {
+ const plan = await planLegacyImport(live, store.db, {
+ network: lookup,
+ subjectSource,
+ dryRun: false,
+ })
+ if (!plan.report.reconciled) {
+ console.log(json ? JSON.stringify(plan.report, null, 2) : formatReport(plan.report))
+ console.error('not reconciled: nothing written')
+ return 3
+ }
+ const written = applyLegacyImport(store.db, plan, Date.now())
+ const after = archivedCounts(store.db)
+ for (const t of plan.report.tables) {
+ if (t.decision === 'import' && (after.get(t.table) ?? 0) < t.imported) {
+ throw new Error(`post-apply check: ${t.table} holds fewer rows than imported`)
+ }
+ }
+ console.log(json ? JSON.stringify(plan.report, null, 2) : formatReport(plan.report))
+ console.error(`wrote ${written} rows to legacy_live_rows`)
+ return 0
+ } finally {
+ store.close()
+ }
+ } finally {
+ live.close()
+ }
+}
+
+main().then(
+ (code) => process.exit(code),
+ (err: unknown) => {
+ if (err instanceof Refusal) {
+ console.error(`refused: ${err.message}`)
+ process.exit(2)
+ }
+ console.error('live legacy import failed:', err)
+ process.exit(1)
+ },
+)
new file mode 100644
new file mode 100644
@@ -0,0 +1,578 @@
@@ -0,0 +1,578 @@
+import { spawnSync } from 'node:child_process'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import Database from 'better-sqlite3'
+import { openSqliteStore } from '@openvibe/persistence/sqlite'
+import { createIdentityClient } from 'openvibe-sdk/identity'
+import { createMockPlatform } from 'openvibe-sdk/testing'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { loadPlatformConfig } from '../config.js'
+import {
+ applyLegacyImport,
+ archivedCounts,
+ assertSnapshotPath,
+ backupAndVerify,
+ decideSubject,
+ formatReport,
+ isPlaceholderPlayer,
+ networkSubjectLookup,
+ planLegacyImport,
+ refusalFor,
+ subjectMapLookup,
+ type ImportReport,
+ type TableReport,
+} from './liveLegacyImport.js'
+import { createPlatformClient } from './serviceClient.js'
+
+// Canonical subjects (Crockford base32 ULIDs).
+const S1 = 'usr_01KKT9AC60KM7CRTB3WN1Z8P56'
+const S2 = 'usr_01KKBC57W8GDSNVJJDQ15PQCDG'
+const S4A = 'usr_01KKBC57W8GDSNVJJDQ15PQCD4'
+const S4B = 'usr_01KKBC57W8GDSNVJJDQ15PQCD5'
+const S5 = 'usr_01KKBC57W8GDSNVJJDQ15PQCD6'
+const S6 = 'usr_01KKBC57W8GDSNVJJDQ15PQCD8'
+const S7 = 'usr_01KKBC57W8GDSNVJJDQ15PQCD9'
+
+/**
+ * A Live snapshot with Live's real column layout for the tables that matter:
+ * user 1 — Live and the Network agree (S1)
+ * user 2 — no Live record, the Network knows it (S2)
+ * user 3 — nobody knows it (held: no-subject)
+ * user 4 — Live says S4A, the Network S4B (held: subject-conflict)
+ * user 5 — Live says S5, the Network does not know it (held: subject-unconfirmed)
+ * user 6 — a placeholder game_players row Live's profile lookup made (excluded)
+ * user 7 — never acted and has no XP, but has a daily-quest row: played (S7)
+ */
+function makeLive(path: string): void {
+ const db = new Database(path)
+ db.pragma('synchronous = OFF')
+ db.exec(`
+ BEGIN;
+ CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL);
+ CREATE TABLE linked_accounts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, service TEXT NOT NULL,
+ service_user_id TEXT NOT NULL, service_username TEXT, linked_at DATETIME, subject_id TEXT,
+ UNIQUE(service, service_user_id)
+ );
+ CREATE TABLE game_players (
+ user_id INTEGER PRIMARY KEY, display_name TEXT, x REAL DEFAULT 4096, y REAL DEFAULT 4096,
+ mining_xp INTEGER DEFAULT 0, woodcut_xp INTEGER DEFAULT 0, hp INTEGER DEFAULT 100,
+ equip_pickaxe TEXT, sleeping_bag_x REAL, created_at DATETIME, last_action DATETIME
+ );
+ CREATE TABLE game_inventory (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, item_id TEXT NOT NULL,
+ quantity INTEGER DEFAULT 1, UNIQUE(user_id, item_id)
+ );
+ CREATE TABLE game_bank (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, item_id TEXT NOT NULL,
+ quantity INTEGER DEFAULT 1, UNIQUE(user_id, item_id)
+ );
+ CREATE TABLE game_achievements (
+ user_id INTEGER NOT NULL, achievement_id TEXT NOT NULL, completed_at DATETIME,
+ PRIMARY KEY (user_id, achievement_id)
+ );
+ CREATE TABLE game_recipes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, recipe_id TEXT NOT NULL,
+ unlocked_at DATETIME, UNIQUE(user_id, recipe_id)
+ );
+ CREATE TABLE game_structures (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER, type TEXT NOT NULL,
+ tile_x INTEGER NOT NULL, tile_y INTEGER NOT NULL
+ );
+ CREATE TABLE game_world_state (key TEXT PRIMARY KEY, value TEXT);
+ CREATE TABLE game_daily_quest_progress (
+ user_id INTEGER NOT NULL, quest_date TEXT NOT NULL, stat_key TEXT NOT NULL, value REAL,
+ PRIMARY KEY (user_id, quest_date, stat_key)
+ );
+ CREATE TABLE canvas_tiles (
+ x INTEGER NOT NULL, y INTEGER NOT NULL, color_index INTEGER NOT NULL, user_id INTEGER,
+ ip_address TEXT, PRIMARY KEY (x, y)
+ );
+ CREATE TABLE canvas_actions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, action_type TEXT NOT NULL, x INTEGER, y INTEGER,
+ user_id INTEGER, ip_address TEXT
+ );
+ INSERT INTO users (id, username) VALUES
+ (1, 'ana'), (2, 'bo'), (3, 'cy'), (4, 'di'), (5, 'ed'), (6, 'fi'), (7, 'gu');
+ INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES
+ (1, 'network', '101', '${S1}'),
+ (2, 'network', '102', NULL),
+ (4, 'network', '104', '${S4A}'),
+ (5, 'network', '105', '${S5}'),
+ (3, 'google', 'g-3', NULL);
+ INSERT INTO game_players (user_id, display_name, mining_xp, woodcut_xp, equip_pickaxe, created_at, last_action) VALUES
+ (1, 'Ana', 1200, 30, 'pick_iron', '2026-03-01 10:00:00', '2026-09-01 10:00:00'),
+ (2, 'Bo', 0, 0, NULL, '2026-03-01 10:00:00', '2026-09-02 10:00:00'),
+ (3, 'Cy', 5, 5, NULL, '2026-03-01 10:00:00', '2026-09-03 10:00:00'),
+ (4, 'Di', 7, 0, NULL, '2026-03-01 10:00:00', '2026-09-04 10:00:00'),
+ (5, 'Ed', 9, 0, NULL, '2026-03-01 10:00:00', '2026-09-05 10:00:00'),
+ (6, NULL, 0, 0, NULL, '2026-09-19 17:52:12', '2026-09-19 17:52:12'),
+ (7, NULL, 0, 0, NULL, '2026-03-10 09:00:00', '2026-03-10 09:00:00');
+ INSERT INTO game_inventory (user_id, item_id, quantity) VALUES
+ (1, 'ore_copper', 40), (1, 'pick_iron', 1), (2, 'fish_bass', 3), (3, 'loot_lint', 2),
+ (4, 'gem_ruby', 1);
+ INSERT INTO game_bank (user_id, item_id, quantity) VALUES (1, 'raw_stone', 900), (3, 'raw_stick', 12);
+ INSERT INTO game_achievements (user_id, achievement_id, completed_at) VALUES
+ (1, 'first_gather', '2026-03-10'), (1, 'level_10_any', '2026-03-11'), (5, 'first_blood', '2026-03-12');
+ INSERT INTO game_recipes (user_id, recipe_id) VALUES (2, 'recipe_plank'), (3, 'recipe_iron_bar');
+ INSERT INTO game_structures (owner_id, type, tile_x, tile_y) VALUES (1, 'furnace', 4, 190), (NULL, 'ruin', 5, 5);
+ INSERT INTO game_world_state VALUES ('world_seed', '1510266867');
+ INSERT INTO game_daily_quest_progress VALUES
+ (1, '2026-03-10', 'gathered', 5), (2, '2026-03-10', 'caught', 1), (7, '2026-03-10', 'walked', 3);
+ INSERT INTO canvas_tiles VALUES (0, 0, 3, 1, '203.0.113.9'), (0, 1, 4, 2, '203.0.113.9');
+ INSERT INTO canvas_actions (action_type, x, y, user_id, ip_address) VALUES
+ ('place', 0, 0, 1, '203.0.113.9'), ('blocked', 0, 1, 2, '203.0.113.9'), ('place', 0, 1, 2, '203.0.113.9');
+ COMMIT;
+ `)
+ db.close()
+}
+
+/** The Network's answers for the fixture users. */
+const NETWORK: Record<number, string> = { 1: S1, 2: S2, 4: S4B, 6: S6, 7: S7 }
+const network = subjectMapLookup(NETWORK)
+
+let dir: string
+let livePath: string
+let worldPath: string
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'live-legacy-'))
+ livePath = join(dir, 'live-snapshot.db')
+ worldPath = join(dir, 'world.db')
+ makeLive(livePath)
+ openSqliteStore(worldPath).close()
+})
+
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true })
+})
+
+const table = (r: ImportReport, name: string): TableReport => {
+ const t = r.tables.find((x) => x.table === name)
+ if (!t) throw new Error(`no ${name} in report`)
+ return t
+}
+
+async function plan(
+ opts: { network?: typeof network | null; world?: Database.Database | null } = {},
+) {
+ const live = new Database(livePath, { readonly: true })
+ const world = opts.world === undefined ? new Database(worldPath, { readonly: true }) : opts.world
+ try {
+ return await planLegacyImport(live, world, {
+ network: opts.network === undefined ? network : opts.network,
+ subjectSource: 'test',
+ dryRun: true,
+ })
+ } finally {
+ live.close()
+ if (opts.world === undefined) world?.close()
+ }
+}
+
+function dumpArchive(): unknown[] {
+ const db = new Database(worldPath, { readonly: true })
+ try {
+ return db.prepare('SELECT * FROM legacy_live_rows ORDER BY source_table, source_key').all()
+ } finally {
+ db.close()
+ }
+}
+
+async function applyOnce(): Promise<number> {
+ const live = new Database(livePath, { readonly: true })
+ const world = new Database(worldPath)
+ try {
+ const p = await planLegacyImport(live, world, {
+ network,
+ subjectSource: 'test',
+ dryRun: false,
+ })
+ return applyLegacyImport(world, p, 1_790_000_000_000)
+ } finally {
+ live.close()
+ world.close()
+ }
+}
+
+describe('subject mapping', () => {
+ it('uses the Network as the authority and never guesses', () => {
+ const linked = new Map<number, string[]>([
+ [1, [S1]],
+ [4, [S4A]],
+ [5, [S5]],
+ [6, [S1, S2]],
+ ])
+ const net = new Map<number, string | null>([
+ [1, S1],
+ [2, S2],
+ [3, null],
+ [4, S4B],
+ [5, null],
+ [7, 'usr_not-a-ulid'],
+ ])
+ expect(decideSubject(1, linked, net)).toEqual({ subjectId: S1 })
+ expect(decideSubject(2, linked, net)).toEqual({ subjectId: S2 })
+ expect(decideSubject(3, linked, net)).toEqual({ hold: 'no-subject' })
+ expect(decideSubject(4, linked, net)).toEqual({ hold: 'subject-conflict' })
+ expect(decideSubject(5, linked, net)).toEqual({ hold: 'subject-unconfirmed' })
+ expect(decideSubject(6, linked, net)).toEqual({ hold: 'subject-conflict' })
+ expect(decideSubject(7, linked, net)).toEqual({ hold: 'no-subject' })
+ // Without a Network answer only Live's single recorded subject counts.
+ expect(decideSubject(1, linked, null)).toEqual({ subjectId: S1 })
+ expect(decideSubject(2, linked, null)).toEqual({ hold: 'no-subject' })
+ expect(decideSubject(6, linked, null)).toEqual({ hold: 'subject-conflict' })
+ })
+
+ it('keys every imported row by the mapped subject and holds the rest', async () => {
+ const { report, rows } = await plan()
+ expect(report.users).toEqual({
+ total: 6,
+ mapped: 3,
+ held: { 'no-subject': 1, 'subject-conflict': 1, 'subject-unconfirmed': 1 },
+ })
+ const players = rows.filter((r) => r.table === 'game_players')
+ expect(players.map((r) => [r.liveUserId, r.subjectId])).toEqual([
+ [1, S1],
+ [2, S2],
+ [7, S7],
+ ])
+ expect(new Set(rows.map((r) => r.liveUserId))).toEqual(new Set([1, 2, 7]))
+ const heldUsers = new Set(report.held.map((h) => h.liveUserId))
+ expect(heldUsers).toEqual(new Set([3, 4, 5, null]))
+ expect(report.held).toContainEqual({
+ table: 'game_structures',
+ key: '2',
+ liveUserId: null,
+ reason: 'no-owner',
+ })
+ expect(report.held).toContainEqual({
+ table: 'game_achievements',
+ key: '5/first_blood',
+ liveUserId: 5,
+ reason: 'subject-unconfirmed',
+ })
+ // The payload is the whole source row, canonical JSON.
+ const ana = players.find((r) => r.liveUserId === 1)
+ expect(JSON.parse(ana?.payload ?? '{}')).toEqual({
+ created_at: '2026-03-01 10:00:00',
+ display_name: 'Ana',
+ equip_pickaxe: 'pick_iron',
+ hp: 100,
+ last_action: '2026-09-01 10:00:00',
+ mining_xp: 1200,
+ sleeping_bag_x: null,
+ user_id: 1,
+ woodcut_xp: 30,
+ x: 4096,
+ y: 4096,
+ })
+ })
+
+ it('resolves through the Network identity service with the games principal', async () => {
+ const platform = createMockPlatform({
+ clients: {
+ games: {
+ secret: 's3cret',
+ grants: [{ capability: 'identity.subject.resolve', audience: 'openvibe.network' }],
+ },
+ },
+ users: [
+ { id: 101, username: 'ana', subject_id: S1, legacy: [{ system: 'live', id: 1 }] },
+ { id: 102, username: 'bo', subject_id: S2, legacy: [{ system: 'live', id: 2 }] },
+ ],
+ })
+ const pc = createPlatformClient(
+ { ...loadPlatformConfig({}), clientSecret: 's3cret', networkUrl: platform.origins.network },
+ platform.fetch,
+ )
+ if (!pc) throw new Error('no platform client')
+ const lookup = networkSubjectLookup(createIdentityClient(pc.client))
+ const answers = await lookup([1, 2, 3])
+ expect(answers).toEqual(
+ new Map([
+ [1, S1],
+ [2, S2],
+ [3, null],
+ ]),
+ )
+ const { report } = await plan({ network: lookup })
+ expect(report.users.mapped).toBe(2)
+ })
+
+ it('without a Network source relies on Live’s own record only', async () => {
+ const { report } = await plan({ network: null })
+ // user 1 (S1), user 4 (S4A) and user 5 (S5) have a Live record; 2, 3 and 7 do not.
+ expect(report.users).toEqual({ total: 6, mapped: 3, held: { 'no-subject': 3 } })
+ })
+})
+
+describe('placeholder players', () => {
+ const base = {
+ user_id: 9,
+ mining_xp: 0,
+ total_deaths: 0,
+ battle_wins: 0,
+ equip_axe: null,
+ sleeping_bag_x: null,
+ sprite_skin: 0,
+ hp: 100,
+ chat_color: '#e8e6e3',
+ created_at: '2026-09-19 17:52:12',
+ last_action: '2026-09-19 17:52:12',
+ }
+ it('excludes only rows nobody played', () => {
+ expect(isPlaceholderPlayer(base, false)).toBe(true)
+ expect(isPlaceholderPlayer(base, true)).toBe(false) // owns other legacy rows
+ expect(isPlaceholderPlayer({ ...base, last_action: '2026-09-20 00:00:00' }, false)).toBe(false)
+ expect(isPlaceholderPlayer({ ...base, mining_xp: 1 }, false)).toBe(false)
+ expect(isPlaceholderPlayer({ ...base, total_deaths: 1 }, false)).toBe(false)
+ expect(isPlaceholderPlayer({ ...base, battle_wins: 2 }, false)).toBe(false)
+ expect(isPlaceholderPlayer({ ...base, equip_axe: 'axe_stone' }, false)).toBe(false)
+ expect(isPlaceholderPlayer({ ...base, sleeping_bag_x: 12 }, false)).toBe(false)
+ expect(isPlaceholderPlayer({ ...base, sprite_skin: 3 }, false)).toBe(false)
+ expect(isPlaceholderPlayer({ ...base, created_at: null }, false)).toBe(false)
+ })
+})
+
+describe('reconciliation', () => {
+ it('accounts for every source row: read = imported + held + excluded', async () => {
+ const { report } = await plan()
+ for (const t of report.tables) {
+ expect(t.read, t.table).toBe(t.imported + t.held + t.excluded)
+ expect(t.balanced).toBe(true)
+ }
+ expect(table(report, 'game_players')).toMatchObject({
+ read: 7,
+ imported: 3,
+ written: 3,
+ held: 3,
+ excluded: 1,
+ })
+ expect(report.excludedRows).toEqual({
+ "game_players: placeholder rows Live's profile lookup created for people who never played": 1,
+ })
+ expect(table(report, 'game_inventory')).toMatchObject({ read: 5, imported: 3, held: 2 })
+ expect(table(report, 'game_bank')).toMatchObject({ read: 2, imported: 1, held: 1 })
+ expect(table(report, 'game_achievements')).toMatchObject({ read: 3, imported: 2, held: 1 })
+ expect(table(report, 'game_recipes')).toMatchObject({ read: 2, imported: 1, held: 1 })
+ expect(table(report, 'game_structures')).toMatchObject({ read: 2, imported: 1, held: 1 })
+ expect(table(report, 'game_world_state')).toMatchObject({ read: 1, excluded: 1, imported: 0 })
+ expect(table(report, 'game_daily_quest_progress')).toMatchObject({ read: 3, excluded: 3 })
+ expect(table(report, 'canvas_tiles')).toMatchObject({ read: 2, excluded: 2 })
+ expect(table(report, 'canvas_actions')).toMatchObject({ read: 3, excluded: 3 })
+ // Tables the snapshot does not have are reported, with zero rows.
+ expect(table(report, 'game_fish_collection')).toMatchObject({ missing: true, read: 0 })
+ expect(report.totals).toEqual({
+ read: 30,
+ imported: 11,
+ written: 11,
+ held: 9,
+ excluded: 10,
+ balanced: true,
+ })
+ expect(report.unlisted).toEqual([])
+ expect(report.reconciled).toBe(true)
+ const text = formatReport(report)
+ expect(text).toContain('read 30 = imported 11 + held 9 + excluded 10: balanced')
+ expect(text).toContain(
+ 'live user 3 (no-subject): game_players[3] game_inventory[4] game_bank[2]',
+ )
+ expect(text).toContain('RECONCILED')
+ })
+
+ it('refuses to apply when a legacy table has no recorded decision', async () => {
+ const db = new Database(livePath)
+ db.exec(
+ "CREATE TABLE game_pets (user_id INTEGER, pet TEXT); INSERT INTO game_pets VALUES (1, 'rat');",
+ )
+ db.close()
+ const p = await plan()
+ expect(p.report.unlisted).toEqual([{ table: 'game_pets', rows: 1 }])
+ expect(p.report.reconciled).toBe(false)
+ expect(formatReport(p.report)).toContain('NOT RECONCILED')
+ const world = new Database(worldPath)
+ try {
+ expect(() => applyLegacyImport(world, p, 1)).toThrow(/does not reconcile/)
+ } finally {
+ world.close()
+ }
+ expect(dumpArchive()).toEqual([])
+ })
+})
+
+describe('apply and idempotency', () => {
+ it('writes the plan in one transaction and a re-run changes nothing', async () => {
+ expect(await applyOnce()).toBe(11)
+ const first = dumpArchive()
+ expect(first).toHaveLength(11)
+ const world = new Database(worldPath, { readonly: true })
+ try {
+ expect(Object.fromEntries(archivedCounts(world))).toEqual({
+ game_achievements: 2,
+ game_bank: 1,
+ game_inventory: 3,
+ game_players: 3,
+ game_recipes: 1,
+ game_structures: 1,
+ })
+ } finally {
+ world.close()
+ }
+
+ // Re-run: same counts, nothing written, archive byte-identical.
+ const again = await plan()
+ expect(again.rows).toEqual([])
+ expect(again.report.totals).toEqual({
+ read: 30,
+ imported: 11,
+ written: 0,
+ held: 9,
+ excluded: 10,
+ balanced: true,
+ })
+ expect(await applyOnce()).toBe(0)
+ expect(dumpArchive()).toEqual(first)
+ })
+
+ it('picks up rows whose subject appears later, without touching earlier ones', async () => {
+ await applyOnce()
+ const first = dumpArchive()
+ const later = subjectMapLookup({ ...NETWORK, 3: 'usr_01KKBC57W8GDSNVJJDQ15PQCD7' })
+ const p = await plan({ network: later })
+ expect(p.report.totals.written).toBe(4) // user 3: player, inventory, bank and recipe rows
+ expect(p.rows.every((r) => r.liveUserId === 3)).toBe(true)
+ const world = new Database(worldPath)
+ try {
+ expect(applyLegacyImport(world, p, 2)).toBe(4)
+ } finally {
+ world.close()
+ }
+ const after = dumpArchive()
+ expect(after).toHaveLength(15)
+ for (const row of first) expect(after).toContainEqual(row)
+ })
+
+ it('holds a row that changed since it was imported and never overwrites it', async () => {
+ await applyOnce()
+ const before = dumpArchive()
+ const db = new Database(livePath)
+ db.exec("UPDATE game_inventory SET quantity = 41 WHERE user_id = 1 AND item_id = 'ore_copper'")
+ db.close()
+ const p = await plan()
+ expect(p.report.held).toContainEqual({
+ table: 'game_inventory',
+ key: '1',
+ liveUserId: 1,
+ reason: 'differs-from-earlier-import',
+ })
+ expect(table(p.report, 'game_inventory')).toMatchObject({ read: 5, imported: 2, held: 3 })
+ expect(p.report.reconciled).toBe(true)
+ const world = new Database(worldPath)
+ try {
+ expect(applyLegacyImport(world, p, 3)).toBe(0)
+ } finally {
+ world.close()
+ }
+ expect(dumpArchive()).toEqual(before)
+ })
+
+ it('plans against a world that has no archive table yet (schema 12)', async () => {
+ const legacyWorld = new Database(':memory:')
+ const p = await plan({ world: legacyWorld })
+ expect(p.report.totals.written).toBe(11)
+ legacyWorld.close()
+ })
+})
+
+describe('safety rails', () => {
+ it('refuses --apply without --backup, a reused backup path and a missing subject source', () => {
+ const base = { livePath: 'x.db', apply: true, backupExists: false }
+ expect(refusalFor({ ...base, subjects: 'network' })).toBe('--apply requires --backup <path>')
+ expect(refusalFor({ ...base, backupPath: 'b.db' })).toMatch(/requires --subjects/)
+ expect(
+ refusalFor({ ...base, backupPath: 'b.db', subjects: 'network', backupExists: true }),
+ ).toMatch(/already exists/)
+ expect(refusalFor({ ...base, backupPath: 'b.db', subjects: 'network' })).toBeNull()
+ expect(refusalFor({ apply: false, backupExists: false })).toMatch(/--live/)
+ expect(refusalFor({ livePath: 'x.db', apply: false, backupExists: false })).toBeNull()
+ })
+
+ it('the script exits 2 on --apply without --backup and writes nothing', () => {
+ const script = join(
+ dirname(fileURLToPath(import.meta.url)),
+ '../../scripts/importLiveLegacy.ts',
+ )
+ const before = readFileSync(worldPath)
+ const run = spawnSync(
+ process.execPath,
+ [
+ '--import',
+ 'tsx',
+ script,
+ '--live',
+ livePath,
+ '--world',
+ worldPath,
+ '--apply',
+ '--subjects',
+ 'network',
+ ],
+ { encoding: 'utf8', env: { ...process.env, OV_OAUTH_CLIENT_SECRET: '' } },
+ )
+ expect(run.status).toBe(2)
+ expect(run.stderr).toContain('refused: --apply requires --backup <path>')
+ expect(readFileSync(worldPath).equals(before)).toBe(true)
+ })
+
+ it('the script dry-runs with an exported subject map and prints the reconciliation', () => {
+ const script = join(
+ dirname(fileURLToPath(import.meta.url)),
+ '../../scripts/importLiveLegacy.ts',
+ )
+ const mapPath = join(dir, 'subjects.json')
+ writeFileSync(mapPath, JSON.stringify(NETWORK))
+ const run = spawnSync(
+ process.execPath,
+ ['--import', 'tsx', script, '--live', livePath, '--world', worldPath, '--subjects', mapPath],
+ { encoding: 'utf8' },
+ )
+ expect(run.stderr).toBe('')
+ expect(run.status).toBe(0)
+ expect(run.stdout).toContain('DRY RUN')
+ expect(run.stdout).toContain('read 30 = imported 11 + held 9 + excluded 10: balanced')
+ expect(dumpArchive()).toEqual([])
+ })
+
+ it('backs up world.db and verifies the copy with integrity_check', async () => {
+ const world = new Database(worldPath)
+ const backup = join(dir, 'world-before.db')
+ try {
+ await backupAndVerify(world, backup, (p) => new Database(p, { readonly: true }))
+ expect(existsSync(backup)).toBe(true)
+ // A copy that does not verify is refused.
+ const bad = join(dir, 'world-bad.db')
+ await expect(
+ backupAndVerify(world, bad, (p) => {
+ const buf = readFileSync(p)
+ buf.fill(0x5a, 100, buf.length) // garble every page after the header
+ writeFileSync(p, buf)
+ return new Database(p, { readonly: true })
+ }),
+ ).rejects.toThrow()
+ } finally {
+ world.close()
+ }
+ })
+
+ it('refuses to read the production Live database', () => {
+ const prod = join(dir, 'opt-live')
+ mkdirSync(prod)
+ const file = join(prod, 'live.db')
+ writeFileSync(file, '')
+ expect(() => assertSnapshotPath(file, `${prod}/`)).toThrow(/production database/)
+ expect(() => assertSnapshotPath(livePath, `${prod}/`)).not.toThrow()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,746 @@
@@ -0,0 +1,746 @@
+/**
+ * Import of OpenVibe.Live's legacy game data (roadmap Wave 12 migration gate,
+ * docs/legacy-import.md).
+ *
+ * Live ran HoboQuest (a 2D tile RPG: `game_*` tables) and a pixel canvas
+ * (`canvas_*`) until both were retired. Scraplandia has no equivalent for
+ * HoboQuest's items, recipes, achievements or skills, so the per-player rows
+ * are kept verbatim in `legacy_live_rows`, keyed by the owner's canonical
+ * Network subject; world-level, expired and derived rows and the canvas are
+ * excluded with a recorded reason. Nothing here touches players or the world.
+ *
+ * Every source row ends in exactly one bucket — imported, held or excluded —
+ * and the report checks `read = imported + held + excluded` per table.
+ * Rows whose Live user has no Network subject (or an ambiguous one) are held
+ * and listed, never guessed. The import is idempotent: rows are keyed by the
+ * source primary key and never overwritten.
+ */
+import { realpathSync } from 'node:fs'
+import type Database from 'better-sqlite3'
+import type { IdentityClient } from 'openvibe-sdk/identity'
+
+/** A Live user's canonical subject: `usr_<ULID>`. Guest subjects never own Live rows. */
+export const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/
+
+/** The production Live data directory; the importer reads snapshots only. */
+export const LIVE_PRODUCTION_DIR = '/opt/openvibe.live/'
+
+export interface LegacyTableSpec {
+ table: string
+ decision: 'import' | 'exclude'
+ /** Column naming the owning Live user (for exclude tables: evidence of play only). */
+ userColumn?: string
+ /** Primary-key columns of the source row: the idempotency key (import tables only). */
+ keyColumns?: string[]
+ /** Why the table is excluded, or what the archived rows are. */
+ reason: string
+ /** Rows of an import table that are excluded one by one, and why. */
+ excludeRows?: {
+ reason: string
+ test: (row: Row, ownsOtherRows: boolean) => boolean
+ }
+}
+
+type Row = Record<string, unknown>
+
+/**
+ * A `game_players` row nobody played: Live's profile lookup
+ * (`game.getPlayer`, still called by Live's chat profile routes) inserts a
+ * default row for every profile it shows. Such a row was never acted on
+ * (`last_action = created_at`), has no XP, counters, equipment, sleeping bag
+ * or cosmetics, and its user owns no row in any other legacy game table.
+ */
+export function isPlaceholderPlayer(row: Row, ownsOtherRows: boolean): boolean {
+ if (ownsOtherRows) return false
+ if (row.created_at == null || row.last_action !== row.created_at) return false
+ for (const [col, v] of Object.entries(row)) {
+ const zero = v === null || v === 0
+ if (/_xp$|^total_|^battle_(wins|losses)$|^structures_built$|^resources_gathered$/.test(col)) {
+ if (!zero) return false
+ } else if (/^equip_|^sleeping_bag_|^name_effect$|^particle_effect$/.test(col)) {
+ if (v !== null) return false
+ } else if (col === 'sprite_skin' && !zero) {
+ return false
+ }
+ }
+ return true
+}
+
+/** Every legacy game and canvas table in Live, with its decision (docs/legacy-import.md). */
+export const LEGACY_TABLES: readonly LegacyTableSpec[] = [
+ {
+ table: 'game_players',
+ decision: 'import',
+ userColumn: 'user_id',
+ keyColumns: ['user_id'],
+ reason: 'HoboQuest profile: skill XP, combat stats, equipment, cosmetics, lifetime counters',
+ excludeRows: {
+ reason: "placeholder rows Live's profile lookup created for people who never played",
+ test: isPlaceholderPlayer,
+ },
+ },
+ {
+ table: 'game_inventory',
+ decision: 'import',
+ userColumn: 'user_id',
+ keyColumns: ['id'],
+ reason: 'HoboQuest item stacks carried by the player',
+ },
+ {
+ table: 'game_bank',
+ decision: 'import',
+ userColumn: 'user_id',
+ keyColumns: ['id'],
+ reason: 'HoboQuest item stacks in the bank',
+ },
+ {
+ table: 'game_achievements',
+ decision: 'import',
+ userColumn: 'user_id',
+ keyColumns: ['user_id', 'achievement_id'],
+ reason: 'HoboQuest achievements completed',
+ },
+ {
+ table: 'game_recipes',
+ decision: 'import',
+ userColumn: 'user_id',
+ keyColumns: ['id'],
+ reason: 'HoboQuest recipes unlocked',
+ },
+ {
+ table: 'game_fish_collection',
+ decision: 'import',
+ userColumn: 'user_id',
+ keyColumns: ['user_id', 'fish_id'],
+ reason: 'HoboQuest fish album',
+ },
+ {
+ table: 'game_battle_stats',
+ decision: 'import',
+ userColumn: 'user_id',
+ keyColumns: ['user_id'],
+ reason: 'HoboQuest PvP record',
+ },
+ {
+ table: 'game_structures',
+ decision: 'import',
+ userColumn: 'owner_id',
+ keyColumns: ['id'],
+ reason: 'HoboQuest buildings owned (a record of ownership; the tile world is gone)',
+ },
+ {
+ table: 'game_farm_plots',
+ decision: 'import',
+ userColumn: 'user_id',
+ keyColumns: ['id'],
+ reason: 'HoboQuest farm plots',
+ },
+ {
+ table: 'game_world_state',
+ decision: 'exclude',
+ reason:
+ "the seed of HoboQuest's procedural 2D world; Scraplandia's world is its own definition",
+ },
+ {
+ table: 'game_effects',
+ decision: 'exclude',
+ userColumn: 'user_id',
+ reason: 'timed buffs; every one has expired with the game',
+ },
+ {
+ table: 'game_dungeon_runs',
+ decision: 'exclude',
+ userColumn: 'leader_id',
+ reason: 'in-progress dungeon sessions; there is no dungeon to resume',
+ },
+ {
+ table: 'game_leaderboard',
+ decision: 'exclude',
+ userColumn: 'user_id',
+ reason: 'a cache derived from game_players',
+ },
+ {
+ table: 'game_daily_quest_progress',
+ decision: 'exclude',
+ userColumn: 'user_id',
+ reason: 'progress on daily quests of past days (they reset daily and can no longer be claimed)',
+ },
+ {
+ table: 'game_daily_quest_claims',
+ decision: 'exclude',
+ userColumn: 'user_id',
+ reason: 'claims of past daily quests',
+ },
+ {
+ table: 'canvas_tiles',
+ decision: 'exclude',
+ reason: "Live's pixel canvas is not a Games feature; its rows carry IP addresses",
+ },
+ {
+ table: 'canvas_actions',
+ decision: 'exclude',
+ reason: 'the pixel canvas placement and moderation log (with IP addresses)',
+ },
+ { table: 'canvas_settings', decision: 'exclude', reason: 'pixel canvas configuration' },
+ { table: 'canvas_snapshots', decision: 'exclude', reason: 'pixel canvas snapshots' },
+ { table: 'canvas_bans', decision: 'exclude', reason: 'pixel canvas moderation' },
+ { table: 'canvas_region_locks', decision: 'exclude', reason: 'pixel canvas moderation' },
+ { table: 'canvas_user_overrides', decision: 'exclude', reason: 'pixel canvas rate limits' },
+]
+
+/** Tables that belong to the legacy game or canvas: every one must be listed above. */
+const LEGACY_TABLE_PATTERN = /^(game|canvas)_/
+
+export type HoldReason =
+ | 'no-subject'
+ | 'subject-unconfirmed'
+ | 'subject-conflict'
+ | 'no-owner'
+ | 'differs-from-earlier-import'
+
+export const HOLD_REASONS: Record<HoldReason, string> = {
+ 'no-subject': 'the Live user has no Network subject',
+ 'subject-unconfirmed': 'Live records a subject but the Network does not confirm it',
+ 'subject-conflict': 'Live and the Network name different subjects',
+ 'no-owner': 'the row names no Live user',
+ 'differs-from-earlier-import': 'already imported with different content (never overwritten)',
+}
+
+export interface HeldRow {
+ table: string
+ key: string
+ liveUserId: number | null
+ reason: HoldReason
+}
+
+export interface TableReport {
+ table: string
+ decision: 'import' | 'exclude'
+ /** The table does not exist in the snapshot (counts are 0). */
+ missing: boolean
+ read: number
+ /** Rows in world.db after the run (new plus already imported). */
+ imported: number
+ /** Rows this run writes (0 on a re-run). */
+ written: number
+ held: number
+ excluded: number
+ balanced: boolean
+}
+
+export interface ImportReport {
+ dryRun: boolean
+ subjectSource: string
+ tables: TableReport[]
+ totals: Omit<TableReport, 'table' | 'decision' | 'missing'>
+ /** Live users owning importable rows, and how their subjects resolved. */
+ users: { total: number; mapped: number; held: Record<string, number> }
+ held: HeldRow[]
+ /** Rows of import tables excluded one by one, per `table: reason`. */
+ excludedRows: Record<string, number>
+ /** Legacy-looking tables in the snapshot that no decision covers. */
+ unlisted: { table: string; rows: number }[]
+ /** Every table balances and nothing is unlisted. */
+ reconciled: boolean
+}
+
+/** Resolves Live user ids to subjects at the Network; null = the Network does not know the user. */
+export type NetworkSubjectLookup = (liveUserIds: number[]) => Promise<Map<number, string | null>>
+
+/** Lookup through the Network identity service: `(system 'live', type 'user', id)` -> subject. */
+export function networkSubjectLookup(
+ identity: Pick<IdentityClient, 'resolveBatch'>,
+): NetworkSubjectLookup {
+ return async (ids) => {
+ const out = new Map<number, string | null>()
+ if (!ids.length) return out
+ const results = await identity.resolveBatch({ system: 'live', type: 'user', ids })
+ for (const id of ids) out.set(id, results[String(id)]?.subject?.id ?? null)
+ return out
+ }
+}
+
+/** Lookup from a map exported from the Network: `{ "<live user id>": "usr_…" }`. */
+export function subjectMapLookup(map: unknown): NetworkSubjectLookup {
+ if (!map || typeof map !== 'object' || Array.isArray(map)) {
+ throw new Error('expected a JSON object { "<live user id>": "usr_…" }')
+ }
+ const entries = map as Record<string, unknown>
+ return async (ids) =>
+ new Map(
+ ids.map((id) => {
+ const v = entries[String(id)]
+ return [id, typeof v === 'string' ? v : null]
+ }),
+ )
+}
+
+/** A row about to be written. */
+export interface PlannedRow {
+ table: string
+ key: string
+ liveUserId: number
+ subjectId: string
+ payload: string
+}
+
+export interface ImportPlan {
+ report: ImportReport
+ rows: PlannedRow[]
+}
+
+const quoteIdent = (name: string): string => `"${name.replace(/"/g, '""')}"`
+
+function tableExists(db: Database.Database, table: string): boolean {
+ return (
+ db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !==
+ undefined
+ )
+}
+
+/** Deterministic JSON of a source row (sorted keys) so re-runs compare byte for byte. */
+export function canonicalRow(row: Row): string {
+ const out: Row = {}
+ for (const key of Object.keys(row).sort()) {
+ const v = row[key]
+ if (typeof v === 'bigint') out[key] = Number(v)
+ else if (v === null || typeof v === 'number' || typeof v === 'string') out[key] = v
+ else throw new Error(`unsupported value in column ${key}: ${typeof v}`)
+ }
+ return JSON.stringify(out)
+}
+
+export function rowKey(row: Row, columns: readonly string[]): string {
+ return columns.map((c) => String(row[c])).join('/')
+}
+
+/**
+ * Refuses the production Live database: the importer reads a `.backup`
+ * snapshot, never the file Live has open.
+ */
+export function assertSnapshotPath(path: string, productionDir = LIVE_PRODUCTION_DIR): void {
+ const real = realpathSync(path)
+ if (real.startsWith(productionDir)) {
+ throw new Error(
+ `${path} is Live's production database; take a snapshot first (sqlite3 <db> ".backup <copy>") and pass the copy`,
+ )
+ }
+}
+
+export interface RunOptions {
+ livePath?: string
+ apply: boolean
+ backupPath?: string
+ subjects?: string
+ /** Whether `backupPath` already exists. */
+ backupExists: boolean
+}
+
+/**
+ * Why a run must not start, or null. `--apply` needs a fresh `--backup`
+ * path (a stale file is not a backup of now) and a Network subject source.
+ */
+export function refusalFor(opts: RunOptions): string | null {
+ if (!opts.livePath) return '--live <snapshot of live.db> is required'
+ if (opts.apply && !opts.backupPath) return '--apply requires --backup <path>'
+ if (opts.apply && !opts.subjects) {
+ return '--apply requires --subjects network (or an exported subject map)'
+ }
+ if (opts.backupPath && opts.backupExists) {
+ return `--backup ${opts.backupPath} already exists; choose a new path`
+ }
+ return null
+}
+
+/** Subjects Live itself recorded for its users (linked_accounts, service 'network'). */
+export function liveLinkedSubjects(live: Database.Database): Map<number, string[]> {
+ const out = new Map<number, string[]>()
+ if (!tableExists(live, 'linked_accounts')) return out
+ const cols = live.prepare('PRAGMA table_info(linked_accounts)').all() as { name: string }[]
+ if (!cols.some((c) => c.name === 'subject_id')) return out
+ const rows = live
+ .prepare(
+ "SELECT user_id, subject_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NOT NULL",
+ )
+ .all() as { user_id: number; subject_id: string }[]
+ for (const r of rows) {
+ if (!USER_SUBJECT.test(r.subject_id)) continue
+ const list = out.get(r.user_id) ?? []
+ if (!list.includes(r.subject_id)) list.push(r.subject_id)
+ out.set(r.user_id, list)
+ }
+ return out
+}
+
+/**
+ * The subject a Live user's rows go to, or why they are held. With a Network
+ * answer the Network is the authority and Live's own record must agree; with
+ * none (an informational dry run) Live's single recorded subject is used.
+ */
+export function decideSubject(
+ liveUserId: number,
+ linked: ReadonlyMap<number, string[]>,
+ network: ReadonlyMap<number, string | null> | null,
+): { subjectId: string } | { hold: HoldReason } {
+ const own = linked.get(liveUserId) ?? []
+ if (own.length > 1) return { hold: 'subject-conflict' }
+ if (network) {
+ const answer = network.get(liveUserId) ?? null
+ const n = answer !== null && USER_SUBJECT.test(answer) ? answer : null
+ if (!n) return { hold: own.length ? 'subject-unconfirmed' : 'no-subject' }
+ if (own.length && own[0] !== n) return { hold: 'subject-conflict' }
+ return { subjectId: n }
+ }
+ return own[0] ? { subjectId: own[0] } : { hold: 'no-subject' }
+}
+
+/** Rows already in world.db's archive, keyed `table\u0000key` -> payload. */
+function existingArchive(world: Database.Database | null): Map<string, string> {
+ const out = new Map<string, string>()
+ if (!world || !tableExists(world, 'legacy_live_rows')) return out
+ const rows = world
+ .prepare('SELECT source_table, source_key, payload FROM legacy_live_rows')
+ .all() as { source_table: string; source_key: string; payload: string }[]
+ for (const r of rows) out.set(`${r.source_table}\u0000${r.source_key}`, r.payload)
+ return out
+}
+
+export interface PlanOptions {
+ /** The Network's answer for each Live user id, or null to rely on Live's own record only. */
+ network: NetworkSubjectLookup | null
+ subjectSource: string
+ dryRun: boolean
+}
+
+/**
+ * Reads the Live snapshot and decides every row. `world` may be null (a
+ * fresh world with nothing imported yet) and is only read here.
+ */
+export async function planLegacyImport(
+ live: Database.Database,
+ world: Database.Database | null,
+ opts: PlanOptions,
+): Promise<ImportPlan> {
+ const sourceRows = new Map<string, Row[]>()
+ const excludedCounts = new Map<string, number>()
+ const missing = new Set<string>()
+ /** Live user id -> the legacy tables that hold a row of theirs. */
+ const activity = new Map<number, Set<string>>()
+ const owner = (row: Row, spec: LegacyTableSpec): number | null => {
+ const uid = spec.userColumn ? row[spec.userColumn] : null
+ return typeof uid === 'number' && Number.isInteger(uid) ? uid : null
+ }
+
+ for (const spec of LEGACY_TABLES) {
+ if (!tableExists(live, spec.table)) {
+ missing.add(spec.table)
+ continue
+ }
+ if (spec.userColumn) {
+ const col = quoteIdent(spec.userColumn)
+ const owners = live
+ .prepare(
+ `SELECT DISTINCT ${col} AS uid FROM ${quoteIdent(spec.table)} WHERE ${col} IS NOT NULL`,
+ )
+ .all() as { uid: unknown }[]
+ for (const { uid } of owners) {
+ if (typeof uid !== 'number') continue
+ activity.set(uid, (activity.get(uid) ?? new Set()).add(spec.table))
+ }
+ }
+ if (spec.decision === 'exclude') {
+ const n = live.prepare(`SELECT count(*) AS n FROM ${quoteIdent(spec.table)}`).get() as {
+ n: number
+ }
+ excludedCounts.set(spec.table, n.n)
+ continue
+ }
+ const order = (spec.keyColumns ?? []).map(quoteIdent).join(', ')
+ const rows = live
+ .prepare(`SELECT * FROM ${quoteIdent(spec.table)} ORDER BY ${order}`)
+ .all() as Row[]
+ sourceRows.set(spec.table, rows)
+ }
+
+ // Row-level exclusions, then the users whose rows are left to import.
+ const excludedRow = new Set<Row>()
+ const excludedRows: Record<string, number> = {}
+ const userIds = new Set<number>()
+ for (const spec of LEGACY_TABLES) {
+ for (const row of sourceRows.get(spec.table) ?? []) {
+ const uid = owner(row, spec)
+ const tables = uid === null ? undefined : activity.get(uid)
+ const ownsOtherRows = [...(tables ?? [])].some((t) => t !== spec.table)
+ if (spec.excludeRows?.test(row, ownsOtherRows)) {
+ excludedRow.add(row)
+ const k = `${spec.table}: ${spec.excludeRows.reason}`
+ excludedRows[k] = (excludedRows[k] ?? 0) + 1
+ continue
+ }
+ if (uid !== null) userIds.add(uid)
+ }
+ }
+
+ const listed = new Set(LEGACY_TABLES.map((t) => t.table))
+ const unlisted = (
+ live.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() as {
+ name: string
+ }[]
+ )
+ .map((r) => r.name)
+ .filter((name) => LEGACY_TABLE_PATTERN.test(name) && !listed.has(name))
+ .map((table) => ({
+ table,
+ rows: (live.prepare(`SELECT count(*) AS n FROM ${quoteIdent(table)}`).get() as { n: number })
+ .n,
+ }))
+
+ const ids = [...userIds].sort((a, b) => a - b)
+ const linked = liveLinkedSubjects(live)
+ const network = opts.network ? await opts.network(ids) : null
+ const verdicts = new Map(ids.map((id) => [id, decideSubject(id, linked, network)]))
+ const usersHeld: Record<string, number> = {}
+ let mapped = 0
+ for (const v of verdicts.values()) {
+ if ('subjectId' in v) mapped++
+ else usersHeld[v.hold] = (usersHeld[v.hold] ?? 0) + 1
+ }
+
+ const archive = existingArchive(world)
+ const held: HeldRow[] = []
+ const planned: PlannedRow[] = []
+ const tables: TableReport[] = []
+
+ for (const spec of LEGACY_TABLES) {
+ const t: TableReport = {
+ table: spec.table,
+ decision: spec.decision,
+ missing: missing.has(spec.table),
+ read: 0,
+ imported: 0,
+ written: 0,
+ held: 0,
+ excluded: 0,
+ balanced: true,
+ }
+ if (spec.decision === 'exclude') {
+ t.read = excludedCounts.get(spec.table) ?? 0
+ t.excluded = t.read
+ } else {
+ for (const row of sourceRows.get(spec.table) ?? []) {
+ t.read++
+ if (excludedRow.has(row)) {
+ t.excluded++
+ continue
+ }
+ const key = rowKey(row, spec.keyColumns ?? [])
+ const liveUserId = owner(row, spec)
+ const verdict = liveUserId === null ? null : verdicts.get(liveUserId)
+ if (!verdict || !('subjectId' in verdict)) {
+ held.push({
+ table: spec.table,
+ key,
+ liveUserId,
+ reason: verdict ? verdict.hold : 'no-owner',
+ })
+ t.held++
+ continue
+ }
+ const payload = canonicalRow(row)
+ const earlier = archive.get(`${spec.table}\u0000${key}`)
+ if (earlier !== undefined && earlier !== payload) {
+ held.push({ table: spec.table, key, liveUserId, reason: 'differs-from-earlier-import' })
+ t.held++
+ continue
+ }
+ t.imported++
+ if (earlier === undefined) {
+ t.written++
+ planned.push({
+ table: spec.table,
+ key,
+ liveUserId: liveUserId as number,
+ subjectId: verdict.subjectId,
+ payload,
+ })
+ }
+ }
+ }
+ t.balanced = t.read === t.imported + t.held + t.excluded
+ tables.push(t)
+ }
+
+ const totals = tables.reduce(
+ (acc, t) => ({
+ read: acc.read + t.read,
+ imported: acc.imported + t.imported,
+ written: acc.written + t.written,
+ held: acc.held + t.held,
+ excluded: acc.excluded + t.excluded,
+ balanced: acc.balanced && t.balanced,
+ }),
+ { read: 0, imported: 0, written: 0, held: 0, excluded: 0, balanced: true },
+ )
+ totals.balanced =
+ totals.balanced && totals.read === totals.imported + totals.held + totals.excluded
+
+ return {
+ rows: planned,
+ report: {
+ dryRun: opts.dryRun,
+ subjectSource: opts.subjectSource,
+ tables,
+ totals,
+ users: { total: ids.length, mapped, held: usersHeld },
+ held,
+ excludedRows,
+ unlisted,
+ reconciled: totals.balanced && unlisted.length === 0,
+ },
+ }
+}
+
+/**
+ * Writes the planned rows in one transaction. Existing keys are never
+ * touched (ON CONFLICT DO NOTHING); the write count must equal the plan or
+ * the transaction rolls back.
+ */
+export function applyLegacyImport(world: Database.Database, plan: ImportPlan, now: number): number {
+ if (!plan.report.reconciled) throw new Error('refusing to apply: the plan does not reconcile')
+ const insert = world.prepare(`
+ INSERT INTO legacy_live_rows (source_table, source_key, live_user_id, subject_id, payload, imported_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT (source_table, source_key) DO NOTHING
+ `)
+ return world.transaction(() => {
+ let written = 0
+ for (const r of plan.rows) {
+ written += insert.run(r.table, r.key, r.liveUserId, r.subjectId, r.payload, now).changes
+ }
+ if (written !== plan.rows.length) {
+ throw new Error(`wrote ${written} rows, planned ${plan.rows.length}: rolled back`)
+ }
+ return written
+ })()
+}
+
+/** Archived row counts per source table (post-apply verification). */
+export function archivedCounts(world: Database.Database): Map<string, number> {
+ const out = new Map<string, number>()
+ if (!tableExists(world, 'legacy_live_rows')) return out
+ const rows = world
+ .prepare('SELECT source_table, count(*) AS n FROM legacy_live_rows GROUP BY source_table')
+ .all() as { source_table: string; n: number }[]
+ for (const r of rows) out.set(r.source_table, r.n)
+ return out
+}
+
+/**
+ * Snapshot of world.db to `path` taken before anything is written, then
+ * verified: PRAGMA integrity_check must answer `ok` and the snapshot must
+ * hold the same players and archive rows as the source.
+ */
+export async function backupAndVerify(
+ world: Database.Database,
+ path: string,
+ open: (path: string) => Database.Database,
+): Promise<void> {
+ await world.backup(path)
+ const copy = open(path)
+ try {
+ const check = copy.pragma('integrity_check') as { integrity_check: string }[]
+ if (check.length !== 1 || check[0]?.integrity_check !== 'ok') {
+ throw new Error(`backup ${path} failed integrity_check: ${JSON.stringify(check)}`)
+ }
+ const count = (db: Database.Database, table: string): number =>
+ tableExists(db, table)
+ ? (db.prepare(`SELECT count(*) AS n FROM ${quoteIdent(table)}`).get() as { n: number }).n
+ : 0
+ for (const table of ['players', 'world_entities', 'legacy_live_rows']) {
+ if (count(copy, table) !== count(world, table)) {
+ throw new Error(`backup ${path} does not match world.db (${table} row count)`)
+ }
+ }
+ } finally {
+ copy.close()
+ }
+}
+
+/** The reconciliation report as text. */
+export function formatReport(report: ImportReport): string {
+ const lines: string[] = []
+ const mode = report.dryRun ? 'DRY RUN (nothing written)' : 'APPLIED'
+ lines.push(`Live legacy import: ${mode}; subjects from ${report.subjectSource}`)
+ lines.push('')
+ const head = ['table', 'decision', 'read', 'imported', 'written', 'held', 'excluded', 'check']
+ const body = report.tables.map((t) => [
+ t.table + (t.missing ? ' (absent)' : ''),
+ t.decision,
+ String(t.read),
+ String(t.imported),
+ String(t.written),
+ String(t.held),
+ String(t.excluded),
+ t.balanced ? 'ok' : 'MISMATCH',
+ ])
+ const tot = report.totals
+ body.push([
+ 'TOTAL',
+ '',
+ String(tot.read),
+ String(tot.imported),
+ String(tot.written),
+ String(tot.held),
+ String(tot.excluded),
+ tot.balanced ? 'ok' : 'MISMATCH',
+ ])
+ const widths = head.map((h, i) => Math.max(h.length, ...body.map((r) => (r[i] ?? '').length)))
+ const fmt = (r: string[]): string =>
+ r.map((c, i) => (i < 2 ? c.padEnd(widths[i] ?? 0) : c.padStart(widths[i] ?? 0))).join(' ')
+ lines.push(fmt(head))
+ for (const r of body) lines.push(fmt(r))
+ lines.push('')
+ lines.push(
+ `read ${tot.read} = imported ${tot.imported} + held ${tot.held} + excluded ${tot.excluded}: ${
+ tot.balanced ? 'balanced' : 'NOT BALANCED'
+ }`,
+ )
+ const heldUsers = Object.entries(report.users.held)
+ .map(([k, v]) => `${v} ${k}`)
+ .join(', ')
+ lines.push(
+ `Live users owning importable rows: ${report.users.total}; mapped to a subject: ${report.users.mapped}; held: ${heldUsers || 'none'}`,
+ )
+ for (const [what, n] of Object.entries(report.excludedRows)) {
+ lines.push(`Excluded rows of import tables: ${what}: ${n}`)
+ }
+ if (report.unlisted.length) {
+ lines.push('')
+ lines.push('UNLISTED legacy tables (no decision recorded; apply is refused):')
+ for (const u of report.unlisted) lines.push(` ${u.table}: ${u.rows} rows`)
+ }
+ if (report.held.length) {
+ lines.push('')
+ lines.push('Held rows (by Live user; table[source keys]):')
+ const byUser = new Map<string, HeldRow[]>()
+ for (const h of report.held) {
+ const k = `${h.liveUserId ?? 'none'}\u0000${h.reason}`
+ byUser.set(k, [...(byUser.get(k) ?? []), h])
+ }
+ const sorted = [...byUser.entries()].sort(
+ ([a], [b]) => Number(a.split('\u0000')[0]) - Number(b.split('\u0000')[0]),
+ )
+ for (const [k, rows] of sorted) {
+ const [uid, reason] = k.split('\u0000')
+ const perTable = new Map<string, string[]>()
+ for (const r of rows) perTable.set(r.table, [...(perTable.get(r.table) ?? []), r.key])
+ const detail = [...perTable.entries()].map(([t, keys]) => `${t}[${keys.join(',')}]`).join(' ')
+ lines.push(` live user ${uid} (${reason}): ${detail}`)
+ }
+ const reasons = [...new Set(report.held.map((h) => h.reason))]
+ for (const r of reasons) lines.push(` ${r}: ${HOLD_REASONS[r]}`)
+ }
+ lines.push('')
+ lines.push(report.reconciled ? 'RECONCILED' : 'NOT RECONCILED')
+ return lines.join('\n')
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,379 @@
@@ -0,0 +1,379 @@
+# Live legacy game data: import, map or exclude
+
+Roadmap Wave 12 migration gate: OpenVibe.Live's legacy HoboQuest and canvas
+data is either imported into OpenVibe.Games or excluded with a recorded
+reason, and every source row is reconciled.
+
+**Status (2026-09-23):** decisions recorded, importer and tests in place,
+dry run done on copies of both production databases (below).
+**Not applied to production.** The owner runs the apply after review
+([host commands](#production-run)).
+
+- Importer: [`apps/server/scripts/importLiveLegacy.ts`](../apps/server/scripts/importLiveLegacy.ts)
+ (CLI) and [`apps/server/src/platform/liveLegacyImport.ts`](../apps/server/src/platform/liveLegacyImport.ts)
+ (decisions, mapping, reconciliation).
+- Tests: [`apps/server/src/platform/liveLegacyImport.test.ts`](../apps/server/src/platform/liveLegacyImport.test.ts).
+- Destination: `legacy_live_rows` in `world.db` (schema 13).
+
+## What the legacy data is
+
+Until it was retired, Live ran two things that Games is now supposed to own:
+
+- **HoboQuest**: a 2D, tile-based RPG with a procedural world (its seed
+ is in `game_world_state`). It had eight skills (mining, fishing, woodcut,
+ farming, combat, crafting, agility, smithing) with an uncapped curve
+ (`level = floor(sqrt(xp / 25)) + 1`). Players hold 253 distinct item ids
+ (ores, fish, loot, gems, tools, rods, hats, cosmetic effects, joke items)
+ and 128 unlocked recipe ids. It also had achievements, a bank, dungeons,
+ daily quests and tile buildings. The game was removed from Live (`server/index.js`: "Game &
+ Canvas — migrated to openvibe.games"), but the tables stayed and one path
+ still writes to them. [See below](#live-still-creates-game_players-rows).
+- **Canvas**: an r/place-style 512×512 pixel board with placement
+ cooldowns and moderation. Production data covers one day (2026-03-15), three
+ accounts, 429 placements and 417 blocked attempts. Live's `/canvas` still
+ 301-redirects to `https://openvibe.games/canvas`, which serves the Games
+ portal: there is no canvas in Games.
+
+Scraplandia (Games) is a different game: a 3D physics sandbox with six
+skills (woodcutting, mining, scavenging, farming, crafting, construction,
+capped at 50 with `xpToNextLevel = 60 · level^1.45`, 347,327 XP to reach 50),
+59 item definitions, blueprint unlocks by recipe id, per-account character
+slots, and no achievement, fishing, bank, dungeon or daily-quest system.
+
+Measured against the production copy:
+
+- 1 of the 253 distinct HoboQuest item ids in inventories and banks exists
+ in Scraplandia's content: `fertilizer`, a name collision (a HoboQuest
+ farm consumable versus a Scraplandia crop input). It is not the same item.
+- 0 of the 128 HoboQuest recipe ids exist as a Scraplandia recipe or item.
+- 22 of the 70 `game_players` rows have any XP; 13 have XP in one of the four
+ skills whose names also exist in Scraplandia (mining, woodcut, farming,
+ crafting). The largest is 840,227 mining XP (HoboQuest level 184): copied
+ 1:1 it would be 2.4× the XP needed to max Scraplandia mining.
+
+## Decisions
+
+Three outcomes are possible for a table:
+
+- **import**: the row is copied, whole and unchanged, into
+ `legacy_live_rows` under its owner's Network subject;
+- **map**: the row is translated into Scraplandia's gameplay model
+ (characters, inventories, skills, unlocks);
+- **exclude**: the row is not copied, and the reason is recorded here and in
+ `LEGACY_TABLES`.
+
+**No table is mapped.** Every per-player HoboQuest table is imported as an
+archive, and nothing is written into Scraplandia's gameplay state:
+
+1. Items and recipes have no counterpart (above). A map would drop all but a
+ name collision.
+2. Skill XP comes from a different game with a different, uncapped curve.
+ Scraplandia levels unlock recipes and interactions. A raw copy would max
+ the top players on day one. A rescaled copy would still give Scraplandia
+ progression for play that did not happen in Scraplandia. Either way this
+ is a game-design decision, not a migration step.
+3. A Scraplandia character is created by its player (name, slot,
+ appearance) under their account key. Making characters for 11 accounts
+ that may never have opened Scraplandia would put characters they did
+ not create into their slots.
+
+The archive keeps every column of every imported row, keyed by subject. A
+later feature (a "HoboQuest veteran" cosmetic, a legacy profile tab, a
+tuned skill grant) can read it without going back to Live. `ADR-0006`
+applies: rows are keyed by the canonical `usr_…` subject, never by Live's
+integer id (the Live id is stored beside it for audit).
+
+| Live table | Rows | What it is | Equivalent in Games | Decision | Reason |
+| --------------------------------------------------------------------------------- | ---: | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `game_players` | 70 | HoboQuest profile: tile position, 8 skill XPs, HP/attack/defense/stamina, 6 equipment slots, sleeping bag, cosmetics, lifetime counters | Partial: `players` has skills (4 of 8 skill names overlap), armor slot, appearance; no stamina, equipment slots, counters | import (archive) | Different game, curve and cap; no character to attach to (see above). **47 placeholder rows are excluded row by row** ([below](#live-still-creates-game_players-rows)) |
+| `game_inventory` | 399 | Carried item stacks `(user, item_id, quantity)` | `players.inventory` (slot-based, Scraplandia item ids) | import (archive) | 252 of 253 item ids have no Scraplandia item; the remaining one is a name collision |
+| `game_bank` | 115 | Banked item stacks (safe on death) | None (no bank; storage boxes are world props) | import (archive) | As above |
+| `game_achievements` | 122 | Completed achievements (26 ids) | None: Scraplandia has no achievement system (README) | import (archive) | Nothing to map onto; kept for a future legacy badge |
+| `game_recipes` | 140 | Unlocked recipes | `players.unlocks` (blueprint recipe ids) | import (archive) | 0 of 128 recipe ids exist in Scraplandia |
+| `game_fish_collection` | 13 | Fish album (times caught, best weight, first catch) | None (no fishing) | import (archive) | Nothing to map onto |
+| `game_battle_stats` | 3 | PvP record (wins, losses, streaks, stolen/lost) | None persisted (combat exists, no record) | import (archive) | Nothing to map onto |
+| `game_structures` | 1 | A furnace on tile (4, 190) | World props are 3D physics entities in a different world | import (archive) | No position or object mapping exists; kept as an ownership record |
+| `game_farm_plots` | 0 | Tile farm plots | Planter boxes (world props) | import (archive) | Empty; listed so a non-empty snapshot would still be handled |
+| `game_world_state` | 1 | Seed of HoboQuest's procedural world | Scraplandia's world is its own definition (`world_id` in `meta`) | exclude | Describes a world that no longer exists |
+| `game_daily_quest_progress` | 131 | Per-day quest counters, 2026-03-10 to 2026-03-17 | None | exclude | Daily quests reset each day; these days' quests can no longer be claimed |
+| `game_daily_quest_claims` | 0 | Claimed daily quests | None | exclude | Empty; past days only |
+| `game_effects` | 0 | Timed buffs | Status effects are runtime state | exclude | Empty; any buff would have expired |
+| `game_dungeon_runs` | 0 | Dungeon sessions | None | exclude | Empty; session state |
+| `game_leaderboard` | 0 | Leaderboard cache | None | exclude | Empty; derived from `game_players` |
+| `canvas_tiles` | 375 | Current pixel of each painted tile, with painter id, username and **IP address** | None | exclude | Canvas is not a Games feature. The rows carry IP addresses |
+| `canvas_actions` | 846 | Placement and "blocked" log, with IP addresses | None | exclude | As above |
+| `canvas_settings` | 8 | Board size, palette, cooldowns | None | exclude | Configuration of the excluded board |
+| `canvas_snapshots`, `canvas_bans`, `canvas_region_locks`, `canvas_user_overrides` | 0 | Canvas snapshots and moderation | None | exclude | Empty |
+
+**Canvas.** The pixel board belongs with Live's community features, or with a
+future product, not with Scraplandia. Its data stays in Live's database and
+backups until the owner decides whether Live drops it. The excluded rows are
+counted in every reconciliation. Follow-up for Live: the `/canvas` redirect
+now lands on the Games portal, so it should be removed or pointed at a
+notice.
+
+**Legacy tables named in the gate:** `game_players` 70, `game_inventory` 399,
+`game_bank` 115, `game_achievements` 122, `game_recipes` 140, `canvas_tiles`
+375, `canvas_actions` 846. These match the audit. The importer also covers the
+other game and canvas tables. If a `game_*` or `canvas_*` table appears
+without a decision, the run is marked not reconciled and `--apply` refuses.
+
+### What the archive loses
+
+- **Nothing inside an imported row.** The payload is the full source row as
+ canonical JSON (sorted keys). Integers and reals stay JSON numbers, and
+ `DATETIME` values stay the text Live stored (UTC, `YYYY-MM-DD HH:MM:SS`).
+- **The link to Live's `users` row.** The owner is re-keyed to the Network
+ subject. The Live user id is kept in `live_user_id`, and the payload's own
+ `user_id`/`owner_id` column still holds it.
+- **Held rows are not imported** (listed below) until their owner gets a
+ Network subject. Excluded tables, and the placeholder `game_players`
+ rows, are not imported at all. The placeholder rows hold only defaults.
+- **Nothing in Scraplandia changes.** No character, inventory, skill or
+ unlock is created or modified. `live_user_id` and the payload are kept for
+ whoever designs a legacy grant.
+
+### Live still creates `game_players` rows
+
+Live's chat profile routes (`server/chat/routes.js` and
+`server/chat/live-context-routes.js`) still call `game.getPlayer(userId)` to
+show game levels on a profile. That function inserts a default row, at the
+outpost spawn, for any user who has none. `game_players` therefore grows
+with every new profile Live shows. Its newest row is from 2026-09-19, and 47
+of the 70 rows are such placeholders.
+
+The importer excludes a `game_players` row one by one
+(`isPlaceholderPlayer`) when all of these hold:
+
+- it was never acted on (`last_action = created_at`);
+- every XP, `total_*`, `battle_*`, `structures_built` and
+ `resources_gathered` column is 0;
+- it has no equipment, sleeping bag, name/particle effect or sprite skin;
+- its user owns no row in any other legacy game table.
+
+Rows of real players are not affected. The exclusion shows in the report as
+`excluded` under `game_players`. A placeholder created after the dry run is
+excluded the same way, so the counts still reconcile.
+
+Follow-up for Live, not done here: make that profile lookup read-only, so
+the table stops growing before Live drops it.
+
+## Identity mapping
+
+A row's owner is its Live user id (`user_id`, or `owner_id` for
+`game_structures`). The Live user id becomes a subject like this:
+
+1. **The Network is the authority.** The importer calls the Network identity
+ service (`POST /internal/identity/resolve-batch` with
+ `{ system: 'live', type: 'user', ids }`) as the `games` service principal,
+ which already holds the grant `identity.subject.resolve` on
+ `openvibe.network`. The Network answers from its `identity_legacy_map`, which
+ holds 327 `live/user` rows.
+2. **Live's own record is a cross-check.** Live stores the subject a signed-in
+ user's token carried in `linked_accounts(service = 'network').subject_id`.
+ On production only 1 of 327 Network links has it filled, so on its own it
+ is not a usable source.
+3. A row is **held**, never guessed, when:
+ - the Network does not know the Live user (`no-subject`);
+ - Live has a subject that the Network does not confirm (`subject-unconfirmed`);
+ - Live and the Network disagree, or Live has two (`subject-conflict`);
+ - the row names no user (`no-owner`);
+ - the row was already imported with different content
+ (`differs-from-earlier-import`: an import is never overwritten).
+
+ The run lists held rows by Live user, with their source keys.
+
+For an offline dry run, `--subjects <file>` takes the same answers as a JSON
+object `{ "<live user id>": "usr_…" }` exported from the Network (command
+below). `--apply` requires `--subjects network` or such a map.
+
+On 2026-09-23 both sources were checked on the host for the 70 Live users
+that own importable rows. The identity service (games principal) and the
+exported map agreed on all 70: 49 resolved, 21 unknown, 0 disagreements.
+After the placeholder rows are excluded, 23 Live users still own
+importable rows: 11 resolve and 12 are held.
+
+The 12 held users are Live accounts created 2026-03-10 to 2026-03-15 and
+last seen the day they were created. They have no linked account of any
+kind and no password, so nobody can sign in to them any more (Live is
+SSO-only). None of them appears in the Network's `live` or `hobostreamer`
+legacy maps. If the Network later records a verified `live/user/<id>`
+mapping for one of them (`POST /internal/identity/legacy-map`), re-run the
+importer. The test "picks up rows whose subject appears later" checks that
+only that user's rows are written.
+
+## The archive table (`world.db`, schema 13)
+
+```sql
+CREATE TABLE legacy_live_rows (
+ source_table TEXT NOT NULL, -- e.g. game_inventory
+ source_key TEXT NOT NULL, -- the source primary key, '/'-joined (e.g. '2107', '4/first_blood')
+ live_user_id INTEGER NOT NULL,
+ subject_id TEXT NOT NULL, -- usr_…
+ payload TEXT NOT NULL, -- the whole source row, canonical JSON
+ imported_at INTEGER NOT NULL, -- ms epoch
+ PRIMARY KEY (source_table, source_key)
+);
+CREATE INDEX idx_legacy_live_rows_subject ON legacy_live_rows (subject_id);
+```
+
+The table is added by the forward-only migration 12 → 13 in
+`packages/persistence/src/sqlite/sqliteStore.ts`. Only the importer writes
+it, and nothing in the simulation reads it.
+
+**Idempotency.** Rows are keyed by `(source_table, source_key)` and written
+with `ON CONFLICT DO NOTHING` inside one transaction. The number of rows
+written must equal the plan or the transaction rolls back. A re-run of the
+same snapshot writes 0 rows and reports the same `imported` counts.
+
+## Dry run on production copies (2026-09-23)
+
+Sources:
+
+- `live.db` and `world.db` were copied on the host with
+ `sudo sqlite3 -readonly <db> ".backup /tmp/…"` and then copied to a
+ workstation. Both copies passed `integrity_check`.
+- The subject map was exported read-only from the Network's
+ `identity_legacy_map`.
+
+Command: `node --import tsx apps/server/scripts/importLiveLegacy.ts --live <live copy>
+--world <world copy> --subjects <exported map>`. The exit code was 0 and
+the world copy was byte-identical afterwards. The local copies were deleted
+after the run.
+
+```
+Live legacy import: DRY RUN (nothing written); subjects from subject map <exported from the Network identity_legacy_map>
+
+table decision read imported written held excluded check
+game_players import 70 11 11 12 47 ok
+game_inventory import 399 316 316 83 0 ok
+game_bank import 115 64 64 51 0 ok
+game_achievements import 122 68 68 54 0 ok
+game_recipes import 140 140 140 0 0 ok
+game_fish_collection import 13 13 13 0 0 ok
+game_battle_stats import 3 3 3 0 0 ok
+game_structures import 1 1 1 0 0 ok
+game_farm_plots import 0 0 0 0 0 ok
+game_world_state exclude 1 0 0 0 1 ok
+game_effects exclude 0 0 0 0 0 ok
+game_dungeon_runs exclude 0 0 0 0 0 ok
+game_leaderboard exclude 0 0 0 0 0 ok
+game_daily_quest_progress exclude 131 0 0 0 131 ok
+game_daily_quest_claims exclude 0 0 0 0 0 ok
+canvas_tiles exclude 375 0 0 0 375 ok
+canvas_actions exclude 846 0 0 0 846 ok
+canvas_settings exclude 8 0 0 0 8 ok
+canvas_snapshots exclude 0 0 0 0 0 ok
+canvas_bans exclude 0 0 0 0 0 ok
+canvas_region_locks exclude 0 0 0 0 0 ok
+canvas_user_overrides exclude 0 0 0 0 0 ok
+TOTAL 2224 616 616 200 1408 ok
+
+read 2224 = imported 616 + held 200 + excluded 1408: balanced
+Live users owning importable rows: 23; mapped to a subject: 11; held: 12 no-subject
+Excluded rows of import tables: game_players: placeholder rows Live's profile lookup created for people who never played: 47
+
+Held rows (by Live user; table[source keys]):
+ live user 2 (no-subject): game_players[2] game_inventory[285,1376,2847,2849,2850,2854,2859,2862,2863,2867,2874,2890,2931,2939,2949] game_bank[2,12,14,27,44,59,129,189,209,210,211,212,217,225,265,456,471,709,712,759,908,1004,1048,1049,1053,1059,1062,1069,1070,1071,1266,1273,1274,1280,1284,1322] game_achievements[2/chest_open_1,2/chest_open_25,2/dragonite_found,2/explorer_10k,2/explorer_1k,2/first_blood,2/first_gather,2/gather_100,2/gather_500,2/level_10_any,2/level_25_any,2/level_50_any,2/mob_slayer_10,2/mob_slayer_50,2/total_level_100,2/total_level_50]
+ live user 4 (no-subject): game_players[4] game_inventory[2107,2108,2147,2149,2163,2173,2175,2177,2178,2180,2183,2188,2198,2199,2219] game_achievements[4/chest_open_1,4/first_blood,4/first_gather,4/mob_slayer_10]
+ live user 5 (no-subject): game_players[5] game_inventory[531,532,533,538,2488] game_achievements[5/chest_open_1,5/explorer_1k,5/first_blood,5/level_10_any]
+ live user 7 (no-subject): game_players[7] game_inventory[2985,2986,2990,2992,2993,2995,2996,3019,3024,3025,3042,3070,3087,3090,3094,3111,3122,3132,3141,3145,3150,3152] game_achievements[7/chest_open_1,7/crystal_wood,7/explorer_1k,7/first_blood,7/first_gather,7/level_10_any,7/level_25_any,7/mob_slayer_10,7/mob_slayer_50,7/total_level_50]
+ live user 10 (no-subject): game_players[10] game_inventory[2489] game_achievements[10/chest_open_1,10/first_blood,10/level_10_any]
+ live user 11 (no-subject): game_players[11] game_inventory[2379,2381,2382,2384,2406,2415] game_bank[3677,3683,3684,3685,3687,3693,3695,3698,3701,3720,3727,3732,3736,3741,3742] game_achievements[11/chest_open_1,11/explorer_1k,11/first_blood,11/first_gather,11/level_10_any,11/level_25_any,11/level_50_any,11/mob_slayer_10,11/total_level_100,11/total_level_50]
+ live user 23 (no-subject): game_players[23]
+ live user 24 (no-subject): game_players[24] game_achievements[24/level_10_any]
+ live user 25 (no-subject): game_players[25] game_achievements[25/level_10_any]
+ live user 27 (no-subject): game_players[27] game_inventory[37123,37133,37139,37140,37141,37142,37144,37147,37153,37154,37155,37156,37163,37168,37170,37171,38652,38661,38662] game_achievements[27/chest_open_1,27/crystal_wood,27/explorer_1k,27/first_blood,27/first_gather]
+ live user 31 (no-subject): game_players[31]
+ live user 44 (no-subject): game_players[44]
+ no-subject: the Live user has no Network subject
+
+RECONCILED
+```
+
+Reading it: 616 rows would be archived for 11 subjects. 200 rows of 12
+unlinkable Live accounts are held. 1,408 rows are excluded: 47 placeholder
+`game_players` rows, the 131 daily-quest rows, the world seed and all 1,229
+canvas rows. Every table balances.
+
+## Production run
+
+Run these on `openvibe-ovh`, logged in as `ubuntu` (the owner of
+`/opt/openvibe.games`; `sqlite3` without `sudo` below runs as `ubuntu` so
+no root-owned `-wal`/`-shm` files appear next to the service's database), after this commit is deployed to
+`/opt/openvibe.games`. Deploying means pull, `pnpm install
+--frozen-lockfile` (the server gained the `better-sqlite3` dependency), then
+restart `openvibe-games`. On its first start the server upgrades `world.db`
+to schema 13, and Live is not touched. The importer runs as `ubuntu` with
+the service's environment, the same way as the service, through a
+transient unit.
+
+```bash
+# --- 0. Variables ----------------------------------------------------------
+TS=$(date -u +%Y%m%dT%H%M%SZ)
+W=/opt/openvibe.games/data/legacy-import
+RUN="sudo systemd-run --quiet --wait --pipe --collect --uid=ubuntu --gid=ubuntu \
+ -p EnvironmentFile=/etc/openvibe/games.env -p WorkingDirectory=/opt/openvibe.games/apps/server \
+ /usr/bin/env node --import tsx scripts/importLiveLegacy.ts"
+sudo install -d -o ubuntu -g ubuntu -m 700 "$W"
+curl -fsS http://127.0.0.1:8000/api/ready # the deployed build is up
+
+# --- 1. Snapshot Live (read-only on the production file) -------------------
+sudo sqlite3 -readonly /opt/openvibe.live/data/live.db ".backup $W/live-$TS.db"
+sudo chown ubuntu:ubuntu "$W/live-$TS.db" && sudo chmod 600 "$W/live-$TS.db"
+sqlite3 -readonly "$W/live-$TS.db" "PRAGMA integrity_check" # as ubuntu; expect: ok
+
+# --- 2. Dry run (writes nothing; exit 0 = reconciled) ----------------------
+$RUN --live "$W/live-$TS.db" --world /opt/openvibe.games/data/world.db --subjects network \
+ | tee "$W/dry-run-$TS.txt"
+# Expect the table above: read 2224 = imported 616 + held 200 + excluded 1408, RECONCILED
+# (game_players grows by one placeholder, excluded, for each new profile Live shows).
+
+# --- 3. Apply: backs up world.db to --backup, verifies it, imports in one transaction
+$RUN --live "$W/live-$TS.db" --world /opt/openvibe.games/data/world.db --subjects network \
+ --apply --backup "$W/world-before-$TS.db" | tee "$W/apply-$TS.txt"
+# stderr: "backup written and verified (integrity_check ok): …" and "wrote 616 rows to legacy_live_rows"
+
+# --- 4. Verify -------------------------------------------------------------
+sqlite3 -readonly /opt/openvibe.games/data/world.db \
+ "SELECT source_table, count(*) FROM legacy_live_rows GROUP BY 1 ORDER BY 1"
+# game_achievements 68, game_bank 64, game_battle_stats 3, game_fish_collection 13,
+# game_inventory 316, game_players 11, game_recipes 140, game_structures 1
+sqlite3 -readonly /opt/openvibe.games/data/world.db \
+ "SELECT count(DISTINCT subject_id) FROM legacy_live_rows" # 11
+sqlite3 -readonly /opt/openvibe.games/data/world.db "PRAGMA integrity_check" # ok
+$RUN --live "$W/live-$TS.db" --world /opt/openvibe.games/data/world.db --subjects network \
+ | grep -E '^TOTAL|balanced|RECONCILED' # idempotency: written 0 on every row
+curl -fsS http://127.0.0.1:8000/api/ready
+
+# --- 5. Clean up the Live snapshot (user data); keep the world backup until satisfied
+sudo rm -f "$W/live-$TS.db" "$W/live-$TS.db-wal" "$W/live-$TS.db-shm"
+```
+
+The importer refuses to start (exit 2) in these cases:
+
+- `--apply` without `--backup` or `--subjects`;
+- a `--backup` path that already exists;
+- a `--live` path under `/opt/openvibe.live/`, the production file;
+- a world database that does not exist.
+
+It exits 3 when the plan does not reconcile, and then writes nothing.
+
+**Rollback.** The import only inserts into `legacy_live_rows`, so it can
+be undone without touching the game:
+`sqlite3 /opt/openvibe.games/data/world.db "DELETE FROM legacy_live_rows"` (as `ubuntu`).
+A full restore of `$W/world-before-$TS.db` (stop the service, replace
+`world.db`, remove `world.db-wal`/`-shm`, start) would also discard any
+player progress made since the backup. Use it only if `world.db` itself is
+damaged.
+
+**Offline dry run.** This is how the run above was made. It needs no
+service secret. Export the map on the host, then pass it with
+`--subjects <file>`:
+
+```bash
+sudo sqlite3 -readonly /opt/openvibe.network/data/network.db \
+ "SELECT json_group_object(m.source_id, m.subject_id) FROM identity_legacy_map m
+ JOIN users u ON u.subject_id = m.subject_id
+ WHERE m.source_system = 'live' AND m.source_type = 'user'" > live-subjects.json
+```
@@ -11,7 +11,7 @@ import type {
@@ -11,7 +11,7 @@ import type {
import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'
import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'
/**
/**
- * SQLite repositories for the platform-integration tables (schema 12): the
+ * SQLite repositories for the platform-integration tables (schema 12+): the
* legacy identity map, the mod registry and the Media mirror queue. The game
* legacy identity map, the mod registry and the Media mirror queue. The game
* simulation never touches these; apps/server's platform adapters do.
* simulation never touches these; apps/server's platform adapters do.
*/
*/
@@ -153,7 +153,13 @@ describe('sqlite store', () => {
@@ -153,7 +153,13 @@ describe('sqlite store', () => {
const loaded = store.constraints.loadAll()
const loaded = store.constraints.loadAll()
expect(loaded).toHaveLength(2)
expect(loaded).toHaveLength(2)
expect(loaded.find((c) => c.id === 'c2')?.params).toEqual({ length: 2 })
expect(loaded.find((c) => c.id === 'c2')?.params).toEqual({ length: 2 })
- expect(store.meta.get('schema_version')).toBe('12')
+ expect(store.meta.get('schema_version')).toBe('13')
+ // v13 legacy archive table exists.
+ expect(
+ store.db
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='legacy_live_rows'")
+ .get(),
+ ).toBeTruthy()
// v9 armor column round-trips.
// v9 armor column round-trips.
store.players.upsert({
store.players.upsert({
...store.players.findByToken('tok_11111111')!,
...store.players.findByToken('tok_11111111')!,
@@ -33,7 +33,7 @@ export interface SqlitePersistenceStore extends PersistenceStore {
@@ -33,7 +33,7 @@ export interface SqlitePersistenceStore extends PersistenceStore {
* a database is upgraded step by step inside a transaction per step.
* a database is upgraded step by step inside a transaction per step.
*/
*/
-const SCHEMA_VERSION = 12
+const SCHEMA_VERSION = 13
const BASE_SCHEMA = `
const BASE_SCHEMA = `
CREATE TABLE IF NOT EXISTS world_entities (
CREATE TABLE IF NOT EXISTS world_entities (
@@ -156,6 +156,26 @@ CREATE TABLE IF NOT EXISTS media_mirrors (
@@ -156,6 +156,26 @@ CREATE TABLE IF NOT EXISTS media_mirrors (
CREATE INDEX IF NOT EXISTS idx_media_mirrors_due ON media_mirrors (status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_media_mirrors_due ON media_mirrors (status, next_attempt_at);
`
`
+/**
+ * Rows imported from OpenVibe.Live's retired HoboQuest game (schema 13),
+ * written only by apps/server/scripts/importLiveLegacy.ts. Scraplandia has no
+ * equivalent for them, so each source row is kept verbatim (canonical JSON)
+ * under its owner's Network subject; `(source_table, source_key)` is the
+ * source row's primary key, which makes the import idempotent.
+ */
+const LEGACY_SCHEMA = `
+CREATE TABLE IF NOT EXISTS legacy_live_rows (
+ source_table TEXT NOT NULL,
+ source_key TEXT NOT NULL,
+ live_user_id INTEGER NOT NULL,
+ subject_id TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ imported_at INTEGER NOT NULL,
+ PRIMARY KEY (source_table, source_key)
+);
+CREATE INDEX IF NOT EXISTS idx_legacy_live_rows_subject ON legacy_live_rows (subject_id);
+`
+
/** Migration from version N applies index N-1. Each runs in a transaction. */
/** Migration from version N applies index N-1. Each runs in a transaction. */
const MIGRATIONS: Record<number, (db: Database.Database) => void> = {
const MIGRATIONS: Record<number, (db: Database.Database) => void> = {
1: (db) => {
1: (db) => {
@@ -238,6 +258,10 @@ const MIGRATIONS: Record<number, (db: Database.Database) => void> = {
@@ -238,6 +258,10 @@ const MIGRATIONS: Record<number, (db: Database.Database) => void> = {
db.exec('ALTER TABLE players ADD COLUMN subject_id TEXT;')
db.exec('ALTER TABLE players ADD COLUMN subject_id TEXT;')
db.exec(PLATFORM_SCHEMA)
db.exec(PLATFORM_SCHEMA)
},
},
+ 12: (db) => {
+ // Archive of OpenVibe.Live's HoboQuest rows (docs/legacy-import.md).
+ db.exec(LEGACY_SCHEMA)
+ },
}
}
interface WorldEntityRow {
interface WorldEntityRow {
@@ -300,6 +324,7 @@ export function openSqliteStore(path: string): SqlitePersistenceStore {
@@ -300,6 +324,7 @@ export function openSqliteStore(path: string): SqlitePersistenceStore {
// Fresh database: create the full current schema.
// Fresh database: create the full current schema.
db.exec(BASE_SCHEMA)
db.exec(BASE_SCHEMA)
db.exec(PLATFORM_SCHEMA)
db.exec(PLATFORM_SCHEMA)
+ db.exec(LEGACY_SCHEMA)
db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(
db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(
'schema_version',
'schema_version',
String(SCHEMA_VERSION),
String(SCHEMA_VERSION),
@@ -107,6 +107,9 @@ importers:
@@ -107,6 +107,9 @@ importers:
ajv:
ajv:
specifier: ^8.17.1
specifier: ^8.17.1
version: 8.20.0
version: 8.20.0
+ better-sqlite3:
+ specifier: ^11.9.1
+ version: 11.10.0
openvibe-contracts:
openvibe-contracts:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0
@@ -120,6 +123,9 @@ importers:
@@ -120,6 +123,9 @@ importers:
specifier: ^8.18.1
specifier: ^8.18.1
version: 8.21.3
version: 8.21.3
devDependencies:
devDependencies:
+ '@types/better-sqlite3':
+ specifier: ^7.6.12
+ version: 7.6.13
'@types/ws':
'@types/ws':
specifier: ^8.18.0
specifier: ^8.18.0
version: 8.18.1
version: 8.18.1