From a061297a3f7e643af96c73a6f9939b2e68f9d8c9 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 2 Aug 2026 15:39:06 +0000 Subject: [PATCH] feat: import Ref current-season comparison fixture --- .../currentSeasonFixture.integration.test.ts | 20 + docs/legacy-db-migration.md | 24 + tools/legacy-db-migration/README.md | 40 + tools/legacy-db-migration/src/cli.ts | 63 +- .../legacy-db-migration/src/currentSeason.ts | 916 ++++++++++++++++++ tools/legacy-db-migration/src/db.ts | 14 +- .../test/currentSeason.test.ts | 50 + 7 files changed, 1119 insertions(+), 8 deletions(-) create mode 100644 app/game-engine/test/currentSeasonFixture.integration.test.ts create mode 100644 tools/legacy-db-migration/src/currentSeason.ts create mode 100644 tools/legacy-db-migration/test/currentSeason.test.ts diff --git a/app/game-engine/test/currentSeasonFixture.integration.test.ts b/app/game-engine/test/currentSeasonFixture.integration.test.ts new file mode 100644 index 0000000..488502b --- /dev/null +++ b/app/game-engine/test/currentSeasonFixture.integration.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; + +const databaseUrl = process.env.CURRENT_SEASON_FIXTURE_DATABASE_URL; + +describe.skipIf(!databaseUrl)('Ref current-season fixture loader', () => { + it('loads the imported scenario 2601 year-186 world without semantic row loss', async () => { + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + + expect(loaded.state).toMatchObject({ currentYear: 186, currentMonth: 1, tickSeconds: 600 }); + expect(loaded.snapshot.generals).toHaveLength(960); + expect(loaded.snapshot.nations).toHaveLength(46); + expect(loaded.snapshot.cities).toHaveLength(94); + expect(loaded.snapshot.diplomacy).toHaveLength(2070); + expect(loaded.snapshot.troops).toHaveLength(20); + expect(loaded.snapshot.events).toHaveLength(9); + expect(loaded.snapshot.initialEvents).toHaveLength(0); + }); +}); diff --git a/docs/legacy-db-migration.md b/docs/legacy-db-migration.md index e2f2a42..7362e55 100644 --- a/docs/legacy-db-migration.md +++ b/docs/legacy-db-migration.md @@ -75,6 +75,30 @@ excluded. In particular, `general`, `city`, `nation`, their turn queues, `ng_betting`, `reserved_open`, `select_pool`, `select_npc_token` and `plock` must not be used to reconstruct a running season. +### Current-season comparison fixture + +The archive exclusion above remains the production migration contract. The +same CLI also provides a separately guarded `current-season-fixture` command for +an isolated Ref/Core comparison database only. It requires a Core template and +Ref source that already match the explicitly supplied scenario/year/month, and +an apply requires `--replace-current-season --apply` together. + +Within one target transaction it truncates season-owned tables and imports the +Ref world clock, dynamic city fields, nations, generals, command queues, +diplomacy matrix, troops, ranks, messages, access log, events, betting, auctions, +yearbook, current storage audit rows and general/world logs. Static Core city +geometry and connection metadata remain from the cloned template. Legacy +message payload targets are renamed from `id`/`nation_id` to +`generalId`/`nationId` so the typed Core API can read them. + +The command refuses an active turn-daemon lease. It intentionally omits process +locks, reservations and selection tokens, Redis-owned tournament state, +`statistic` aggregate text and `ng_diplomacy` letters; the result reports each +unsupported category. Owner binding for a browser-capture account is explicit +through `CURRENT_SEASON_CAPTURE_USER_ID` and +`CURRENT_SEASON_CAPTURE_SOURCE_OWNER`. This mode must not be used for a live +season or as a substitute for the long-lived archive cutover procedure. + ## Archived play read model `/past-plays` is an authenticated, read-only projection. The server derives the diff --git a/tools/legacy-db-migration/README.md b/tools/legacy-db-migration/README.md index 35c34ad..b0c15dd 100644 --- a/tools/legacy-db-migration/README.md +++ b/tools/legacy-db-migration/README.md @@ -37,6 +37,46 @@ LEGACY_GAME_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate After reviewing the JSON counts and excluded-table reasons, add `GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`. +### Isolated current-season comparison fixture + +`current-season-fixture` is separate from the long-lived archive migration. It +replaces the running-season tables of an isolated Core test schema with a Ref +MariaDB season so both implementations can be compared from the same persisted +world. Never run it against a production or shared development schema. + +Start from a cloned Core database whose scenario, year and month already match +the Ref source. Dry-run verifies that contract and reports the planned counts: + +```sh +LEGACY_GAME_DATABASE_URL=... GAME_DATABASE_URL=... \ + pnpm --filter @sammo-ts/legacy-db-migration migrate current-season-fixture \ + --profile hwe --expected-scenario 2601 --expected-year 186 --expected-month 1 +``` + +Applying requires both destructive flags so an ordinary archive command cannot +replace a running season accidentally: + +```sh +LEGACY_GAME_DATABASE_URL=... GAME_DATABASE_URL=... \ + pnpm --filter @sammo-ts/legacy-db-migration migrate current-season-fixture \ + --profile hwe --expected-scenario 2601 --expected-year 186 --expected-month 1 \ + --replace-current-season --apply +``` + +The importer preserves the Core template's static city geometry and connection +metadata, then imports Ref cities, nations, generals, queues, diplomacy, troops, +ranks, messages, logs, events, markets, yearbook rows, current storage values and +world clock in one PostgreSQL transaction. Ref message target keys are converted +to the typed Core message payload. `CURRENT_SEASON_CAPTURE_USER_ID` may bind one +Ref owner selected by `CURRENT_SEASON_CAPTURE_SOURCE_OWNER` to an existing Core +test account; other positive owners receive deterministic legacy UUIDs. + +Process locks, selection tokens, Redis-owned tournament brackets, legacy annual +aggregate text and diplomatic-letter workflow are deliberately excluded and are +listed in the JSON result. This fixture is evidence for persisted-state and GUI +comparison, not proof that the two engines consume RNG identically after the +next turn. + Kakao members retain their OAuth ID, email, and OAuth metadata. `kakao_verified_at` and `kakao_grace_started_at` are set to the migration time. Legacy password hashes and salts are retained and upgraded to Argon2id after diff --git a/tools/legacy-db-migration/src/cli.ts b/tools/legacy-db-migration/src/cli.ts index 85ea39a..69e93dc 100644 --- a/tools/legacy-db-migration/src/cli.ts +++ b/tools/legacy-db-migration/src/cli.ts @@ -8,8 +8,9 @@ import { createMariaPool, createPostgresPool } from './db.js'; import { migrateGame } from './game.js'; import { migrateGateway } from './gateway.js'; import { hashPasswordForReset } from './password.js'; +import { migrateCurrentSeasonFixture } from './currentSeason.js'; -type Command = 'gateway' | 'game' | 'reset-password'; +type Command = 'gateway' | 'game' | 'current-season-fixture' | 'reset-password'; interface CliOptions { command: Command; @@ -17,11 +18,18 @@ interface CliOptions { profile?: string; loginId?: string; passwordFile?: string; + expectedScenario?: number; + expectedYear?: number; + expectedMonth?: number; + replaceCurrentSeason: boolean; } const usage = `Usage: pnpm --filter @sammo-ts/legacy-db-migration migrate gateway [--apply] pnpm --filter @sammo-ts/legacy-db-migration migrate game --profile [--apply] + pnpm --filter @sammo-ts/legacy-db-migration migrate current-season-fixture --profile \ + --expected-scenario --expected-year --expected-month \ + [--replace-current-season --apply] pnpm --filter @sammo-ts/legacy-db-migration migrate reset-password --login-id --password-file --apply Environment: @@ -35,16 +43,25 @@ credentials are not exposed in the process list.`; const parseArguments = (argv: readonly string[]): CliOptions => { const command = argv[0]; - if (command !== 'gateway' && command !== 'game' && command !== 'reset-password') { + if ( + command !== 'gateway' && + command !== 'game' && + command !== 'current-season-fixture' && + command !== 'reset-password' + ) { throw new Error(usage); } - const options: CliOptions = { command, apply: false }; + const options: CliOptions = { command, apply: false, replaceCurrentSeason: false }; for (let index = 1; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--apply') { options.apply = true; continue; } + if (argument === '--replace-current-season') { + options.replaceCurrentSeason = true; + continue; + } const next = argv[index + 1]; if (!next || next.startsWith('--')) { throw new Error(`Missing value for ${argument}\n\n${usage}`); @@ -55,6 +72,12 @@ const parseArguments = (argv: readonly string[]): CliOptions => { options.loginId = next; } else if (argument === '--password-file') { options.passwordFile = next; + } else if (argument === '--expected-scenario') { + options.expectedScenario = Number(next); + } else if (argument === '--expected-year') { + options.expectedYear = Number(next); + } else if (argument === '--expected-month') { + options.expectedMonth = Number(next); } else { throw new Error(`Unknown argument: ${argument}\n\n${usage}`); } @@ -131,11 +154,41 @@ const run = async (): Promise => { } if (!options.profile || !/^[a-z][a-z0-9_-]{1,31}$/.test(options.profile)) { - throw new Error(`game requires a safe --profile value\n\n${usage}`); + throw new Error(`${options.command} requires a safe --profile value\n\n${usage}`); } const source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL')); - const target = options.apply ? createPostgresPool(requireEnvironment('GAME_DATABASE_URL')) : null; + const target = + options.apply || options.command === 'current-season-fixture' + ? createPostgresPool(requireEnvironment('GAME_DATABASE_URL')) + : null; try { + if (options.command === 'current-season-fixture') { + if ( + !Number.isSafeInteger(options.expectedScenario) || + !Number.isSafeInteger(options.expectedYear) || + !Number.isSafeInteger(options.expectedMonth) || + options.expectedMonth! < 1 || + options.expectedMonth! > 12 + ) { + throw new Error( + `current-season-fixture requires valid expected scenario/year/month values\n\n${usage}` + ); + } + if (options.apply && !options.replaceCurrentSeason) { + throw new Error('current-season-fixture --apply also requires --replace-current-season'); + } + const summary = await migrateCurrentSeasonFixture(source, target!, { + apply: options.apply, + profile: options.profile, + expectedScenario: options.expectedScenario!, + expectedYear: options.expectedYear!, + expectedMonth: options.expectedMonth!, + captureUserId: process.env.CURRENT_SEASON_CAPTURE_USER_ID?.trim() || null, + captureSourceOwner: Number(process.env.CURRENT_SEASON_CAPTURE_SOURCE_OWNER ?? 0), + }); + console.log(JSON.stringify(summary, null, 2)); + return; + } const summary = await migrateGame(source, target, options.apply, options.profile); console.log(JSON.stringify(summary, null, 2)); } finally { diff --git a/tools/legacy-db-migration/src/currentSeason.ts b/tools/legacy-db-migration/src/currentSeason.ts new file mode 100644 index 0000000..b62953d --- /dev/null +++ b/tools/legacy-db-migration/src/currentSeason.ts @@ -0,0 +1,916 @@ +import { createHash } from 'node:crypto'; + +import type { Pool as MariaPool } from 'mariadb'; +import type { Pool as PgPool, PoolClient } from 'pg'; + +import { + jsonParameter, + paginateSource, + querySource, + toDate, + toFloat, + toNullableDate, + toNullableString, + toNumber, + toStringValue, + upsertRows, + withMigrationLock, + type SourceRow, + type TargetRow, +} from './db.js'; +import { legacyUserId } from './identity.js'; +import { parseJson, type JsonValue } from './transform.js'; + +const batchSize = 200; + +export interface CurrentSeasonFixtureOptions { + apply: boolean; + profile: string; + expectedScenario: number; + expectedYear: number; + expectedMonth: number; + captureUserId: string | null; + captureSourceOwner: number; +} + +interface CurrentSeasonContract { + scenario: number; + year: number; + month: number; + turnTermMinutes: number; +} + +interface CurrentSeasonSummary { + command: 'current-season-fixture'; + apply: boolean; + profile: string; + sourceContract: CurrentSeasonContract; + targetTemplateContract: CurrentSeasonContract; + counts: Record; + unsupported: Record; +} + +const asObject = (value: JsonValue, context: string): Record => { + if (value === null || Array.isArray(value) || typeof value !== 'object') { + throw new Error(`${context}: expected a JSON object`); + } + return value as Record; +}; + +const jsonObject = (value: unknown, context: string): Record => + asObject(parseJson(value ?? '{}', context), context); + +const jsonObjectOrLegacyEmpty = (value: unknown, context: string): Record => { + const parsed = parseJson(value ?? '{}', context); + if (Array.isArray(parsed) && parsed.length === 0) { + return {}; + } + return asObject(parsed, context); +}; + +const legacyMessageTarget = (value: JsonValue, context: string): Record => { + const target = asObject(value, context); + return { + generalId: toNumber(target.id ?? 0, `${context}.id`), + generalName: toStringValue(target.name ?? '', `${context}.name`), + nationId: toNumber(target.nation_id ?? 0, `${context}.nation_id`), + nationName: toStringValue(target.nation ?? '', `${context}.nation`), + color: toStringValue(target.color ?? '#000000', `${context}.color`), + icon: toStringValue(target.icon ?? '', `${context}.icon`), + }; +}; + +export const transformLegacyMessagePayload = (value: unknown): Record => { + const payload = jsonObject(value, 'message.message'); + const option = payload.option; + return { + src: legacyMessageTarget(payload.src ?? {}, 'message.message.src'), + dest: legacyMessageTarget(payload.dest ?? {}, 'message.message.dest'), + text: toStringValue(payload.text ?? '', 'message.message.text'), + ...(option === undefined || (Array.isArray(option) && option.length === 0) ? {} : { option }), + }; +}; + +const nullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue => { + if (value === null || value === undefined || value === '') { + return fallback; + } + return parseJson(value, context) ?? fallback; +}; + +const nullableNumber = (value: unknown, context: string): number | null => + value === null || value === undefined ? null : toNumber(value, context); + +const booleanValue = (value: unknown): boolean => { + if (Buffer.isBuffer(value)) { + return value.length > 0 && value[0] !== 0; + } + return value === true || value === 1 || value === '1' || value === '\u0001'; +}; + +const ownerUserId = (owner: unknown, options: CurrentSeasonFixtureOptions): string | null => { + const ownerNo = nullableNumber(owner, 'general.owner'); + if (!ownerNo || ownerNo <= 0) { + return null; + } + if (options.captureUserId && ownerNo === options.captureSourceOwner) { + return options.captureUserId; + } + return legacyUserId(ownerNo); +}; + +const readSourceContract = async (source: MariaPool): Promise => { + const rows = await querySource( + source, + `SELECT \`key\`, JSON_UNQUOTE(value) AS value + FROM storage + WHERE namespace = 'game_env' + AND \`key\` IN ('scenario', 'year', 'month', 'turnterm')` + ); + const values = new Map(rows.map((row) => [String(row.key), Number(row.value)])); + return { + scenario: values.get('scenario') ?? Number.NaN, + year: values.get('year') ?? Number.NaN, + month: values.get('month') ?? Number.NaN, + turnTermMinutes: values.get('turnterm') ?? Number.NaN, + }; +}; + +const readTargetContract = async (target: PgPool): Promise => { + const result = await target.query<{ + scenario: string; + year: number; + month: number; + turn_term_minutes: number; + }>( + `SELECT scenario_code AS scenario, + current_year AS year, + current_month AS month, + tick_seconds / 60 AS turn_term_minutes + FROM world_state` + ); + if (result.rowCount !== 1) { + throw new Error('Target template must contain exactly one world_state row'); + } + const row = result.rows[0]!; + return { + scenario: Number(row.scenario), + year: row.year, + month: row.month, + turnTermMinutes: row.turn_term_minutes, + }; +}; + +const assertContract = (contract: CurrentSeasonContract, options: CurrentSeasonFixtureOptions, label: string): void => { + if ( + contract.scenario !== options.expectedScenario || + contract.year !== options.expectedYear || + contract.month !== options.expectedMonth + ) { + throw new Error( + `${label} contract mismatch: expected ${options.expectedScenario}/${options.expectedYear}-${String(options.expectedMonth).padStart(2, '0')}, got ${contract.scenario}/${contract.year}-${String(contract.month).padStart(2, '0')}` + ); + } +}; + +const insertBatches = async ( + client: PoolClient, + table: string, + rows: readonly TargetRow[], + conflictColumns: readonly string[], + counts: Record +): Promise => { + for (let offset = 0; offset < rows.length; offset += batchSize) { + await upsertRows(client, table, rows.slice(offset, offset + batchSize), conflictColumns); + } + counts[table] = (counts[table] ?? 0) + rows.length; +}; + +const migratePaged = async ( + source: MariaPool, + client: PoolClient, + sourceTable: string, + sourceIdColumn: string, + targetTable: string, + conflictColumns: readonly string[], + mapper: (row: SourceRow) => TargetRow, + counts: Record +): Promise => { + for await (const rows of paginateSource(source, sourceTable, sourceIdColumn, batchSize)) { + await insertBatches(client, targetTable, rows.map(mapper), conflictColumns, counts); + } +}; + +export const mapGeneral = (row: SourceRow, options: CurrentSeasonFixtureOptions): TargetRow => { + const id = toNumber(row.no, 'general.no'); + const aux = jsonObjectOrLegacyEmpty(row.aux, `general.${id}.aux`); + const meta: Record = { + ...aux, + owner: nullableNumber(row.owner, `general.${id}.owner`) ?? 0, + owner_name: toNullableString(row.owner_name), + npcmsg: toNullableString(row.npcmsg) ?? '', + npc_org: nullableNumber(row.npc_org, `general.${id}.npc_org`) ?? 0, + newmsg: nullableNumber(row.newmsg, `general.${id}.newmsg`) ?? 0, + leadership_exp: toNumber(row.leadership_exp, `general.${id}.leadership_exp`), + strength_exp: toNumber(row.strength_exp, `general.${id}.strength_exp`), + intel_exp: toNumber(row.intel_exp, `general.${id}.intel_exp`), + dex1: toNumber(row.dex1, `general.${id}.dex1`), + dex2: toNumber(row.dex2, `general.${id}.dex2`), + dex3: toNumber(row.dex3, `general.${id}.dex3`), + dex4: toNumber(row.dex4, `general.${id}.dex4`), + dex5: toNumber(row.dex5, `general.${id}.dex5`), + officer_city: toNumber(row.officer_city, `general.${id}.officer_city`), + permission: toNullableString(row.permission) ?? 'normal', + makelimit: nullableNumber(row.makelimit, `general.${id}.makelimit`) ?? 0, + killturn: nullableNumber(row.killturn, `general.${id}.killturn`) ?? 0, + block: nullableNumber(row.block, `general.${id}.block`) ?? 0, + dedlevel: nullableNumber(row.dedlevel, `general.${id}.dedlevel`) ?? 0, + explevel: nullableNumber(row.explevel, `general.${id}.explevel`) ?? 0, + belong: nullableNumber(row.belong, `general.${id}.belong`) ?? 0, + betray: nullableNumber(row.betray, `general.${id}.betray`) ?? 0, + specage: nullableNumber(row.specage, `general.${id}.specage`) ?? 0, + specage2: nullableNumber(row.specage2, `general.${id}.specage2`) ?? 0, + defence_train: nullableNumber(row.defence_train, `general.${id}.defence_train`) ?? 0, + tnmt: nullableNumber(row.tnmt, `general.${id}.tnmt`) ?? 0, + myset: nullableNumber(row.myset, `general.${id}.myset`) ?? 0, + tournament: nullableNumber(row.tournament, `general.${id}.tournament`) ?? 0, + newvote: nullableNumber(row.newvote, `general.${id}.newvote`) ?? 0, + }; + return { + id, + user_id: ownerUserId(row.owner, options), + name: toStringValue(row.name, `general.${id}.name`), + nation_id: toNumber(row.nation, `general.${id}.nation`), + city_id: toNumber(row.city, `general.${id}.city`), + troop_id: toNumber(row.troop, `general.${id}.troop`), + npc_state: toNumber(row.npc, `general.${id}.npc`), + affinity: nullableNumber(row.affinity, `general.${id}.affinity`), + born_year: nullableNumber(row.bornyear, `general.${id}.bornyear`) ?? 180, + dead_year: nullableNumber(row.deadyear, `general.${id}.deadyear`) ?? 300, + picture: toNullableString(row.picture), + image_server: toNumber(row.imgsvr, `general.${id}.imgsvr`), + leadership: toNumber(row.leadership, `general.${id}.leadership`), + strength: toNumber(row.strength, `general.${id}.strength`), + intel: toNumber(row.intel, `general.${id}.intel`), + injury: toNumber(row.injury, `general.${id}.injury`), + experience: toNumber(row.experience, `general.${id}.experience`), + dedication: toNumber(row.dedication, `general.${id}.dedication`), + officer_level: toNumber(row.officer_level, `general.${id}.officer_level`), + gold: toNumber(row.gold, `general.${id}.gold`), + rice: toNumber(row.rice, `general.${id}.rice`), + crew: toNumber(row.crew, `general.${id}.crew`), + crew_type_id: toNumber(row.crewtype, `general.${id}.crewtype`), + train: toNumber(row.train, `general.${id}.train`), + atmos: toNumber(row.atmos, `general.${id}.atmos`), + weapon_code: toStringValue(row.weapon, `general.${id}.weapon`), + book_code: toStringValue(row.book, `general.${id}.book`), + horse_code: toStringValue(row.horse, `general.${id}.horse`), + item_code: toStringValue(row.item, `general.${id}.item`), + turn_time: toDate(row.turntime, `general.${id}.turntime`), + recent_war_time: toNullableDate(row.recent_war, `general.${id}.recent_war`), + age: nullableNumber(row.age, `general.${id}.age`) ?? 20, + start_age: nullableNumber(row.startage, `general.${id}.startage`) ?? 20, + personal_code: toStringValue(row.personal, `general.${id}.personal`), + special_code: toStringValue(row.special, `general.${id}.special`), + special2_code: toStringValue(row.special2, `general.${id}.special2`), + last_turn: nullableJson(row.last_turn, {}, `general.${id}.last_turn`), + meta, + penalty: nullableJson(row.penalty, {}, `general.${id}.penalty`), + created_at: new Date(0), + updated_at: new Date(0), + }; +}; + +export const mapNation = (row: SourceRow, nationEnv: Record): TargetRow => { + const id = toNumber(row.nation, 'nation.nation'); + const aux = jsonObject(row.aux, `nation.${id}.aux`); + return { + id, + name: toStringValue(row.name, `nation.${id}.name`), + color: toStringValue(row.color, `nation.${id}.color`), + capital_city_id: nullableNumber(row.capital, `nation.${id}.capital`), + chief_general_id: null, + gold: nullableNumber(row.gold, `nation.${id}.gold`) ?? 0, + rice: nullableNumber(row.rice, `nation.${id}.rice`) ?? 0, + tech: toFloat(row.tech ?? 0, `nation.${id}.tech`), + level: nullableNumber(row.level, `nation.${id}.level`) ?? 0, + type_code: toStringValue(row.type, `nation.${id}.type`), + meta: { + ...aux, + ...nationEnv, + capital: nullableNumber(row.capital, `nation.${id}.capital`) ?? 0, + capset: nullableNumber(row.capset, `nation.${id}.capset`) ?? 0, + gennum: nullableNumber(row.gennum, `nation.${id}.gennum`) ?? 0, + bill: nullableNumber(row.bill, `nation.${id}.bill`) ?? 0, + rate: nullableNumber(row.rate, `nation.${id}.rate`) ?? 0, + rate_tmp: nullableNumber(row.rate_tmp, `nation.${id}.rate_tmp`) ?? 0, + secretlimit: nullableNumber(row.secretlimit, `nation.${id}.secretlimit`) ?? 0, + chief_set: nullableNumber(row.chief_set, `nation.${id}.chief_set`) ?? 0, + scout: nullableNumber(row.scout, `nation.${id}.scout`) ?? 0, + war: nullableNumber(row.war, `nation.${id}.war`) ?? 0, + strategic_cmd_limit: nullableNumber(row.strategic_cmd_limit, `nation.${id}.strategic_cmd_limit`) ?? 0, + surlimit: nullableNumber(row.surlimit, `nation.${id}.surlimit`) ?? 0, + power: nullableNumber(row.power, `nation.${id}.power`) ?? 0, + spy: nullableJson(row.spy, {}, `nation.${id}.spy`), + }, + }; +}; + +const hashYearbook = (row: TargetRow): string => + createHash('sha256') + .update(JSON.stringify([row.map, row.nations, row.global_history, row.global_action])) + .digest('hex'); + +const migrateStorageAndWorld = async ( + source: MariaPool, + client: PoolClient, + options: CurrentSeasonFixtureOptions, + counts: Record +): Promise => { + const storage = await querySource(source, 'SELECT * FROM storage ORDER BY id'); + const gameEnv: Record = {}; + const archives: TargetRow[] = []; + for (const row of storage) { + const id = toNumber(row.id, 'storage.id'); + const namespace = toStringValue(row.namespace, `storage.${id}.namespace`); + const key = toStringValue(row.key, `storage.${id}.key`); + const value = parseJson(row.value, `storage.${id}.value`); + archives.push({ source_id: id, namespace, key, value: jsonParameter(value), scope: 'current-season-fixture' }); + if (namespace === 'game_env') { + gameEnv[key] = value; + } + } + await insertBatches(client, 'legacy_game_storage', archives, ['source_id'], counts); + + const latestHistory = await querySource(source, 'SELECT server_id FROM ng_history ORDER BY no DESC LIMIT 1'); + const maxRows = await querySource( + source, + 'SELECT (SELECT COALESCE(MAX(no), 0) FROM general) AS max_general, (SELECT COALESCE(MAX(nation), 0) FROM nation) AS max_nation' + ); + const maxRow = maxRows[0]!; + const dynamicMeta: Record = { + refGameEnv: gameEnv, + serverId: latestHistory[0] + ? toStringValue(latestHistory[0].server_id, 'ng_history.server_id') + : options.profile, + lastGeneralId: toNumber(maxRow.max_general, 'general.max'), + lastNationId: toNumber(maxRow.max_nation, 'nation.max'), + lastBettingId: Number(gameEnv.last_betting_id ?? 0), + opentime: String(gameEnv.opentime ?? ''), + starttime: String(gameEnv.starttime ?? ''), + turntime: String(gameEnv.turntime ?? ''), + develcost: Number(gameEnv.develcost ?? 0), + genius: Number(gameEnv.genius ?? 0), + }; + await client.query( + `UPDATE world_state + SET current_year = $1, + current_month = $2, + tick_seconds = $3, + config = jsonb_set(jsonb_set(config, '{npcMode}', to_jsonb($4::integer), true), '{turnTermMinutes}', to_jsonb($5::integer), true), + meta = meta || $6::jsonb, + updated_at = CURRENT_TIMESTAMP`, + [ + options.expectedYear, + options.expectedMonth, + Number(gameEnv.turnterm) * 60, + Number(gameEnv.npcmode), + Number(gameEnv.turnterm), + JSON.stringify(dynamicMeta), + ] + ); + counts.world_state = 1; +}; + +const replaceCurrentSeason = async ( + source: MariaPool, + client: PoolClient, + options: CurrentSeasonFixtureOptions, + counts: Record +): Promise => { + await client.query('BEGIN'); + try { + const activeLease = await client.query( + `SELECT 1 FROM turn_daemon_lease WHERE lease_until > CURRENT_TIMESTAMP LIMIT 1` + ); + if (activeLease.rowCount) { + throw new Error('Refusing to replace a current season while a turn daemon lease is active'); + } + const targetCityMetaRows = await client.query<{ id: number; meta: JsonValue }>(`SELECT id, meta FROM city`); + const targetCityMeta = new Map(targetCityMetaRows.rows.map((row) => [row.id, row.meta])); + await client.query(` + TRUNCATE TABLE + input_event, turn_daemon_lease, + general_turn_revision, nation_turn_revision, + traffic_period_general, traffic_period, + message_read_state, general_access_log, + general_turn, nation_turn, rank_data, message, log_entry, + event, auction_bid, auction, nation_bet, nation_betting, + diplomacy_letter, diplomacy, troop, + select_npc_token, select_pool, + board_comment, board_post, vote_comment, vote, vote_poll, + legacy_game_storage, yearbook_history, + general, city, nation + RESTART IDENTITY CASCADE + `); + + await migratePaged( + source, + client, + 'city', + 'city', + 'city', + ['id'], + (row) => { + const id = toNumber(row.city, 'city.city'); + const staticMeta = (targetCityMeta.get(id) as Record | undefined) ?? {}; + return { + id, + name: toStringValue(row.name, `city.${id}.name`), + level: toNumber(row.level, `city.${id}.level`), + nation_id: toNumber(row.nation, `city.${id}.nation`), + supply_state: toNumber(row.supply, `city.${id}.supply`), + front_state: toNumber(row.front, `city.${id}.front`), + pop: toNumber(row.pop, `city.${id}.pop`), + pop_max: toNumber(row.pop_max, `city.${id}.pop_max`), + agri: toNumber(row.agri, `city.${id}.agri`), + agri_max: toNumber(row.agri_max, `city.${id}.agri_max`), + comm: toNumber(row.comm, `city.${id}.comm`), + comm_max: toNumber(row.comm_max, `city.${id}.comm_max`), + secu: toNumber(row.secu, `city.${id}.secu`), + secu_max: toNumber(row.secu_max, `city.${id}.secu_max`), + trust: toFloat(row.trust, `city.${id}.trust`), + trade: nullableNumber(row.trade, `city.${id}.trade`), + def: toNumber(row.def, `city.${id}.def`), + def_max: toNumber(row.def_max, `city.${id}.def_max`), + wall: toNumber(row.wall, `city.${id}.wall`), + wall_max: toNumber(row.wall_max, `city.${id}.wall_max`), + region: toNumber(row.region, `city.${id}.region`), + conflict: nullableJson(row.conflict, {}, `city.${id}.conflict`), + meta: { + ...staticMeta, + officer_set: toNumber(row.officer_set, `city.${id}.officer_set`), + state: toNumber(row.state, `city.${id}.state`), + term: toNumber(row.term, `city.${id}.term`), + dead: toNumber(row.dead, `city.${id}.dead`), + trust: toFloat(row.trust, `city.${id}.trust`), + trade: nullableNumber(row.trade, `city.${id}.trade`), + region: toNumber(row.region, `city.${id}.region`), + }, + }; + }, + counts + ); + + const nationEnvRows = await querySource(source, 'SELECT namespace, `key`, value FROM nation_env ORDER BY id'); + const nationEnv = new Map>(); + for (const row of nationEnvRows) { + const nationId = toNumber(row.namespace, 'nation_env.namespace'); + const entry = nationEnv.get(nationId) ?? {}; + entry[toStringValue(row.key, 'nation_env.key')] = parseJson(row.value, 'nation_env.value'); + nationEnv.set(nationId, entry); + } + await migratePaged( + source, + client, + 'nation', + 'nation', + 'nation', + ['id'], + (row) => { + const id = toNumber(row.nation, 'nation.nation'); + return mapNation(row, nationEnv.get(id) ?? {}); + }, + counts + ); + await migratePaged( + source, + client, + 'general', + 'no', + 'general', + ['id'], + (row) => mapGeneral(row, options), + counts + ); + await client.query(` + UPDATE nation n + SET chief_general_id = ( + SELECT g.id + FROM general g + WHERE g.nation_id = n.id AND g.officer_level = 12 + ORDER BY g.id + LIMIT 1 + ) + `); + await migratePaged( + source, + client, + 'troop', + 'troop_leader', + 'troop', + ['troop_leader'], + (row) => ({ + troop_leader: toNumber(row.troop_leader, 'troop.troop_leader'), + nation: toNumber(row.nation, 'troop.nation'), + name: toStringValue(row.name, 'troop.name'), + }), + counts + ); + await migratePaged( + source, + client, + 'diplomacy', + 'no', + 'diplomacy', + ['src_nation_id', 'dest_nation_id'], + (row) => ({ + src_nation_id: toNumber(row.me, 'diplomacy.me'), + dest_nation_id: toNumber(row.you, 'diplomacy.you'), + state_code: nullableNumber(row.state, 'diplomacy.state') ?? 0, + term: nullableNumber(row.term, 'diplomacy.term') ?? 0, + is_dead: (nullableNumber(row.dead, 'diplomacy.dead') ?? 0) !== 0, + is_showing: row.showing !== null, + meta: { + dead: nullableNumber(row.dead, 'diplomacy.dead') ?? 0, + showing: toNullableDate(row.showing, 'diplomacy.showing')?.toISOString() ?? null, + }, + created_at: new Date(0), + }), + counts + ); + await migratePaged( + source, + client, + 'general_turn', + 'id', + 'general_turn', + ['general_id', 'turn_idx'], + (row) => ({ + general_id: toNumber(row.general_id, 'general_turn.general_id'), + turn_idx: toNumber(row.turn_idx, 'general_turn.turn_idx'), + action_code: toStringValue(row.action, 'general_turn.action'), + arg: nullableJson(row.arg, {}, 'general_turn.arg'), + created_at: new Date(0), + }), + counts + ); + await migratePaged( + source, + client, + 'nation_turn', + 'id', + 'nation_turn', + ['nation_id', 'officer_level', 'turn_idx'], + (row) => ({ + nation_id: toNumber(row.nation_id, 'nation_turn.nation_id'), + officer_level: toNumber(row.officer_level, 'nation_turn.officer_level'), + turn_idx: toNumber(row.turn_idx, 'nation_turn.turn_idx'), + action_code: toStringValue(row.action, 'nation_turn.action'), + arg: nullableJson(row.arg, {}, 'nation_turn.arg'), + created_at: new Date(0), + }), + counts + ); + await migratePaged( + source, + client, + 'rank_data', + 'id', + 'rank_data', + ['general_id', 'type'], + (row) => ({ + nation_id: toNumber(row.nation_id, 'rank_data.nation_id'), + general_id: toNumber(row.general_id, 'rank_data.general_id'), + type: toStringValue(row.type, 'rank_data.type'), + value: toNumber(row.value, 'rank_data.value'), + }), + counts + ); + await migratePaged( + source, + client, + 'message', + 'id', + 'message', + ['id'], + (row) => ({ + id: toNumber(row.id, 'message.id'), + mailbox: toNumber(row.mailbox, 'message.mailbox'), + type: toStringValue(row.type, 'message.type'), + src: toNumber(row.src, 'message.src'), + dest: toNumber(row.dest, 'message.dest'), + time: toDate(row.time, 'message.time'), + valid_until: toDate(row.valid_until, 'message.valid_until'), + message: jsonParameter(transformLegacyMessagePayload(row.message)), + }), + counts + ); + await migratePaged( + source, + client, + 'general_access_log', + 'id', + 'general_access_log', + ['general_id'], + (row) => ({ + general_id: toNumber(row.general_id, 'general_access_log.general_id'), + user_id: ownerUserId(row.user_id, options), + last_refresh: toNullableDate(row.last_refresh, 'general_access_log.last_refresh'), + refresh: toNumber(row.refresh, 'general_access_log.refresh'), + refresh_total: toNumber(row.refresh_total, 'general_access_log.refresh_total'), + refresh_score: toNumber(row.refresh_score, 'general_access_log.refresh_score'), + refresh_score_total: toNumber(row.refresh_score_total, 'general_access_log.refresh_score_total'), + }), + counts + ); + await migratePaged( + source, + client, + 'event', + 'id', + 'event', + ['id'], + (row) => ({ + id: toNumber(row.id, 'event.id'), + target_code: toStringValue(row.target, 'event.target').toLowerCase(), + priority: toNumber(row.priority, 'event.priority'), + condition: nullableJson(row.condition, true, 'event.condition'), + action: nullableJson(row.action, [], 'event.action'), + meta: {}, + created_at: new Date(0), + }), + counts + ); + + const bettingRows = await querySource( + source, + `SELECT id, value FROM storage WHERE namespace = 'betting' ORDER BY id` + ); + const betting = bettingRows.map((row) => { + const sourceId = toNumber(row.id, 'storage.betting.id'); + const value = jsonObject(row.value, `storage.betting.${sourceId}`); + return { + id: Number(value.id), + type: String(value.type), + name: String(value.name), + finished: Boolean(value.finished), + select_count: Number(value.selectCnt), + is_exclusive: value.isExclusive === null ? null : Boolean(value.isExclusive), + requires_inheritance_point: Boolean(value.reqInheritancePoint), + open_year_month: Number(value.openYearMonth), + close_year_month: Number(value.closeYearMonth), + candidates: value.candidates ?? [], + winner: value.winner ?? null, + created_at: new Date(0), + updated_at: new Date(0), + }; + }); + await insertBatches(client, 'nation_betting', betting, ['id'], counts); + await migratePaged( + source, + client, + 'ng_betting', + 'id', + 'nation_bet', + ['id'], + (row) => { + const selection = nullableJson(row.betting_type, [], 'ng_betting.betting_type'); + return { + id: toNumber(row.id, 'ng_betting.id'), + betting_id: toNumber(row.betting_id, 'ng_betting.betting_id'), + general_id: toNumber(row.general_id, 'ng_betting.general_id'), + user_id: ownerUserId(row.user_id, options), + selection, + selection_key: JSON.stringify(selection), + amount: toFloat(row.amount, 'ng_betting.amount'), + created_at: new Date(0), + updated_at: new Date(0), + }; + }, + counts + ); + await migratePaged( + source, + client, + 'ng_auction', + 'id', + 'auction', + ['id'], + (row) => { + const detail = jsonObject(row.detail, 'ng_auction.detail'); + const type = toStringValue(row.type, 'ng_auction.type'); + return { + id: toNumber(row.id, 'ng_auction.id'), + type: type === 'buyRice' ? 'BUY_RICE' : type === 'sellRice' ? 'SELL_RICE' : 'UNIQUE_ITEM', + target_code: toNullableString(row.target), + host_general_id: toNumber(row.host_general_id, 'ng_auction.host_general_id'), + host_name: typeof detail.hostName === 'string' ? detail.hostName : null, + detail, + status: booleanValue(row.finished) ? 'FINISHED' : 'OPEN', + close_at: toDate(row.close_date, 'ng_auction.close_date'), + latest_event_id: `ref-auction-${toNumber(row.id, 'ng_auction.id')}`, + latest_event_at: toDate(row.open_date, 'ng_auction.open_date'), + finalizing_at: null, + finished_at: booleanValue(row.finished) ? toDate(row.close_date, 'ng_auction.close_date') : null, + created_at: toDate(row.open_date, 'ng_auction.open_date'), + updated_at: toDate(row.open_date, 'ng_auction.open_date'), + }; + }, + counts + ); + await migratePaged( + source, + client, + 'ng_auction_bid', + 'no', + 'auction_bid', + ['id'], + (row) => ({ + id: toNumber(row.no, 'ng_auction_bid.no'), + auction_id: toNumber(row.auction_id, 'ng_auction_bid.auction_id'), + general_id: toNumber(row.general_id, 'ng_auction_bid.general_id'), + amount: toNumber(row.amount, 'ng_auction_bid.amount'), + event_id: `ref-auction-bid-${toNumber(row.no, 'ng_auction_bid.no')}`, + event_at: toDate(row.date, 'ng_auction_bid.date'), + meta: nullableJson(row.aux, {}, 'ng_auction_bid.aux'), + created_at: toDate(row.date, 'ng_auction_bid.date'), + }), + counts + ); + await migratePaged( + source, + client, + 'ng_history', + 'no', + 'yearbook_history', + ['profile_name', 'year', 'month', 'source_id'], + (row) => { + const id = toNumber(row.no, 'ng_history.no'); + const mapped: TargetRow = { + profile_name: toStringValue(row.server_id, 'ng_history.server_id'), + source_id: id, + year: toNumber(row.year, 'ng_history.year'), + month: toNumber(row.month, 'ng_history.month'), + map: nullableJson(row.map, {}, 'ng_history.map'), + nations: nullableJson(row.nations, [], 'ng_history.nations'), + global_history: nullableJson(row.global_history, [], 'ng_history.global_history'), + global_action: nullableJson(row.global_action, [], 'ng_history.global_action'), + hash: '', + created_at: new Date(0), + }; + mapped.hash = hashYearbook(mapped); + return mapped; + }, + counts + ); + await migrateStorageAndWorld(source, client, options, counts); + + const generalRecordMaxRows = await querySource( + source, + 'SELECT COALESCE(MAX(id), 0) AS max_id FROM general_record' + ); + const worldOffset = toNumber(generalRecordMaxRows[0]!.max_id, 'general_record.max_id'); + await migratePaged( + source, + client, + 'general_record', + 'id', + 'log_entry', + ['id'], + (row) => { + const type = toStringValue(row.log_type, 'general_record.log_type'); + return { + id: toNumber(row.id, 'general_record.id'), + scope: 'GENERAL', + category: + type === 'action' + ? 'ACTION' + : type === 'battle_brief' + ? 'BATTLE_BRIEF' + : type === 'battle' + ? 'BATTLE_DETAIL' + : 'HISTORY', + sub_type: type, + year: toNumber(row.year, 'general_record.year'), + month: toNumber(row.month, 'general_record.month'), + text: toStringValue(row.text, 'general_record.text'), + general_id: toNumber(row.general_id, 'general_record.general_id'), + nation_id: null, + user_id: null, + meta: { source: 'ref.general_record' }, + created_at: new Date(0), + }; + }, + counts + ); + await migratePaged( + source, + client, + 'world_history', + 'id', + 'log_entry', + ['id'], + (row) => ({ + id: worldOffset + toNumber(row.id, 'world_history.id'), + scope: 'NATION', + category: 'HISTORY', + sub_type: 'world_history', + year: toNumber(row.year, 'world_history.year'), + month: toNumber(row.month, 'world_history.month'), + text: toStringValue(row.text, 'world_history.text'), + general_id: null, + nation_id: toNumber(row.nation_id, 'world_history.nation_id'), + user_id: null, + meta: { source: 'ref.world_history', sourceId: toNumber(row.id, 'world_history.id') }, + created_at: new Date(0), + }), + counts + ); + + await client.query(` + SELECT setval(pg_get_serial_sequence('message', 'id'), COALESCE((SELECT MAX(id) FROM message), 1), true); + SELECT setval(pg_get_serial_sequence('log_entry', 'id'), COALESCE((SELECT MAX(id) FROM log_entry), 1), true); + SELECT setval(pg_get_serial_sequence('auction', 'id'), COALESCE((SELECT MAX(id) FROM auction), 1), true); + SELECT setval(pg_get_serial_sequence('auction_bid', 'id'), COALESCE((SELECT MAX(id) FROM auction_bid), 1), true); + SELECT setval(pg_get_serial_sequence('nation_bet', 'id'), COALESCE((SELECT MAX(id) FROM nation_bet), 1), true) + `); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } +}; + +const collectDryRunCounts = async (source: MariaPool): Promise> => { + const tables = [ + 'city', + 'nation', + 'general', + 'troop', + 'diplomacy', + 'general_turn', + 'nation_turn', + 'rank_data', + 'message', + 'general_access_log', + 'event', + 'ng_betting', + 'ng_auction', + 'ng_auction_bid', + 'ng_history', + 'general_record', + 'world_history', + 'storage', + ]; + const counts: Record = {}; + for (const table of tables) { + const rows = await querySource(source, `SELECT COUNT(*) AS count FROM \`${table}\``); + counts[table] = toNumber(rows[0]!.count, `${table}.count`); + } + return counts; +}; + +export const migrateCurrentSeasonFixture = async ( + source: MariaPool, + target: PgPool, + options: CurrentSeasonFixtureOptions +): Promise => { + const sourceContract = await readSourceContract(source); + const targetTemplateContract = await readTargetContract(target); + assertContract(sourceContract, options, 'Source'); + assertContract(targetTemplateContract, options, 'Target template'); + if (sourceContract.turnTermMinutes !== targetTemplateContract.turnTermMinutes) { + throw new Error('Source and target template turn terms differ'); + } + + const counts = options.apply ? {} : await collectDryRunCounts(source); + if (options.apply) { + const client = await target.connect(); + try { + await withMigrationLock(client, `sammo-current-season-fixture-v1:${options.profile}`, async () => { + await replaceCurrentSeason(source, client, options, counts); + }); + } finally { + client.release(); + } + } + + return { + command: 'current-season-fixture', + apply: options.apply, + profile: options.profile, + sourceContract, + targetTemplateContract, + counts, + unsupported: { + plock: 'Runtime lock rows are intentionally not copied.', + reserved_open: 'Legacy process scheduling is not a Core database concept.', + tournament: 'Core tournament brackets are Redis-owned and require a separate fixture.', + select_pool: 'Selection reservations are ephemeral and intentionally not copied.', + select_npc_token: 'Selection tokens are ephemeral and intentionally not copied.', + statistic: 'Ref annual aggregate text has no lossless current Core table; source rows remain in Ref.', + ng_diplomacy: 'Legacy diplomatic letters need a semantic state conversion before mutation is safe.', + }, + }; +}; diff --git a/tools/legacy-db-migration/src/db.ts b/tools/legacy-db-migration/src/db.ts index abbd151..f969e5e 100644 --- a/tools/legacy-db-migration/src/db.ts +++ b/tools/legacy-db-migration/src/db.ts @@ -26,12 +26,20 @@ const quoteIdentifier = (value: string): string => { export const createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri); -export const createPostgresPool = (connectionString: string): pg.Pool => - new pg.Pool({ - connectionString, +export const createPostgresPool = (connectionString: string): pg.Pool => { + const url = new URL(connectionString); + const schema = url.searchParams.get('schema'); + if (schema && !IDENTIFIER.test(schema)) { + throw new Error(`Unsafe PostgreSQL schema: ${schema}`); + } + url.searchParams.delete('schema'); + return new pg.Pool({ + connectionString: url.toString(), max: 2, application_name: 'sammo-legacy-db-migration', + ...(schema ? { options: `-c search_path=${schema}` } : {}), }); +}; const isSourceRow = (value: unknown): value is SourceRow => value !== null && !Array.isArray(value) && typeof value === 'object'; diff --git a/tools/legacy-db-migration/test/currentSeason.test.ts b/tools/legacy-db-migration/test/currentSeason.test.ts new file mode 100644 index 0000000..aaad9d6 --- /dev/null +++ b/tools/legacy-db-migration/test/currentSeason.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { transformLegacyMessagePayload } from '../src/currentSeason.js'; + +describe('transformLegacyMessagePayload', () => { + it('renames legacy target fields while preserving the message', () => { + expect( + transformLegacyMessagePayload( + JSON.stringify({ + src: { + id: 44, + name: '⭕곽i사', + nation_id: 42, + nation: '주네핑', + color: '#ffffff', + icon: '/image/icons/default.jpg', + }, + dest: { + id: 0, + name: '', + nation_id: 0, + nation: 'System', + color: '#000000', + icon: '/image/icons/default.jpg', + }, + text: '테스트 메시지', + option: [], + }) + ) + ).toEqual({ + src: { + generalId: 44, + generalName: '⭕곽i사', + nationId: 42, + nationName: '주네핑', + color: '#ffffff', + icon: '/image/icons/default.jpg', + }, + dest: { + generalId: 0, + generalName: '', + nationId: 0, + nationName: 'System', + color: '#000000', + icon: '/image/icons/default.jpg', + }, + text: '테스트 메시지', + }); + }); +});