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

OP OpenVibers committed 23 Sept 2026 · 474df50332 · 16 files, +2087 −5


Changes

ddiff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
iindex ec86a96..65265e6 100644
---- a/ARCHITECTURE.md
++++ b/ARCHITECTURE.md
@@@ -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
world'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
ddiff --git a/README.md b/README.md
iindex c6aed03..6ea8e32 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
-- 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
navbar and footer from `https://openvibe.network/shared/`.
- **Persistence proof.** `apps/server/src/game/platformIntegration.test.ts`
ddiff --git a/ROADMAP.md b/ROADMAP.md
iindex 3c43a71..e880d1a 100644
---- a/ROADMAP.md
++++ b/ROADMAP.md
@@@ -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 seam
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;
sandboxed executable mods (OpenVibe.Host Stage C); `games.progress.summary`
user-module writes are granted but not implemented
ddiff --git a/apps/server/dist-types/platform/liveLegacyImport.d.ts b/apps/server/dist-types/platform/liveLegacyImport.d.ts
nnew file mode 100644
iindex 0000000..b094ba4
---- /dev/null
++++ b/apps/server/dist-types/platform/liveLegacyImport.d.ts
@@@ -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
ddiff --git a/apps/server/dist-types/platform/liveLegacyImport.d.ts.map b/apps/server/dist-types/platform/liveLegacyImport.d.ts.map
nnew file mode 100644
iindex 0000000..30c7d4a
---- /dev/null
++++ b/apps/server/dist-types/platform/liveLegacyImport.d.ts.map
@@@ -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
ddiff --git a/apps/server/dist-types/platform/liveLegacyImport.test.d.ts b/apps/server/dist-types/platform/liveLegacyImport.test.d.ts
nnew file mode 100644
iindex 0000000..1837439
---- /dev/null
++++ b/apps/server/dist-types/platform/liveLegacyImport.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=liveLegacyImport.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/liveLegacyImport.test.d.ts.map b/apps/server/dist-types/platform/liveLegacyImport.test.d.ts.map
nnew file mode 100644
iindex 0000000..a0fa090
---- /dev/null
++++ b/apps/server/dist-types/platform/liveLegacyImport.test.d.ts.map
@@@ -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
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex f5414e5..a4fe72a 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -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"
}
ddiff --git a/apps/server/scripts/importLiveLegacy.ts b/apps/server/scripts/importLiveLegacy.ts
nnew file mode 100644
iindex 0000000..cf86d74
---- /dev/null
++++ b/apps/server/scripts/importLiveLegacy.ts
@@@ -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)
++ },
++)
ddiff --git a/apps/server/src/platform/liveLegacyImport.test.ts b/apps/server/src/platform/liveLegacyImport.test.ts
nnew file mode 100644
iindex 0000000..d8fe145
---- /dev/null
++++ b/apps/server/src/platform/liveLegacyImport.test.ts
@@@ -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()
++ })
++})
ddiff --git a/apps/server/src/platform/liveLegacyImport.ts b/apps/server/src/platform/liveLegacyImport.ts
nnew file mode 100644
iindex 0000000..b4bc965
---- /dev/null
++++ b/apps/server/src/platform/liveLegacyImport.ts
@@@ -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')
++}
ddiff --git a/docs/legacy-import.md b/docs/legacy-import.md
nnew file mode 100644
iindex 0000000..f47d237
---- /dev/null
++++ b/docs/legacy-import.md
@@@ -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
++```
ddiff --git a/packages/persistence/src/sqlite/platformRepositories.ts b/packages/persistence/src/sqlite/platformRepositories.ts
iindex c692307..1c89a34 100644
---- a/packages/persistence/src/sqlite/platformRepositories.ts
++++ b/packages/persistence/src/sqlite/platformRepositories.ts
@@@ -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.
*/
ddiff --git a/packages/persistence/src/sqlite/sqliteStore.test.ts b/packages/persistence/src/sqlite/sqliteStore.test.ts
iindex b759e38..889ce6f 100644
---- a/packages/persistence/src/sqlite/sqliteStore.test.ts
++++ b/packages/persistence/src/sqlite/sqliteStore.test.ts
@@@ -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')!,
ddiff --git a/packages/persistence/src/sqlite/sqliteStore.ts b/packages/persistence/src/sqlite/sqliteStore.ts
iindex adc255e..a4e7584 100644
---- a/packages/persistence/src/sqlite/sqliteStore.ts
++++ b/packages/persistence/src/sqlite/sqliteStore.ts
@@@ -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 = 13
const 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),
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 0fc0fa5..12631bd 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -107,6 +107,9 @@ importers:
ajv:
specifier: ^8.17.1
version: 8.20.0
++ better-sqlite3:
++ specifier: ^11.9.1
++ version: 11.10.0
openvibe-contracts:
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
@@@ -120,6 +123,9 @@ importers:
specifier: ^8.18.1
version: 8.21.3
devDependencies:
++ '@types/better-sqlite3':
++ specifier: ^7.6.12
++ version: 7.6.13
'@types/ws':
specifier: ^8.18.0
version: 8.18.1