merge: Ref current-season comparison fixture

This commit is contained in:
2026-08-02 15:41:20 +00:00
7 changed files with 1119 additions and 8 deletions
@@ -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);
});
});
+24
View File
@@ -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
+40
View File
@@ -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
+58 -5
View File
@@ -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 <profile> [--apply]
pnpm --filter @sammo-ts/legacy-db-migration migrate current-season-fixture --profile <profile> \
--expected-scenario <id> --expected-year <year> --expected-month <month> \
[--replace-current-season --apply]
pnpm --filter @sammo-ts/legacy-db-migration migrate reset-password --login-id <id> --password-file <path> --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<void> => {
}
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 {
@@ -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<string, number>;
unsupported: Record<string, string>;
}
const asObject = (value: JsonValue, context: string): Record<string, JsonValue> => {
if (value === null || Array.isArray(value) || typeof value !== 'object') {
throw new Error(`${context}: expected a JSON object`);
}
return value as Record<string, JsonValue>;
};
const jsonObject = (value: unknown, context: string): Record<string, JsonValue> =>
asObject(parseJson(value ?? '{}', context), context);
const jsonObjectOrLegacyEmpty = (value: unknown, context: string): Record<string, JsonValue> => {
const parsed = parseJson(value ?? '{}', context);
if (Array.isArray(parsed) && parsed.length === 0) {
return {};
}
return asObject(parsed, context);
};
const legacyMessageTarget = (value: JsonValue, context: string): Record<string, JsonValue> => {
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<string, JsonValue> => {
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<CurrentSeasonContract> => {
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<CurrentSeasonContract> => {
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<string, number>
): Promise<void> => {
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<string, number>
): Promise<void> => {
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<string, JsonValue> = {
...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<string, JsonValue>): 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<string, number>
): Promise<void> => {
const storage = await querySource(source, 'SELECT * FROM storage ORDER BY id');
const gameEnv: Record<string, JsonValue> = {};
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<string, JsonValue> = {
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<string, number>
): Promise<void> => {
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<string, JsonValue> | 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<number, Record<string, JsonValue>>();
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<Record<string, number>> => {
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<string, number> = {};
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<CurrentSeasonSummary> => {
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.',
},
};
};
+11 -3
View File
@@ -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';
@@ -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: '테스트 메시지',
});
});
});