From fa9cee36cf8a6df86dd09ad4b76d7e7badc2f05c Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 07:40:45 +0000 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=20outbox=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EC=8B=9C=EA=B0=81=EC=9D=84=20UTC=20wall?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KST 세션에서 ReadModel과 WebPush outbox의 producer provenance를 분리하고 due, lease, retention 비교를 UTC wall 계약으로 맞춘다. 기존 TIMESTAMP(3) 타입과 이전 DML 형태를 유지하면서 populated upgrade, pending requeue, delivered 보존과 두 번째 deploy no-op을 자동 검증한다. --- .../src/services/webPushOutboxWorker.ts | 9 +- app/gateway-api/test/releaseManifest.test.ts | 2 +- packages/infra/package.json | 1 + packages/infra/prisma/game.prisma | 16 +- .../migration.sql | 40 ++ packages/infra/prisma/migrations/README.md | 22 +- .../verify-game-outbox-utc-wall-migration.sh | 596 ++++++++++++++++++ ...dModelOutboxDispatcher.integration.test.ts | 92 ++- release-manifest.json | 2 +- 9 files changed, 765 insertions(+), 15 deletions(-) create mode 100644 packages/infra/prisma/migrations/20260824070000_game_outbox_utc_wall_timestamps/migration.sql create mode 100755 packages/infra/scripts/verify-game-outbox-utc-wall-migration.sh diff --git a/app/game-api/src/services/webPushOutboxWorker.ts b/app/game-api/src/services/webPushOutboxWorker.ts index 89d837d4..c2cc6929 100644 --- a/app/game-api/src/services/webPushOutboxWorker.ts +++ b/app/game-api/src/services/webPushOutboxWorker.ts @@ -44,8 +44,11 @@ export class WebPushOutboxWorker { SELECT "id" FROM "web_push_outbox" WHERE "delivered_at" IS NULL - AND "available_at" <= CURRENT_TIMESTAMP - AND ("locked_at" IS NULL OR "locked_at" <= CURRENT_TIMESTAMP - INTERVAL '30 seconds') + AND "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + AND ( + "locked_at" IS NULL + OR "locked_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '30 seconds' + ) ORDER BY "id" FOR UPDATE SKIP LOCKED LIMIT 50 @@ -116,7 +119,7 @@ export class WebPushOutboxWorker { WITH expired AS ( SELECT "id" FROM "web_push_outbox" - WHERE "delivered_at" < CURRENT_TIMESTAMP - INTERVAL '1 day' + WHERE "delivered_at" < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 day' ORDER BY "id" LIMIT 500 ) diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index ba261de8..021a96e1 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -39,7 +39,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20260823010000_add_web_push_notifications', - gameSchemaHead: '20260823010000_add_web_push_outbox', + gameSchemaHead: '20260824070000_game_outbox_utc_wall_timestamps', }); }); diff --git a/packages/infra/package.json b/packages/infra/package.json index 434099ca..669d8503 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -21,6 +21,7 @@ "verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh", "verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh", "verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh", + "verify:migration:outbox-utc": "sh scripts/verify-game-outbox-utc-wall-migration.sh", "coverage:activate:game": "node scripts/activate-read-model-coverage.mjs", "prisma:db:push:game": "prisma db push --schema prisma/game.prisma", "prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma" diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 06d8873c..791db1ca 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -97,12 +97,12 @@ model ReadModelOutbox { id BigInt @id @default(autoincrement()) payload Json attempts Int @default(0) - availableAt DateTime @default(now()) @map("available_at") - lockedAt DateTime? @map("locked_at") + availableAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("available_at") @db.Timestamp(3) + lockedAt DateTime? @map("locked_at") @db.Timestamp(3) lockOwner String? @map("lock_owner") - deliveredAt DateTime? @map("delivered_at") + deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3) lastError String? @map("last_error") - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("created_at") @db.Timestamp(3) @@index([deliveredAt, availableAt, id], map: "read_model_outbox_delivered_at_available_at_id_idx") @@map("read_model_outbox") @@ -116,12 +116,12 @@ model WebPushOutbox { year Int? month Int? attempts Int @default(0) - availableAt DateTime @default(now()) @map("available_at") - lockedAt DateTime? @map("locked_at") + availableAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("available_at") @db.Timestamp(3) + lockedAt DateTime? @map("locked_at") @db.Timestamp(3) lockOwner String? @map("lock_owner") - deliveredAt DateTime? @map("delivered_at") + deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3) lastError String? @map("last_error") - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("created_at") @db.Timestamp(3) @@index([deliveredAt, availableAt, id], map: "web_push_outbox_delivered_at_available_at_id_idx") @@map("web_push_outbox") diff --git a/packages/infra/prisma/migrations/20260824070000_game_outbox_utc_wall_timestamps/migration.sql b/packages/infra/prisma/migrations/20260824070000_game_outbox_utc_wall_timestamps/migration.sql new file mode 100644 index 00000000..e95f5be5 --- /dev/null +++ b/packages/infra/prisma/migrations/20260824070000_game_outbox_utc_wall_timestamps/migration.sql @@ -0,0 +1,40 @@ +-- The game schema contains legacy wall-clock DateTime fields, so its shared +-- connection pool cannot be changed to UTC wholesale. Keep the two operational +-- outboxes rollback-compatible as TIMESTAMP(3), but make their scheduling and +-- event-time contract explicitly UTC wall time. +BEGIN; + +-- Read-model journal inserts omit both timestamp columns and therefore used the +-- database session wall clock. Normalize that single-provenance history to UTC +-- wall time. Web Push createMany already wrote JavaScript UTC fields, so its +-- created_at values must remain byte-for-byte unchanged. +UPDATE "read_model_outbox" +SET "created_at" = ("created_at" AT TIME ZONE current_setting('TimeZone')) AT TIME ZONE 'UTC'; + +-- Both outboxes are at-least-once. Release stale leases and make every pending +-- row immediately eligible so mixed historical available_at provenance cannot +-- strand it. Read-model replay is a tolerated duplicate invalidation; Web Push +-- replay is deduplicated by its stable Gateway event ID. +UPDATE "read_model_outbox" +SET + "available_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + "locked_at" = NULL, + "lock_owner" = NULL +WHERE "delivered_at" IS NULL; + +UPDATE "web_push_outbox" +SET + "available_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + "locked_at" = NULL, + "lock_owner" = NULL +WHERE "delivered_at" IS NULL; + +ALTER TABLE "read_model_outbox" + ALTER COLUMN "available_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); + +ALTER TABLE "web_push_outbox" + ALTER COLUMN "available_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); + +COMMIT; diff --git a/packages/infra/prisma/migrations/README.md b/packages/infra/prisma/migrations/README.md index e3549f19..6af16e99 100644 --- a/packages/infra/prisma/migrations/README.md +++ b/packages/infra/prisma/migrations/README.md @@ -27,7 +27,11 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여 - `world_state`, `nation`, `city`, `general`, `message`, `troop` - `general_turn`, `nation_turn`과 revision·lease field - `input_event`, `turn_daemon_lease` -- `read_model_revision`, `read_model_outbox`, `read_model_revision_meta` +- `read_model_revision`, `read_model_outbox`, `read_model_revision_meta`, `web_push_outbox` +- 두 outbox의 `available_at`, `locked_at`, `delivered_at`, `created_at`은 + millisecond 정밀도 `timestamp without time zone`을 유지한다. 이 migration + 이후 신규 값과 pending/dispatcher 운영 계약은 UTC wall 값으로 통일하되, + 이미 전달된 과거 행의 표시용 시각 전체를 일괄 재해석하지 않는다. - `read_model_revision_meta.id=1`의 `coverage_version=0` - `diplomacy`, `event`, `log_entry`, `error_log` - auction, board, vote, yearbook, archive와 inheritance table @@ -42,6 +46,22 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여 검증이 끝나면 이름을 직접 확인한 임시 database와 role만 제거합니다. 공유 database나 Compose volume을 삭제하지 않습니다. +## Game outbox UTC-wall populated upgrade 검증 + +Git에서 제외된 전용 PostgreSQL URL을 주입해 target 직전 migration chain부터 +실제 data upgrade와 두 번째 deploy no-op까지 검증합니다. + +```sh +GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL=... \ + pnpm --filter @sammo-ts/infra verify:migration:outbox-utc +``` + +검증기는 실행별 소유권 comment가 있는 schema만 만들고 정리합니다. KST DB +default로 생성된 ReadModel `created_at`의 UTC-wall 변환, 기존 JavaScript UTC +WebPush `created_at` 보존, 두 pending outbox의 requeue·lease 해제, delivered 행 +보존, target checksum과 이전 DML shape 호환성을 확인합니다. 이전 binary를 별도 +build해 실행하거나 down migration을 제공한다는 뜻은 아닙니다. + ## NPC selection 중복 owner preflight `20260731000000_add_npc_selection_token`은 `general.user_id` 중복을 발견하면 diff --git a/packages/infra/scripts/verify-game-outbox-utc-wall-migration.sh b/packages/infra/scripts/verify-game-outbox-utc-wall-migration.sh new file mode 100755 index 00000000..f3fa827a --- /dev/null +++ b/packages/infra/scripts/verify-game-outbox-utc-wall-migration.sh @@ -0,0 +1,596 @@ +#!/bin/sh +set -eu + +: "${GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL:?GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL is required}" + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +package_dir=$(dirname "$script_dir") +prisma_dir="$package_dir/prisma" +target_migration=20260824070000_game_outbox_utc_wall_timestamps +run_id=$(date -u +%m%d%H%M%S)_$$ +schema_name="game_outbox_utc_upgrade_$run_id" +ownership_token="sammo-game-outbox-utc-migration:$run_id" +work_dir=$(mktemp -d "$package_dir/.game-outbox-utc-migration.XXXXXX") + +case "$schema_name" in + game_outbox_utc_upgrade_[0-9]*_[0-9]*) ;; + *) + echo "unsafe outbox migration schema name" >&2 + exit 64 + ;; +esac + +cleanup() { + cleanup_status=0 + OWNERSHIP_TOKEN=$ownership_token \ + SCHEMA_NAME=$schema_name \ + DATABASE_URL=$GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL \ + pnpm --dir "$package_dir" exec node --input-type=module -e ' + import pg from "pg"; + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`; + await client.connect(); + try { + const ownership = await client.query( + "SELECT obj_description(oid, $$pg_namespace$$) AS owner FROM pg_namespace WHERE nspname = $1", + [process.env.SCHEMA_NAME] + ); + if (ownership.rowCount > 0) { + if (ownership.rows[0]?.owner !== process.env.OWNERSHIP_TOKEN) { + throw new Error(`refusing to drop unowned schema: ${process.env.SCHEMA_NAME}`); + } + await client.query(`DROP SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)} CASCADE`); + } + } finally { + await client.end(); + } + ' >/dev/null 2>&1 || cleanup_status=1 + case "$work_dir" in + "$package_dir"/.game-outbox-utc-migration.*) + rm -r -- "$work_dir" || cleanup_status=1 + ;; + *) + echo "refusing to remove unsafe outbox migration work directory: $work_dir" >&2 + cleanup_status=1 + ;; + esac + return "$cleanup_status" +} + +handle_exit() { + exit_status=$? + trap - EXIT HUP INT TERM + if ! cleanup && [ "$exit_status" -eq 0 ]; then + exit_status=1 + fi + exit "$exit_status" +} + +trap handle_exit EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +[ -d "$prisma_dir/migrations/$target_migration" ] || { + echo "target migration is missing: $target_migration" >&2 + exit 66 +} + +build_database_url() { + SCHEMA_NAME=$schema_name DATABASE_URL=$GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL \ + pnpm --dir "$package_dir" exec node --input-type=module -e ' + const url = new URL(process.env.DATABASE_URL); + url.searchParams.set("schema", process.env.SCHEMA_NAME); + // The migration must reinterpret the predecessor DB-default timestamps + // from the real legacy KST session contract, independently of host TZ. + url.searchParams.set("options", "-c TimeZone=Asia/Seoul"); + process.stdout.write(url.href); + ' +} + +database_url=$(build_database_url) + +OWNERSHIP_TOKEN=$ownership_token \ + SCHEMA_NAME=$schema_name \ + DATABASE_URL=$GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL \ + pnpm --dir "$package_dir" exec node --input-type=module -e ' + import pg from "pg"; + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`; + const apostrophe = String.fromCharCode(39); + const quoteLiteral = (value) => + `${apostrophe}${value.replaceAll(apostrophe, apostrophe.repeat(2))}${apostrophe}`; + await client.connect(); + try { + await client.query("BEGIN"); + await client.query(`CREATE SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)}`); + await client.query( + `COMMENT ON SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)} IS ${quoteLiteral( + process.env.OWNERSHIP_TOKEN + )}` + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + await client.end(); + } + ' + +stage_prisma="$work_dir/prisma" +mkdir -p "$stage_prisma/migrations" +cp "$prisma_dir/game.prisma" "$stage_prisma/game.prisma" +found_target=0 +for migration_dir in "$prisma_dir"/migrations/[0-9]*; do + migration_name=$(basename "$migration_dir") + if [ "$migration_name" = "$target_migration" ]; then + found_target=1 + break + fi + cp -R "$migration_dir" "$stage_prisma/migrations/$migration_name" +done +[ "$found_target" -eq 1 ] || { + echo "target migration was not found in migration order" >&2 + exit 1 +} + +cd "$package_dir" +if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$stage_prisma/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$stage_prisma/game.prisma" \ + >"$work_dir/predecessor-deploy.log" 2>&1; then + echo "failed to deploy the predecessor game migration chain" >&2 + exit 1 +fi + +SCHEMA_NAME=$schema_name DATABASE_URL=$database_url \ + pnpm exec node --input-type=module -e ' + import pg from "pg"; + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`; + await client.connect(); + try { + const timezone = await client.query("SHOW TimeZone"); + if (timezone.rows[0]?.TimeZone !== "Asia/Seoul") { + throw new Error(`expected Asia/Seoul fixture session, received ${timezone.rows[0]?.TimeZone}`); + } + await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`); + await client.query("BEGIN"); + await client.query(` + CREATE TABLE "_game_outbox_utc_upgrade_probe" ( + "id" INTEGER PRIMARY KEY, + "before_utc" TIMESTAMP(3) NOT NULL, + "read_pending_created_before" TIMESTAMP(3) + ) + `); + await client.query(` + INSERT INTO "_game_outbox_utc_upgrade_probe" ("id", "before_utc") + VALUES (1, CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + `); + await client.query(` + INSERT INTO "read_model_outbox" ( + "id", "payload", "attempts", "available_at", "locked_at", + "lock_owner", "delivered_at", "last_error", "created_at" + ) VALUES + ( + 910001, + $json$ {"version":1,"changes":[["general",101,"2"]],"fixture":"read-pending"} $json$::jsonb, + 2, + TIMESTAMP $$2030-01-02 03:04:05.111$$, + TIMESTAMP $$2026-08-24 16:01:02.222$$, + $$legacy-read-worker$$, + NULL, + $$read retry$$, + DEFAULT + ), + ( + 910002, + $json$ {"version":1,"changes":[["nation",7,"3"]],"fixture":"read-delivered"} $json$::jsonb, + 3, + TIMESTAMP $$2026-08-24 15:00:00.333$$, + TIMESTAMP $$2026-08-24 15:01:00.444$$, + $$delivered-read-worker$$, + TIMESTAMP $$2026-08-24 15:02:00.555$$, + $$delivered read marker$$, + TIMESTAMP $$2026-08-24 16:10:00.789$$ + ) + `); + await client.query(` + INSERT INTO "web_push_outbox" ( + "id", "event_id", "event_type", "user_ids", "year", "month", + "attempts", "available_at", "locked_at", "lock_owner", + "delivered_at", "last_error", "created_at" + ) VALUES + ( + 920001, + $$upgrade-web-pending$$, + $$PRIVATE_MESSAGE_RECEIVED$$, + ARRAY[$$user-b$$, $$user-a$$], + 201, + 7, + 4, + TIMESTAMP $$2030-02-03 04:05:06.222$$, + TIMESTAMP $$2026-08-24 07:02:03.333$$, + $$legacy-web-worker$$, + NULL, + $$web retry$$, + TIMESTAMP $$2026-08-24 07:00:00.456$$ + ), + ( + 920002, + $$upgrade-web-delivered$$, + $$MONTH_CHANGED$$, + ARRAY[$$user-c$$], + 202, + 8, + 5, + TIMESTAMP $$2026-08-24 07:10:00.333$$, + TIMESTAMP $$2026-08-24 07:11:00.444$$, + $$delivered-web-worker$$, + TIMESTAMP $$2026-08-24 07:12:00.555$$, + $$delivered web marker$$, + TIMESTAMP $$2026-08-24 07:09:00.789$$ + ) + `); + await client.query(` + UPDATE "_game_outbox_utc_upgrade_probe" + SET "read_pending_created_before" = ( + SELECT "created_at" FROM "read_model_outbox" WHERE "id" = 910001 + ) + WHERE "id" = 1 + `); + const readProvenance = await client.query(` + SELECT read."created_at" = probe."before_utc" + INTERVAL $$9 hours$$ AS "isKstDefault" + FROM "read_model_outbox" read + CROSS JOIN "_game_outbox_utc_upgrade_probe" probe + WHERE read."id" = 910001 + `); + if (readProvenance.rows[0]?.isKstDefault !== true) { + throw new Error("predecessor read-model created_at did not use the KST database default"); + } + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + await client.end(); + } + ' + +if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" \ + >"$work_dir/incremental-deploy.log" 2>&1; then + echo "failed to deploy the outbox UTC-wall migration" >&2 + exit 1 +fi + +SCHEMA_NAME=$schema_name \ + TARGET_MIGRATION=$target_migration \ + MIGRATION_FILE="$prisma_dir/migrations/$target_migration/migration.sql" \ + DATABASE_URL=$database_url \ + pnpm exec node --input-type=module -e ' + import { createHash } from "node:crypto"; + import { readFile } from "node:fs/promises"; + import pg from "pg"; + + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`; + const canonicalize = (value) => { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalize(entry)]) + ); + } + return value; + }; + const assertEqual = (actual, expected, label) => { + if (JSON.stringify(canonicalize(actual)) !== JSON.stringify(canonicalize(expected))) { + throw new Error(`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`); + } + }; + + await client.connect(); + try { + await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`); + + const readRows = await client.query(` + SELECT + "read_model_outbox"."id"::int AS "id", + "payload", + "attempts", + to_char("available_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "availableAt", + CASE WHEN "locked_at" IS NULL THEN NULL + ELSE to_char("locked_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "lockedAt", + "lock_owner" AS "lockOwner", + CASE WHEN "delivered_at" IS NULL THEN NULL + ELSE to_char("delivered_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "deliveredAt", + "last_error" AS "lastError", + to_char("created_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "createdAt", + "created_at" = ( + probe."read_pending_created_before" AT TIME ZONE $$Asia/Seoul$$ + ) AT TIME ZONE $$UTC$$ AS "createdNormalized", + "available_at" >= probe."before_utc" - INTERVAL $$1 second$$ + AND "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ + AS "availableInMigrationWindow" + FROM "read_model_outbox" + CROSS JOIN "_game_outbox_utc_upgrade_probe" probe + WHERE "read_model_outbox"."id" IN (910001, 910002) + ORDER BY "read_model_outbox"."id" + `); + const [readPending, readDelivered] = readRows.rows; + if (!readPending || !readDelivered) throw new Error("read-model upgrade fixtures are missing"); + assertEqual( + { + id: readPending.id, + payload: readPending.payload, + attempts: readPending.attempts, + lockedAt: readPending.lockedAt, + lockOwner: readPending.lockOwner, + deliveredAt: readPending.deliveredAt, + lastError: readPending.lastError, + createdNormalized: readPending.createdNormalized, + availableInMigrationWindow: readPending.availableInMigrationWindow, + }, + { + id: 910001, + payload: { version: 1, changes: [["general", 101, "2"]], fixture: "read-pending" }, + attempts: 2, + lockedAt: null, + lockOwner: null, + deliveredAt: null, + lastError: "read retry", + createdNormalized: true, + availableInMigrationWindow: true, + }, + "pending read-model row" + ); + assertEqual( + { + id: readDelivered.id, + payload: readDelivered.payload, + attempts: readDelivered.attempts, + availableAt: readDelivered.availableAt, + lockedAt: readDelivered.lockedAt, + lockOwner: readDelivered.lockOwner, + deliveredAt: readDelivered.deliveredAt, + lastError: readDelivered.lastError, + createdAt: readDelivered.createdAt, + }, + { + id: 910002, + payload: { version: 1, changes: [["nation", 7, "3"]], fixture: "read-delivered" }, + attempts: 3, + availableAt: "2026-08-24 15:00:00.333", + lockedAt: "2026-08-24 15:01:00.444", + lockOwner: "delivered-read-worker", + deliveredAt: "2026-08-24 15:02:00.555", + lastError: "delivered read marker", + createdAt: "2026-08-24 07:10:00.789", + }, + "delivered read-model row" + ); + + const webRows = await client.query(` + SELECT + "web_push_outbox"."id"::int AS "id", + "event_id" AS "eventId", + "event_type" AS "eventType", + "user_ids" AS "userIds", + "year", + "month", + "attempts", + to_char("available_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "availableAt", + CASE WHEN "locked_at" IS NULL THEN NULL + ELSE to_char("locked_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "lockedAt", + "lock_owner" AS "lockOwner", + CASE WHEN "delivered_at" IS NULL THEN NULL + ELSE to_char("delivered_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "deliveredAt", + "last_error" AS "lastError", + to_char("created_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "createdAt", + "available_at" >= probe."before_utc" - INTERVAL $$1 second$$ + AND "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ + AS "availableInMigrationWindow" + FROM "web_push_outbox" + CROSS JOIN "_game_outbox_utc_upgrade_probe" probe + WHERE "web_push_outbox"."id" IN (920001, 920002) + ORDER BY "web_push_outbox"."id" + `); + const [webPending, webDelivered] = webRows.rows; + if (!webPending || !webDelivered) throw new Error("web-push upgrade fixtures are missing"); + assertEqual( + { + id: webPending.id, + eventId: webPending.eventId, + eventType: webPending.eventType, + userIds: webPending.userIds, + year: webPending.year, + month: webPending.month, + attempts: webPending.attempts, + lockedAt: webPending.lockedAt, + lockOwner: webPending.lockOwner, + deliveredAt: webPending.deliveredAt, + lastError: webPending.lastError, + createdAt: webPending.createdAt, + availableInMigrationWindow: webPending.availableInMigrationWindow, + }, + { + id: 920001, + eventId: "upgrade-web-pending", + eventType: "PRIVATE_MESSAGE_RECEIVED", + userIds: ["user-b", "user-a"], + year: 201, + month: 7, + attempts: 4, + lockedAt: null, + lockOwner: null, + deliveredAt: null, + lastError: "web retry", + createdAt: "2026-08-24 07:00:00.456", + availableInMigrationWindow: true, + }, + "pending web-push row" + ); + assertEqual( + { + id: webDelivered.id, + eventId: webDelivered.eventId, + eventType: webDelivered.eventType, + userIds: webDelivered.userIds, + year: webDelivered.year, + month: webDelivered.month, + attempts: webDelivered.attempts, + availableAt: webDelivered.availableAt, + lockedAt: webDelivered.lockedAt, + lockOwner: webDelivered.lockOwner, + deliveredAt: webDelivered.deliveredAt, + lastError: webDelivered.lastError, + createdAt: webDelivered.createdAt, + }, + { + id: 920002, + eventId: "upgrade-web-delivered", + eventType: "MONTH_CHANGED", + userIds: ["user-c"], + year: 202, + month: 8, + attempts: 5, + availableAt: "2026-08-24 07:10:00.333", + lockedAt: "2026-08-24 07:11:00.444", + lockOwner: "delivered-web-worker", + deliveredAt: "2026-08-24 07:12:00.555", + lastError: "delivered web marker", + createdAt: "2026-08-24 07:09:00.789", + }, + "delivered web-push row" + ); + + const columns = await client.query(` + SELECT table_name AS "tableName", column_name AS "columnName", + data_type AS "dataType", datetime_precision AS "precision" + FROM information_schema.columns + WHERE table_schema = $1 + AND table_name IN ($$read_model_outbox$$, $$web_push_outbox$$) + AND column_name IN ($$available_at$$, $$locked_at$$, $$delivered_at$$, $$created_at$$) + ORDER BY table_name, column_name + `, [process.env.SCHEMA_NAME]); + if (columns.rowCount !== 8) throw new Error(`expected eight outbox timestamp columns, received ${columns.rowCount}`); + for (const column of columns.rows) { + if (column.dataType !== "timestamp without time zone" || column.precision !== 3) { + throw new Error(`rollback-incompatible timestamp column: ${JSON.stringify(column)}`); + } + } + + const expectedChecksum = createHash("sha256") + .update(await readFile(process.env.MIGRATION_FILE)) + .digest("hex"); + const history = await client.query(` + SELECT checksum, finished_at IS NOT NULL AS finished, + rolled_back_at IS NULL AS "notRolledBack", applied_steps_count AS steps + FROM "_prisma_migrations" + WHERE migration_name = $1 + `, [process.env.TARGET_MIGRATION]); + assertEqual(history.rows, [{ checksum: expectedChecksum, finished: true, notRolledBack: true, steps: 1 }], "migration history"); + + // This is the rollback-compatibility boundary: the predecessor runtime + // DML shapes and TIMESTAMP(3) mappings remain accepted. It deliberately + // does not claim a down migration or execute a separately built old binary. + const rollbackProbe = await client.query(` + WITH inserted_read AS ( + INSERT INTO "read_model_outbox" ("payload") + VALUES ($json$ {"version":1,"changes":[],"fixture":"old-read-shape"} $json$::jsonb) + RETURNING "created_at", "available_at" + ), inserted_web AS ( + INSERT INTO "web_push_outbox" ( + "event_id", "event_type", "user_ids", "available_at", "created_at" + ) VALUES ( + $$upgrade-old-web-shape$$, + $$PRIVATE_MESSAGE_RECEIVED$$, + ARRAY[]::TEXT[], + CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$, + CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$ + ) + RETURNING "created_at", "available_at" + ) + SELECT + inserted_read."created_at" BETWEEN + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "readCreatedUtc", + inserted_read."available_at" BETWEEN + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "readAvailableUtc", + inserted_web."created_at" BETWEEN + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "webCreatedUtc", + inserted_web."available_at" BETWEEN + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "webAvailableUtc" + FROM inserted_read CROSS JOIN inserted_web + `); + assertEqual( + rollbackProbe.rows, + [{ readCreatedUtc: true, readAvailableUtc: true, webCreatedUtc: true, webAvailableUtc: true }], + "predecessor runtime DML compatibility" + ); + } finally { + await client.end(); + } + ' + +if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" \ + >"$work_dir/noop-deploy.log" 2>&1; then + echo "second outbox UTC-wall migration deploy failed" >&2 + exit 1 +fi +grep -Fq 'No pending migrations to apply' "$work_dir/noop-deploy.log" + +if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \ + pnpm exec prisma migrate status --schema "$prisma_dir/game.prisma" \ + >"$work_dir/status.log" 2>&1; then + echo "outbox UTC-wall migration status is not clean" >&2 + exit 1 +fi +grep -Fq 'Database schema is up to date' "$work_dir/status.log" + +SCHEMA_NAME=$schema_name \ + TARGET_MIGRATION=$target_migration \ + MIGRATION_FILE="$prisma_dir/migrations/$target_migration/migration.sql" \ + DATABASE_URL=$database_url \ + pnpm exec node --input-type=module -e ' + import { createHash } from "node:crypto"; + import { readFile } from "node:fs/promises"; + import pg from "pg"; + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + try { + const expectedChecksum = createHash("sha256") + .update(await readFile(process.env.MIGRATION_FILE)) + .digest("hex"); + const result = await client.query(` + SELECT count(*)::int AS count, + bool_and(checksum = $2) AS "checksumMatches", + bool_and(finished_at IS NOT NULL) AS finished, + bool_and(rolled_back_at IS NULL) AS "notRolledBack", + sum(applied_steps_count)::int AS steps + FROM ${`"${process.env.SCHEMA_NAME.replaceAll("\"", "\"\"")}"`}."_prisma_migrations" + WHERE migration_name = $1 + `, [process.env.TARGET_MIGRATION, expectedChecksum]); + const row = result.rows[0]; + if ( + row?.count !== 1 || + row.checksumMatches !== true || + row.finished !== true || + row.notRolledBack !== true || + row.steps !== 1 + ) { + throw new Error(`unexpected target migration history after no-op deploy: ${JSON.stringify(row)}`); + } + } finally { + await client.end(); + } + ' + +echo "Game outbox UTC-wall populated upgrade, pending requeue, delivered preservation, checksum, no-op, and predecessor DML compatibility passed" diff --git a/packages/infra/test/readModelOutboxDispatcher.integration.test.ts b/packages/infra/test/readModelOutboxDispatcher.integration.test.ts index 754acb57..10203b69 100644 --- a/packages/infra/test/readModelOutboxDispatcher.integration.test.ts +++ b/packages/infra/test/readModelOutboxDispatcher.integration.test.ts @@ -7,6 +7,7 @@ import { dispatchReadModelOutboxBatch, pruneDeliveredReadModelOutbox, } from '../src/readModelOutboxDispatcher.js'; +import { enqueueWebPushOutboxEvents } from '../src/webPushOutbox.js'; const databaseUrl = process.env.READ_MODEL_JOURNAL_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); @@ -26,7 +27,9 @@ integration('read-model outbox PostgreSQL delivery boundary', () => { afterAll(async () => disconnect?.()); beforeEach(async () => { - await prisma.$executeRaw`TRUNCATE TABLE "read_model_outbox", "read_model_revision" RESTART IDENTITY`; + await prisma.$executeRaw` + TRUNCATE TABLE "read_model_outbox", "read_model_revision", "web_push_outbox" RESTART IDENTITY + `; }); const enqueue = async (entityId: number): Promise => { @@ -47,6 +50,93 @@ integration('read-model outbox PostgreSQL delivery boundary', () => { expect(new Set(ids).size).toBe(20); }); + it('keeps both game outbox defaults claimable as current instants in a non-UTC session', async () => { + const beforeInsert = Date.now(); + await prisma.$transaction(async (transaction) => { + await transaction.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`; + await writeReadModelChangeJournal(transaction, [{ domain: 'general.content', entityId: 77 }]); + await enqueueWebPushOutboxEvents(transaction, [ + { + eventId: 'non-utc-default-instant', + eventType: 'PRIVATE_MESSAGE_RECEIVED', + userIds: ['outbox-timezone-user'], + }, + ]); + }); + const afterInsert = Date.now(); + + const readModelRow = await prisma.readModelOutbox.findFirstOrThrow(); + const webPushRow = await prisma.webPushOutbox.findFirstOrThrow(); + for (const [label, instant] of Object.entries({ + readModelAvailableAt: readModelRow.availableAt, + readModelCreatedAt: readModelRow.createdAt, + webPushAvailableAt: webPushRow.availableAt, + webPushCreatedAt: webPushRow.createdAt, + })) { + expect(instant.getTime(), label).toBeGreaterThanOrEqual(beforeInsert - 1_000); + expect(instant.getTime(), label).toBeLessThanOrEqual(afterInsert + 1_000); + } + const [storedDefaults] = await prisma.$queryRaw< + Array<{ + readAvailableMs: number; + readCreatedMs: number; + webAvailableMs: number; + webCreatedMs: number; + }> + >` + SELECT + (SELECT (EXTRACT(EPOCH FROM "available_at") * 1000)::double precision + FROM "read_model_outbox" LIMIT 1) AS "readAvailableMs", + (SELECT (EXTRACT(EPOCH FROM "created_at") * 1000)::double precision + FROM "read_model_outbox" LIMIT 1) AS "readCreatedMs", + (SELECT (EXTRACT(EPOCH FROM "available_at") * 1000)::double precision + FROM "web_push_outbox" LIMIT 1) AS "webAvailableMs", + (SELECT (EXTRACT(EPOCH FROM "created_at") * 1000)::double precision + FROM "web_push_outbox" LIMIT 1) AS "webCreatedMs" + `; + if (!storedDefaults) throw new Error('outbox UTC-wall defaults were not persisted'); + for (const [label, instantMs] of Object.entries(storedDefaults)) { + expect(instantMs, label).toBeGreaterThanOrEqual(beforeInsert - 1_000); + expect(instantMs, label).toBeLessThanOrEqual(afterInsert + 1_000); + } + + const lockAt = new Date(); + const retryAt = new Date(lockAt.getTime() + 60_000); + await prisma.webPushOutbox.update({ + where: { eventId: 'non-utc-default-instant' }, + data: { availableAt: retryAt, lockedAt: lockAt, lockOwner: 'non-utc-session-worker' }, + }); + const [webPushSchedule] = await prisma.$queryRaw< + Array<{ + availableMs: number; + lockedMs: number; + due: boolean; + leaseExpired: boolean; + }> + >` + SELECT + (EXTRACT(EPOCH FROM "available_at") * 1000)::double precision AS "availableMs", + (EXTRACT(EPOCH FROM "locked_at") * 1000)::double precision AS "lockedMs", + "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AS "due", + "locked_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '30 seconds' + AS "leaseExpired" + FROM "web_push_outbox" + WHERE "event_id" = 'non-utc-default-instant' + `; + if (!webPushSchedule) throw new Error('web push UTC-wall schedule was not persisted'); + expect(webPushSchedule).toMatchObject({ due: false, leaseExpired: false }); + expect(Math.abs(webPushSchedule.availableMs - retryAt.getTime())).toBeLessThanOrEqual(1); + expect(Math.abs(webPushSchedule.lockedMs - lockAt.getTime())).toBeLessThanOrEqual(1); + + await expect( + claimReadModelOutboxBatch(prisma, { + owner: 'non-utc-session-worker', + limit: 1, + now: new Date(afterInsert + 1_000), + }) + ).resolves.toHaveLength(1); + }); + it('releases a publish failure and later delivers the same row', async () => { await enqueue(7); const failedAt = new Date('2099-08-16T00:00:00.000Z'); diff --git a/release-manifest.json b/release-manifest.json index 2537fe48..cf4fb87f 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260823010000_add_web_push_notifications", - "gameSchemaHead": "20260823010000_add_web_push_outbox", + "gameSchemaHead": "20260824070000_game_outbox_utc_wall_timestamps", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] } From c82c076b1b07cd5953304f8b879d07764bbb7eaf Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 07:40:56 +0000 Subject: [PATCH 2/8] =?UTF-8?q?test:=20=EA=B2=BD=EB=A7=A4=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20fixture=EC=9D=98=20=EB=85=BC=EB=A6=AC=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=EA=B3=BC=20mailbox=EB=A5=BC=20=EA=B5=90=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manual world의 game clock으로 만료 시각을 만들고 private mailbox 범위의 장수 ID를 사용한다. 연장 소진 뒤 invalid item 취소 결과도 durable input event와 함께 고정한다. --- .../test/auctionWorker.integration.test.ts | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/app/game-api/test/auctionWorker.integration.test.ts b/app/game-api/test/auctionWorker.integration.test.ts index 01a429aa..17e83935 100644 --- a/app/game-api/test/auctionWorker.integration.test.ts +++ b/app/game-api/test/auctionWorker.integration.test.ts @@ -62,8 +62,7 @@ liveDescribe('auction worker durable recovery', () => { await connector.disconnect(); }); - const createAuction = async (status: 'OPEN' | 'FINALIZING') => { - const closeAt = new Date(Date.now() - 60_000); + const createAuction = async (status: 'OPEN' | 'FINALIZING', closeAt = new Date(Date.now() - 60_000)) => { const auction = await connector.prisma.auction.create({ data: { type: 'BUY_RICE', @@ -340,14 +339,14 @@ liveDescribe('auction worker durable recovery', () => { imageServer: 0, }); const host = buildGeneral({ - id: 992_032, + id: 8_032, userId: 'auction-durable-host', name: '경매주최자', gold: 1_000, rice: 900, }); const bidder = buildGeneral({ - id: 992_033, + id: 8_033, userId: 'auction-durable-bidder', name: '경매입찰자', gold: 800, @@ -366,7 +365,7 @@ liveDescribe('auction worker durable recovery', () => { map, }; - const lifecycleGeneralIds = [992_032, 992_033]; + const lifecycleGeneralIds = [8_032, 8_033]; await connector.prisma.logEntry.deleteMany({ where: { generalId: { in: lifecycleGeneralIds } } }); await connector.prisma.general.deleteMany({ where: { id: { in: lifecycleGeneralIds } } }); await connector.prisma.worldState.deleteMany({ where: { id: worldId } }); @@ -398,7 +397,8 @@ liveDescribe('auction worker durable recovery', () => { }); createdGeneralIds.push(general.id); } - const auction = await createAuction('OPEN'); + const logicalPastCloseAt = new Date(state.lastTurnTime.getTime() - 60_000); + const auction = await createAuction('OPEN', logicalPastCloseAt); await connector.prisma.auction.update({ where: { id: auction.id }, data: { hostGeneralId: host.id, hostName: host.name }, @@ -487,7 +487,7 @@ liveDescribe('auction worker durable recovery', () => { hostName: '(상인)', detail: { remainCloseDateExtensionCnt: 1 }, status: 'OPEN', - closeAt: new Date(Date.now() - 60_000), + closeAt: logicalPastCloseAt, }, }); extensionAuctionId = extensionAuction.id; @@ -528,8 +528,8 @@ liveDescribe('auction worker durable recovery', () => { expect(reopened).toMatchObject({ status: 'OPEN' }); expect(reopened!.closeAt.getTime()).toBeGreaterThan(extensionAuction.closeAt.getTime()); - const secondWallNow = new Date(); - const secondCloseAt = new Date(secondWallNow.getTime() - 1_000); + const secondGameNow = world.getGameNow(new Date()); + const secondCloseAt = new Date(secondGameNow.getTime() - 1_000); const secondCloseTick = world.dateToGameTick(secondCloseAt); await connector.prisma.auction.update({ where: { id: extensionAuction.id }, @@ -546,8 +546,8 @@ liveDescribe('auction worker durable recovery', () => { timerKey: 'timer', historyKey: 'history', id: String(extensionAuction.id), - nowMs: world.getGameNow(secondWallNow).getTime(), - nowTick: world.dateToGameTick(secondWallNow), + nowMs: secondGameNow.getTime(), + nowTick: world.dateToGameTick(secondGameNow), }); for (let attempt = 0; attempt < 200; attempt += 1) { @@ -558,6 +558,16 @@ liveDescribe('auction worker durable recovery', () => { if (event?.status === 'SUCCEEDED' && storedAuction?.status === 'CANCELED') break; await delay(25); } + const secondExtensionEvent = await connector.prisma.inputEvent.findUniqueOrThrow({ + where: { requestId: secondExtensionRequestId }, + }); + expect(secondExtensionEvent).toMatchObject({ status: 'SUCCEEDED', error: null }); + expect(secondExtensionEvent.result).toEqual({ + type: 'auctionFinalize', + ok: false, + auctionId: extensionAuction.id, + reason: '아이템 키가 올바르지 않습니다.', + }); await expect( connector.prisma.auction.findUniqueOrThrow({ where: { id: extensionAuction.id } }) ).resolves.toMatchObject({ status: 'CANCELED' }); From 8d966d3152cf9f54b5f44354bda106a30ae49efa Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 07:41:12 +0000 Subject: [PATCH 3/8] =?UTF-8?q?test:=20=EC=8B=A4=EC=A0=9C=20transport=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=EA=B3=BC=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20?= =?UTF-8?q?=EB=82=B4=EA=B5=AC=20=ED=96=89=EB=A0=AC=EC=9D=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 Fastify HTTP, PostgreSQL과 Redis에서 예약 mutation의 인증·sanction·소유권·수뇌 경계, 감사 원장과 무부작용을 검증한다. 장수 55개와 수뇌 35개를 R1/R2/R3으로 전수 분류하고 대표 9개를 Ref/Core 의미 비교부터 lease, 단일 flush와 fresh reload까지 실행한다. 비행위자 revision/lease와 관직 5 수뇌 partition도 exact 보존한다. --- .../securityTransport.integration.test.ts | 1525 ++++++++++++++++- tools/conditional-integration-registry.tsv | 2 + .../src/turn-differential/canonical.ts | 19 +- .../commandDurabilityRisk.ts | 231 +++ ...llLifecyclePersistence.integration.test.ts | 59 +- ...ndRiskDurabilityMatrix.integration.test.ts | 1227 +++++++++++++ .../turnSnapshotCanonicalCoverage.test.ts | 19 + tools/run-conditional-integration.sh | 46 +- 8 files changed, 3078 insertions(+), 50 deletions(-) create mode 100644 tools/integration-tests/src/turn-differential/commandDurabilityRisk.ts create mode 100644 tools/integration-tests/test/turnCommandRiskDurabilityMatrix.integration.test.ts diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index c51f3be6..eadea405 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -1,13 +1,15 @@ import fs from 'node:fs/promises'; +import { createServer, type Server as HttpServer } from 'node:http'; import os from 'node:os'; import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, createRedisConnector, + enqueueWebPushOutboxEvents, resolveRedisConfigFromEnv, type GamePrismaClient, type RedisConnector, @@ -15,17 +17,34 @@ import { import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { createGameApiServer } from '../src/server.js'; +import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js'; -const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; -const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_HOST || !process.env.REDIS_PORT); -const profileId = process.env.POSTGRES_SCHEMA ?? 'conditional_integration'; +const databaseUrl = process.env.SECURITY_TRANSPORT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL); +const dedicatedSuffix = 'security_transport'; +let profileId = process.env.POSTGRES_SCHEMA ?? 'conditional_integration'; const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? String(process.pid); const profileName = `che:security-http-${runId}`; const userId = `security-http-user-${process.pid}`; +const noGeneralUserId = `security-http-no-general-${process.pid}`; +const sameNationUserId = `security-http-same-nation-${process.pid}`; +const foreignUserId = `security-http-foreign-${process.pid}`; +const ordinaryUserId = `security-http-ordinary-${process.pid}`; const generalId = 990_001; +const sameNationGeneralId = 990_002; +const foreignGeneralId = 990_003; +const npcGeneralId = 990_004; +const ordinaryGeneralId = 990_005; +const fixtureGeneralIds = [generalId, sameNationGeneralId, foreignGeneralId, npcGeneralId, ordinaryGeneralId]; +const ownerNationId = 99_001; +const foreignNationId = 99_002; +const fixtureNationIds = [ownerNationId, foreignNationId]; +const fixtureWorldId = 990_001; +const mutationRequestPrefix = `security-http-matrix-${process.pid}-`; const secret = 'security-http-e2e-secret'; const redisPrefix = `sammo:security-http:${process.pid}`; const envKeys = [ + 'DATABASE_URL', 'PROFILE', 'SCENARIO', 'GAME_PROFILE_NAME', @@ -33,6 +52,7 @@ const envKeys = [ 'GAME_API_PORT', 'GAME_TOKEN_SECRET', 'GATEWAY_REDIS_PREFIX', + 'GATEWAY_INTERNAL_API_URL', 'GAME_UPLOAD_DIR', ] as const; const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]])); @@ -46,6 +66,27 @@ let db: GamePrismaClient; let disconnectDb: (() => Promise) | null = null; let redis: RedisConnector | null = null; let accessTokenStore: RedisAccessTokenStore; +let createdFixtureWorld = false; +let gatewayStatusServer: HttpServer | null = null; +let receivedGatewayWebPushEvents: Array<{ internalToken: string | null; body: unknown }> = []; + +export const assertDedicatedSecurityTransportDatabase = (rawUrl: string): void => { + resolveDedicatedSecurityTransportTarget(rawUrl); +}; + +const resolveDedicatedSecurityTransportTarget = (rawUrl: string): { databaseUrl: string; schema: string } => { + const url = new URL(rawUrl); + const schema = url.searchParams.get('schema'); + const databaseName = decodeURIComponent(url.pathname.replace(/^\/+/, '')); + if (!schema?.endsWith(dedicatedSuffix) && !databaseName.endsWith(dedicatedSuffix)) { + throw new Error( + `Refusing to mutate non-dedicated security transport database: schema=${schema ?? '(missing)'}, database=${databaseName || '(missing)'}` + ); + } + const effectiveSchema = schema?.trim() || 'public'; + url.searchParams.set('schema', effectiveSchema); + return { databaseUrl: url.href, schema: effectiveSchema }; +}; const restoreEnv = (): void => { for (const [key, value] of originalEnv) { @@ -57,28 +98,90 @@ const restoreEnv = (): void => { } }; +const listenGatewayStatusStub = async (): Promise => { + gatewayStatusServer = createServer((request, response) => { + if (request.method === 'GET' && request.url === `/internal/profile-status/${encodeURIComponent(profileName)}`) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ profileName, status: 'RUNNING' })); + return; + } + if (request.method === 'POST' && request.url === '/internal/account-icon-resets') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ resets: [] })); + return; + } + if (request.method === 'POST' && request.url === '/internal/web-push-events') { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer | string) => chunks.push(Buffer.from(chunk))); + request.on('end', () => { + try { + receivedGatewayWebPushEvents.push({ + internalToken: + typeof request.headers['x-sammo-internal-token'] === 'string' + ? request.headers['x-sammo-internal-token'] + : null, + body: JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown, + }); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{}'); + } catch { + response.writeHead(400); + response.end(); + } + }); + return; + } + response.writeHead(404); + response.end(); + }); + await new Promise((resolve, reject) => { + gatewayStatusServer!.once('error', reject); + gatewayStatusServer!.listen(0, '127.0.0.1', () => { + gatewayStatusServer!.off('error', reject); + resolve(); + }); + }); + const address = gatewayStatusServer.address(); + if (!address || typeof address === 'string') throw new Error('gateway status stub did not bind a TCP port'); + return `http://127.0.0.1:${address.port}`; +}; + +const closeGatewayStatusStub = async (): Promise => { + if (!gatewayStatusServer) return; + const current = gatewayStatusServer; + gatewayStatusServer = null; + await new Promise((resolve, reject) => current.close((error) => (error ? reject(error) : resolve()))); +}; + const deleteProfileRedisKeys = async (): Promise => { if (!redis) { return; } - for await (const keys of redis.client.scanIterator({ - MATCH: `sammo:game:*:${profileName}:*`, - COUNT: 100, - })) { - if (keys.length > 0) { - await redis.client.del(keys); + for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) { + for await (const keys of redis.client.scanIterator({ + MATCH: pattern, + COUNT: 100, + })) { + if (keys.length > 0) { + await redis.client.del(keys); + } } } }; -const buildPayload = (suffix: string, sanctions: GameSessionTokenPayload['sanctions']): GameSessionTokenPayload => ({ +const buildPayload = ( + suffix: string, + sanctions: GameSessionTokenPayload['sanctions'], + actorUserId = userId, + actorProfile = profileName +): GameSessionTokenPayload => ({ version: 1, - profile: profileName, + profile: actorProfile, issuedAt: new Date(Date.now() - 1_000).toISOString(), expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), sessionId: `security-http-session-${process.pid}-${suffix}`, user: { - id: userId, + id: actorUserId, username: 'security-http-user', displayName: 'Security HTTP User', roles: ['user'], @@ -87,8 +190,12 @@ const buildPayload = (suffix: string, sanctions: GameSessionTokenPayload['sancti sanctions, }); -const createAccessToken = async (suffix: string, sanctions: GameSessionTokenPayload['sanctions']): Promise => { - const created = await accessTokenStore.create(buildPayload(suffix, sanctions)); +const createAccessToken = async ( + suffix: string, + sanctions: GameSessionTokenPayload['sanctions'], + actorUserId = userId +): Promise => { + const created = await accessTokenStore.create(buildPayload(suffix, sanctions, actorUserId)); if (!created) { throw new Error('failed to seed the game access token'); } @@ -101,6 +208,7 @@ const requestTrpc = async ( method?: 'GET' | 'POST'; input?: unknown; accessToken?: string; + idempotencyKey?: string; } = {} ): Promise<{ response: Response; body: unknown }> => { const method = options.method ?? 'GET'; @@ -109,6 +217,7 @@ const requestTrpc = async ( headers: { ...(method === 'POST' ? { 'content-type': 'application/json' } : {}), ...(options.accessToken ? { authorization: `Bearer ${options.accessToken}` } : {}), + ...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}), }, ...(method === 'POST' ? { body: JSON.stringify(options.input) } : {}), }); @@ -118,31 +227,402 @@ const requestTrpc = async ( }; }; +const readReservedMutationState = async () => ({ + generals: await db.general.findMany({ + where: { id: { in: fixtureGeneralIds } }, + select: { + id: true, + userId: true, + nationId: true, + officerLevel: true, + lastTurn: true, + meta: true, + }, + orderBy: { id: 'asc' }, + }), + generalTurns: await db.generalTurn.findMany({ + where: { generalId: { in: fixtureGeneralIds } }, + select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }), + generalTurnRevisions: await db.generalTurnRevision.findMany({ + where: { generalId: { in: fixtureGeneralIds } }, + select: { generalId: true, revision: true, leaseOwner: true, leaseExpiresAt: true }, + orderBy: { generalId: 'asc' }, + }), + generalAccessLogs: await db.generalAccessLog.findMany({ + where: { generalId: { in: fixtureGeneralIds } }, + select: { + generalId: true, + userId: true, + lastRefresh: true, + refresh: true, + refreshTotal: true, + refreshScore: true, + refreshScoreTotal: true, + lastActionAt: true, + }, + orderBy: { generalId: 'asc' }, + }), + nationTurns: await db.nationTurn.findMany({ + where: { nationId: { in: fixtureNationIds } }, + select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true }, + orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }, { turnIdx: 'asc' }], + }), + nationTurnRevisions: await db.nationTurnRevision.findMany({ + where: { nationId: { in: fixtureNationIds } }, + select: { nationId: true, officerLevel: true, revision: true, leaseOwner: true, leaseExpiresAt: true }, + orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }], + }), + readModelRevisions: await db.readModelRevision.findMany({ + where: { + OR: [ + { domain: 'reserved.general', entityId: { in: fixtureGeneralIds } }, + { domain: 'dashboard.global', entityId: 0 }, + ], + }, + select: { domain: true, entityId: true, revision: true }, + orderBy: [{ domain: 'asc' }, { entityId: 'asc' }], + }), + readModelOutbox: await db.readModelOutbox.findMany({ + select: { id: true, payload: true }, + orderBy: { id: 'asc' }, + }), + messages: await db.message.findMany({ + where: { + OR: [{ src: { in: fixtureGeneralIds } }, { dest: { in: [...fixtureGeneralIds, ...fixtureNationIds] } }], + }, + select: { id: true, mailbox: true, type: true, src: true, dest: true, message: true }, + orderBy: { id: 'asc' }, + }), + logs: await db.logEntry.findMany({ + where: { + OR: [{ generalId: { in: fixtureGeneralIds } }, { nationId: { in: fixtureNationIds } }], + }, + select: { id: true, scope: true, category: true, generalId: true, nationId: true, text: true }, + orderBy: { id: 'asc' }, + }), + engineInputEvents: await db.inputEvent.findMany({ + where: { target: 'ENGINE', requestId: { startsWith: mutationRequestPrefix } }, + select: { requestId: true, eventType: true, status: true, actorUserId: true }, + orderBy: { sequence: 'asc' }, + }), + webPushOutboxCount: await db.webPushOutbox.count(), + eventCount: await db.event.count(), + auctionCount: await db.auction.count(), + auctionBidCount: await db.auctionBid.count(), +}); + +const quotePostgresIdentifier = (value: string): string => `"${value.replaceAll('"', '""')}"`; + +const readDurableSchemaStateExcludingMatrixApiJournal = async () => { + const tables = await db.$queryRawUnsafe>( + `SELECT table_name AS "tableName" + FROM information_schema.tables + WHERE table_schema = $1 AND table_type = 'BASE TABLE' + ORDER BY table_name`, + profileId + ); + return Promise.all( + tables.map(async ({ tableName }) => { + const qualifiedTable = `${quotePostgresIdentifier(profileId)}.${quotePostgresIdentifier(tableName)}`; + const matrixApiFilter = + tableName === 'input_event' ? `WHERE NOT (target = 'API' AND request_id LIKE $1)` : ''; + const rows = await db.$queryRawUnsafe>( + `SELECT to_jsonb(snapshot_row)::text AS "rowJson" + FROM ${qualifiedTable} AS snapshot_row + ${matrixApiFilter} + ORDER BY to_jsonb(snapshot_row)::text`, + ...(matrixApiFilter ? [`${mutationRequestPrefix}%`] : []) + ); + return { tableName, rows: rows.map(({ rowJson }) => rowJson) }; + }) + ); +}; + +type DurableSchemaState = Awaited>; + +const withoutDurableTables = (state: DurableSchemaState, allowedTables: readonly string[]): DurableSchemaState => { + const allowed = new Set(allowedTables); + return state.filter(({ tableName }) => !allowed.has(tableName)); +}; + +const readSuccessAllowedTableState = async () => ({ + generalTurns: await db.generalTurn.findMany({ orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }] }), + generalTurnRevisions: await db.generalTurnRevision.findMany({ orderBy: { generalId: 'asc' } }), + nationTurns: await db.nationTurn.findMany({ + orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }, { turnIdx: 'asc' }], + }), + nationTurnRevisions: await db.nationTurnRevision.findMany({ + orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }], + }), + generalAccessLogs: await db.generalAccessLog.findMany({ orderBy: { generalId: 'asc' } }), + readModelRevisions: await db.readModelRevision.findMany({ + orderBy: [{ domain: 'asc' }, { entityId: 'asc' }], + }), + readModelOutbox: await db.readModelOutbox.findMany({ orderBy: { id: 'asc' } }), +}); + +const readAccessTelemetryState = async () => ({ + periods: await db.trafficPeriod.findMany({ orderBy: { id: 'asc' } }), + generals: await db.trafficPeriodGeneral.findMany({ + orderBy: [{ periodId: 'asc' }, { generalId: 'asc' }], + }), + accessLogs: await db.generalAccessLog.findMany({ orderBy: { generalId: 'asc' } }), +}); + +const readRealtimeRedisState = async (): Promise> => { + if (!redis) return []; + const keys = new Set(); + for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) { + for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) { + for (const key of batch) keys.add(key); + } + } + return Promise.all( + [...keys].sort().map(async (key) => [key, await redis!.client.get(key)] as [string, string | null]) + ); +}; + +const expectApiInputEvent = async ( + idempotencyKey: string, + procedure: string, + expected: { actorUserId: string; status: 'FAILED' | 'SUCCEEDED' } | null +): Promise => { + const requestId = `${idempotencyKey}:${procedure}`; + const events = await db.inputEvent.findMany({ + // beforeEach removes the whole matrix prefix. Query that complete + // namespace so an extra/rewritten API journal row cannot hide behind + // the full-schema snapshot's one explicitly allowed exclusion. + where: { target: 'API', requestId: { startsWith: mutationRequestPrefix } }, + select: { + requestId: true, + target: true, + eventType: true, + payload: true, + actorUserId: true, + status: true, + result: true, + error: true, + attempts: true, + lockedBy: true, + leaseUntil: true, + processingAt: true, + completedAt: true, + createdAt: true, + }, + orderBy: { sequence: 'asc' }, + }); + if (!expected) { + expect(events).toEqual([]); + return; + } + expect(events).toEqual([ + { + requestId, + target: 'API', + eventType: procedure, + payload: {}, + actorUserId: expected.actorUserId, + status: expected.status, + result: expected.status === 'SUCCEEDED' ? { ok: true } : null, + error: expected.status === 'SUCCEEDED' ? null : expect.any(String), + attempts: 1, + lockedBy: null, + leaseUntil: null, + processingAt: expect.any(Date), + completedAt: expect.any(Date), + createdAt: expect.any(Date), + }, + ]); + const event = events[0]; + if (!event?.processingAt || !event.completedAt) { + throw new Error('API input event must have processing/completion timestamps'); + } + expect(event.completedAt.getTime()).toBeGreaterThanOrEqual(event.processingAt.getTime()); + if (expected.status === 'FAILED') { + expect(event.error?.length).toBeGreaterThan(0); + } +}; + +const expectSingleActorActivity = ( + rows: Awaited>['generalAccessLogs'] +) => { + expect(rows).toEqual([ + { + generalId, + userId, + lastRefresh: null, + refresh: 0, + refreshTotal: 0, + refreshScore: 0, + refreshScoreTotal: 0, + lastActionAt: expect.any(Date), + }, + ]); +}; + +const requestReservedGeneral = (accessToken: string | undefined, idempotencyKey: string, targetGeneralId = generalId) => + requestTrpc('turns.reserved.setGeneral', { + method: 'POST', + input: { + generalId: targetGeneralId, + turnIndex: 0, + action: '휴식', + args: {}, + expectedRevision: 0, + }, + accessToken, + idempotencyKey, + }); + +const requestReservedNation = (accessToken: string, idempotencyKey: string, targetGeneralId: number) => + requestTrpc('turns.reserved.setNation', { + method: 'POST', + input: { + generalId: targetGeneralId, + turnIndex: 0, + action: '휴식', + args: {}, + expectedRevision: 0, + }, + accessToken, + idempotencyKey, + }); + +const ownershipDenialCases = [ + { + label: 'authenticated user without a general', + actorUserId: noGeneralUserId, + targetGeneralId: generalId, + }, + { + label: 'same-nation foreign-owned general', + actorUserId: userId, + targetGeneralId: sameNationGeneralId, + }, + { + label: 'other-nation foreign-owned general', + actorUserId: userId, + targetGeneralId: foreignGeneralId, + }, + { + label: 'NPC general', + actorUserId: userId, + targetGeneralId: npcGeneralId, + }, +] as const; + +describe('security transport database guard', () => { + it('rejects a shared database and schema before connecting', () => { + expect(() => + assertDedicatedSecurityTransportDatabase('postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=public') + ).toThrow('Refusing to mutate non-dedicated security transport database'); + }); + + it('accepts only an explicitly dedicated schema or database name', () => { + expect(() => + assertDedicatedSecurityTransportDatabase( + 'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=ci_security_transport' + ) + ).not.toThrow(); + expect(() => + assertDedicatedSecurityTransportDatabase( + 'postgresql://fixture:fixture@127.0.0.1:5432/ci_security_transport' + ) + ).not.toThrow(); + }); +}); + integration('game API security over HTTP transport', () => { beforeAll(async () => { + const dedicatedTarget = resolveDedicatedSecurityTransportTarget(databaseUrl!); + profileId = dedicatedTarget.schema; uploadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-game-security-http-')); - process.env.PROFILE = profileId; + process.env.DATABASE_URL = dedicatedTarget.databaseUrl; + process.env.PROFILE = dedicatedTarget.schema; process.env.SCENARIO = 'security-http'; process.env.GAME_PROFILE_NAME = profileName; process.env.GAME_API_HOST = '127.0.0.1'; process.env.GAME_API_PORT = '0'; process.env.GAME_TOKEN_SECRET = secret; process.env.GATEWAY_REDIS_PREFIX = redisPrefix; + process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub(); process.env.GAME_UPLOAD_DIR = uploadDir; - const connector = createGamePostgresConnector({ url: databaseUrl! }); + const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl }); await connector.connect(); db = connector.prisma; disconnectDb = () => connector.disconnect(); - await db.general.deleteMany({ where: { id: generalId } }); - await db.general.create({ - data: { - id: generalId, - userId, - name: '보안HTTP', - turnTime: new Date('2026-07-26T00:00:00.000Z'), - }, + await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); + await db.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); + await db.general.deleteMany({ where: { id: { in: fixtureGeneralIds } } }); + await db.general.createMany({ + data: [ + { + id: generalId, + userId, + name: '보안HTTP', + nationId: ownerNationId, + officerLevel: 12, + turnTime: new Date('2026-07-26T00:00:00.000Z'), + }, + { + id: sameNationGeneralId, + userId: sameNationUserId, + name: '동일국타인', + nationId: ownerNationId, + officerLevel: 5, + turnTime: new Date('2026-07-26T00:00:00.000Z'), + }, + { + id: foreignGeneralId, + userId: foreignUserId, + name: '타국타인', + nationId: foreignNationId, + officerLevel: 5, + turnTime: new Date('2026-07-26T00:00:00.000Z'), + }, + { + id: npcGeneralId, + userId: null, + name: 'NPC장수', + nationId: ownerNationId, + npcState: 2, + officerLevel: 5, + turnTime: new Date('2026-07-26T00:00:00.000Z'), + }, + { + id: ordinaryGeneralId, + userId: ordinaryUserId, + name: '비수뇌', + nationId: ownerNationId, + officerLevel: 4, + turnTime: new Date('2026-07-26T00:00:00.000Z'), + }, + ], }); + if ((await db.worldState.count()) === 0) { + await db.worldState.create({ + data: { + id: fixtureWorldId, + scenarioCode: 'security-http', + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + }, + }); + createdFixtureWorld = true; + } + await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } }); + await db.readModelOutbox.deleteMany(); + await db.webPushOutbox.deleteMany(); redis = createRedisConnector(resolveRedisConfigFromEnv()); await redis.connect(); @@ -157,7 +637,29 @@ integration('game API security over HTTP transport', () => { afterAll(async () => { await server?.app.close(); - await db?.general.deleteMany({ where: { id: generalId } }); + await closeGatewayStatusStub(); + await db?.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } }); + await db?.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db?.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } }); + await db?.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db?.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db?.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db?.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); + await db?.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); + await db?.readModelRevision.deleteMany({ + where: { + OR: [ + { domain: 'reserved.general', entityId: { in: fixtureGeneralIds } }, + { domain: 'dashboard.global', entityId: 0 }, + ], + }, + }); + await db?.readModelOutbox.deleteMany(); + await db?.webPushOutbox.deleteMany(); + await db?.general.deleteMany({ where: { id: { in: fixtureGeneralIds } } }); + if (createdFixtureWorld) { + await db?.worldState.deleteMany({ where: { id: fixtureWorldId } }); + } await disconnectDb?.(); await deleteProfileRedisKeys(); await redis?.disconnect(); @@ -167,6 +669,31 @@ integration('game API security over HTTP transport', () => { restoreEnv(); }, 30_000); + beforeEach(async () => { + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } }); + await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } }); + await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); + await db.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); + await db.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); + await db.readModelRevision.deleteMany({ + where: { + OR: [ + { domain: 'reserved.general', entityId: { in: fixtureGeneralIds } }, + { domain: 'dashboard.global', entityId: 0 }, + ], + }, + }); + await db.readModelOutbox.deleteMany(); + await db.webPushOutbox.deleteMany(); + receivedGatewayWebPushEvents = []; + if (redis) { + await redis.client.del(`sammo:${profileName}:read-model:revision`); + } + }); + it('accepts an authenticated query from a POST JSON body', async () => { const accessToken = await createAccessToken('json-query-body', {}); const general = await requestTrpc('general.me', { @@ -190,30 +717,32 @@ integration('game API security over HTTP transport', () => { it.each([ { label: 'global suspension', - sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' }, + sanctions: () => ({ suspendedUntil: '2099-01-01T00:00:00.000Z' }), }, { label: 'instance game restriction', - sanctions: { + sanctions: () => ({ serverRestrictions: { [profileName]: { blockedFeatures: ['game'], }, }, - }, + }), }, { label: 'profile-id wildcard restriction', - sanctions: { + sanctions: () => ({ serverRestrictions: { [profileId]: { blockedFeatures: ['*'], }, }, - }, + }), }, ])('blocks an authenticated game API request for $label', async ({ label, sanctions }) => { - const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions); + const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions()); + const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); + const redisBefore = await readRealtimeRedisState(); const blocked = await requestTrpc('general.me', { accessToken }); expect(blocked.response.status).toBe(403); @@ -224,6 +753,8 @@ integration('game API security over HTTP transport', () => { }, }, }); + expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); + expect(await readRealtimeRedisState()).toEqual(redisBefore); }); it.each([ @@ -243,6 +774,7 @@ integration('game API security over HTTP transport', () => { }, ])('allows non-message APIs but blocks message send for $label', async ({ label, sanctions }) => { const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions); + const idempotencyKey = `${mutationRequestPrefix}message-${label.replaceAll(' ', '-')}`; const general = await requestTrpc('general.me', { accessToken }); expect(general.response.status).toBe(200); expect(general.body).toMatchObject({ @@ -254,6 +786,9 @@ integration('game API security over HTTP transport', () => { }, }, }); + const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); + const telemetryBefore = await readAccessTelemetryState(); + const redisBefore = await readRealtimeRedisState(); const message = await requestTrpc('messages.send', { method: 'POST', @@ -263,6 +798,7 @@ integration('game API security over HTTP transport', () => { text: '차단되어야 하는 메시지', }, accessToken, + idempotencyKey, }); expect(message.response.status).toBe(403); expect(message.body).toMatchObject({ @@ -272,6 +808,59 @@ integration('game API security over HTTP transport', () => { }, }, }); + await expectApiInputEvent(idempotencyKey, 'messages.send', { + actorUserId: userId, + status: 'FAILED', + }); + expect(telemetryBefore).toEqual({ periods: [], generals: [], accessLogs: [] }); + const telemetryAfter = await readAccessTelemetryState(); + expect(telemetryAfter.periods).toEqual([ + { + id: expect.any(Number), + worldStateId: fixtureWorldId, + year: 190, + month: 1, + startedAt: expect.any(Date), + lastRefresh: expect.any(Date), + refresh: 1, + online: 1, + }, + ]); + const trafficPeriod = telemetryAfter.periods[0]; + if (!trafficPeriod) throw new Error('message access did not create its traffic period'); + expect(telemetryAfter.generals).toEqual([ + { + periodId: trafficPeriod.id, + generalId, + userId, + refresh: 1, + lastRefresh: trafficPeriod.lastRefresh, + }, + ]); + expect(telemetryAfter.accessLogs).toEqual([ + { + id: expect.any(Number), + generalId, + userId, + lastRefresh: trafficPeriod.lastRefresh, + lastActionAt: null, + refresh: 1, + refreshTotal: 1, + refreshScore: 1, + refreshScoreTotal: 1, + }, + ]); + expect(trafficPeriod.lastRefresh.getTime()).toBeGreaterThanOrEqual(trafficPeriod.startedAt.getTime()); + expect( + withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [ + 'traffic_period', + 'traffic_period_general', + 'general_access_log', + ]) + ).toEqual( + withoutDurableTables(durableBefore, ['traffic_period', 'traffic_period_general', 'general_access_log']) + ); + expect(await readRealtimeRedisState()).toEqual(redisBefore); }); it('rejects a restricted signed gateway token before issuing a game access token', async () => { @@ -285,6 +874,8 @@ integration('game API security over HTTP transport', () => { }), secret ); + const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); + const redisBefore = await readRealtimeRedisState(); const blocked = await requestTrpc('auth.exchangeGatewayToken', { method: 'POST', input: { gatewayToken }, @@ -298,8 +889,878 @@ integration('game API security over HTTP transport', () => { }, }, }); + expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); + expect(await readRealtimeRedisState()).toEqual(redisBefore); }); + it.each([ + { + label: 'missing bearer token', + accessToken: async () => undefined, + expectedStatus: 401, + expectedCode: 'UNAUTHORIZED', + }, + { + label: 'unknown bearer token', + accessToken: async () => 'unknown-security-http-token', + expectedStatus: 401, + expectedCode: 'UNAUTHORIZED', + }, + { + label: 'access token stored for another profile', + accessToken: async () => { + const otherProfileStore = new RedisAccessTokenStore(redis!.client, `${profileName}:other`); + const created = await otherProfileStore.create( + buildPayload('cross-profile', {}, userId, `${profileName}:other`) + ); + if (!created) throw new Error('failed to seed the cross-profile access token'); + return created.accessToken; + }, + expectedStatus: 401, + expectedCode: 'UNAUTHORIZED', + }, + { + label: 'gameplay sanction', + accessToken: () => + createAccessToken('matrix-sanction', { + serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } }, + }), + expectedStatus: 403, + expectedCode: 'FORBIDDEN', + }, + ])( + 'rejects $label before creating an API input event or any durable/Redis gameplay side effect', + async ({ label, accessToken, expectedStatus, expectedCode }) => { + const idempotencyKey = `${mutationRequestPrefix}auth-${label.replaceAll(' ', '-')}`; + const token = await accessToken(); + const databaseBefore = await readReservedMutationState(); + const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); + const redisBefore = await readRealtimeRedisState(); + + const result = await requestReservedGeneral(token, idempotencyKey); + + expect(result.response.status).toBe(expectedStatus); + expect(result.body).toMatchObject({ error: { data: { code: expectedCode } } }); + expect(await readReservedMutationState()).toEqual(databaseBefore); + expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); + expect(await readRealtimeRedisState()).toEqual(redisBefore); + await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', null); + } + ); + + it.each(ownershipDenialCases)( + 'keeps reserved queues, journal/outbox, ENGINE events, and profile Redis unchanged for $label general ownership denial', + async ({ label, actorUserId, targetGeneralId }) => { + const idempotencyKey = `${mutationRequestPrefix}owner-${label.replaceAll(' ', '-')}`; + const accessToken = await createAccessToken(`matrix-owner-${label.replaceAll(' ', '-')}`, {}, actorUserId); + const databaseBefore = await readReservedMutationState(); + const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); + const redisBefore = await readRealtimeRedisState(); + + const result = await requestReservedGeneral(accessToken, idempotencyKey, targetGeneralId); + + expect(result.response.status).toBe(403); + expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } }); + expect(await readReservedMutationState()).toEqual(databaseBefore); + expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); + expect(await readRealtimeRedisState()).toEqual(redisBefore); + await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', { + actorUserId, + status: 'FAILED', + }); + } + ); + + it.each(ownershipDenialCases)( + 'keeps reserved queues, journal/outbox, ENGINE events, and profile Redis unchanged for $label nation ownership denial', + async ({ label, actorUserId, targetGeneralId }) => { + const idempotencyKey = `${mutationRequestPrefix}nation-owner-${label.replaceAll(' ', '-')}`; + const accessToken = await createAccessToken( + `matrix-nation-owner-${label.replaceAll(' ', '-')}`, + {}, + actorUserId + ); + const databaseBefore = await readReservedMutationState(); + const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); + const redisBefore = await readRealtimeRedisState(); + + const result = await requestReservedNation(accessToken, idempotencyKey, targetGeneralId); + + expect(result.response.status).toBe(403); + expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } }); + expect(await readReservedMutationState()).toEqual(databaseBefore); + expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); + expect(await readRealtimeRedisState()).toEqual(redisBefore); + await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { + actorUserId, + status: 'FAILED', + }); + } + ); + + it('keeps the nation queue unchanged when an owned general is below the officer threshold', async () => { + const idempotencyKey = `${mutationRequestPrefix}nation-non-officer`; + const accessToken = await createAccessToken('matrix-nation-non-officer', {}, ordinaryUserId); + const databaseBefore = await readReservedMutationState(); + const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); + const redisBefore = await readRealtimeRedisState(); + + const result = await requestReservedNation(accessToken, idempotencyKey, ordinaryGeneralId); + + expect(result.response.status).toBe(403); + expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } }); + expect(await readReservedMutationState()).toEqual(databaseBefore); + expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); + expect(await readRealtimeRedisState()).toEqual(redisBefore); + await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { + actorUserId: ordinaryUserId, + status: 'FAILED', + }); + }); + + it('commits an owned general reservation once with an authenticated actor and durable journal', async () => { + const idempotencyKey = `${mutationRequestPrefix}general-success`; + const accessToken = await createAccessToken('matrix-general-success', {}); + const databaseBefore = await readReservedMutationState(); + const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); + const allowedTablesBefore = await readSuccessAllowedTableState(); + const redisBefore = await readRealtimeRedisState(); + + const result = await requestReservedGeneral(accessToken, idempotencyKey); + + expect(result.response.status).toBe(200); + expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } }); + expect( + await db.generalTurn.findMany({ + where: { generalId }, + select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, + orderBy: { turnIdx: 'asc' }, + }) + ).toEqual( + Array.from({ length: 30 }, (_, turnIdx) => ({ + generalId, + turnIdx, + actionCode: '휴식', + arg: {}, + })) + ); + expect(await db.generalTurnRevision.findUnique({ where: { generalId } })).toMatchObject({ + revision: 1, + leaseOwner: null, + leaseExpiresAt: null, + }); + await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', { + actorUserId: userId, + status: 'SUCCEEDED', + }); + expect( + await db.inputEvent.count({ + where: { target: 'ENGINE', requestId: { startsWith: idempotencyKey } }, + }) + ).toBe(0); + await expect.poll(() => db.readModelOutbox.count()).toBe(1); + await expect + .poll( + async () => { + const row = await db.readModelOutbox.findFirst(); + return { + delivered: row?.deliveredAt instanceof Date, + attempts: row?.attempts ?? null, + locked: row?.lockedAt instanceof Date, + lastError: row?.lastError ?? null, + }; + }, + { timeout: 5_000, interval: 50 } + ) + .toEqual({ delivered: true, attempts: 1, locked: false, lastError: null }); + const readModelRedisRevisionKey = `sammo:${profileName}:read-model:revision`; + await expect + .poll(() => redis!.client.get(readModelRedisRevisionKey), { timeout: 5_000, interval: 50 }) + .toBe('1'); + expect( + await db.readModelRevision.findMany({ + where: { + OR: [ + { domain: 'reserved.general', entityId: generalId }, + { domain: 'dashboard.global', entityId: 0 }, + ], + }, + select: { domain: true, entityId: true, revision: true }, + orderBy: [{ domain: 'asc' }, { entityId: 'asc' }], + }) + ).toEqual([ + { domain: 'dashboard.global', entityId: 0, revision: 1n }, + { domain: 'reserved.general', entityId: generalId, revision: 1n }, + ]); + expect(await db.readModelOutbox.findMany({ select: { payload: true } })).toEqual([ + { + payload: { + version: 1, + changes: [ + ['dashboard.global', 0, '1'], + ['reserved.general', generalId, '1'], + ], + }, + }, + ]); + const databaseAfter = await readReservedMutationState(); + expect(databaseAfter.generals).toEqual(databaseBefore.generals); + expect(databaseAfter.generalTurns).toEqual( + Array.from({ length: 30 }, (_, turnIdx) => ({ + generalId, + turnIdx, + actionCode: '휴식', + arg: {}, + })) + ); + expect(databaseAfter.generalTurnRevisions).toEqual([ + { generalId, revision: 1, leaseOwner: null, leaseExpiresAt: null }, + ]); + expect(databaseAfter.nationTurns).toEqual(databaseBefore.nationTurns); + expect(databaseAfter.nationTurnRevisions).toEqual(databaseBefore.nationTurnRevisions); + expect(databaseAfter.messages).toEqual(databaseBefore.messages); + expect(databaseAfter.logs).toEqual(databaseBefore.logs); + expect(databaseAfter.engineInputEvents).toEqual(databaseBefore.engineInputEvents); + expect(databaseAfter.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount); + expect(databaseAfter.eventCount).toBe(databaseBefore.eventCount); + expect(databaseAfter.auctionCount).toBe(databaseBefore.auctionCount); + expect(databaseAfter.auctionBidCount).toBe(databaseBefore.auctionBidCount); + expectSingleActorActivity(databaseAfter.generalAccessLogs); + const allowedTablesAfter = await readSuccessAllowedTableState(); + expect(allowedTablesBefore.readModelOutbox).toEqual([]); + expect(allowedTablesAfter.generalTurns.filter((row) => row.generalId !== generalId)).toEqual( + allowedTablesBefore.generalTurns.filter((row) => row.generalId !== generalId) + ); + const committedGeneralTurns = allowedTablesAfter.generalTurns.filter((row) => row.generalId === generalId); + expect( + committedGeneralTurns.map(({ generalId: rowGeneralId, turnIdx, actionCode, arg }) => ({ + generalId: rowGeneralId, + turnIdx, + actionCode, + arg, + })) + ).toEqual(databaseAfter.generalTurns); + expect(new Set(committedGeneralTurns.map(({ id }) => id)).size).toBe(30); + expect(committedGeneralTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true); + expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId !== generalId)).toEqual( + allowedTablesBefore.generalTurnRevisions.filter((row) => row.generalId !== generalId) + ); + expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId === generalId)).toEqual([ + { + generalId, + revision: 1, + leaseOwner: null, + leaseExpiresAt: null, + updatedAt: expect.any(Date), + }, + ]); + expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== generalId)).toEqual( + allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== generalId) + ); + expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([ + { + id: expect.any(Number), + generalId, + userId, + lastRefresh: null, + lastActionAt: expect.any(Date), + refresh: 0, + refreshTotal: 0, + refreshScore: 0, + refreshScoreTotal: 0, + }, + ]); + const expectedReadModelKeys = new Set([`dashboard.global:0`, `reserved.general:${generalId}`]); + expect( + allowedTablesAfter.readModelRevisions.filter( + ({ domain, entityId }) => !expectedReadModelKeys.has(`${domain}:${entityId}`) + ) + ).toEqual( + allowedTablesBefore.readModelRevisions.filter( + ({ domain, entityId }) => !expectedReadModelKeys.has(`${domain}:${entityId}`) + ) + ); + expect( + allowedTablesAfter.readModelRevisions.filter(({ domain, entityId }) => + expectedReadModelKeys.has(`${domain}:${entityId}`) + ) + ).toEqual([ + { domain: 'dashboard.global', entityId: 0, revision: 1n, updatedAt: expect.any(Date) }, + { domain: 'reserved.general', entityId: generalId, revision: 1n, updatedAt: expect.any(Date) }, + ]); + expect(allowedTablesAfter.readModelOutbox).toEqual([ + { + id: expect.anything(), + payload: { + version: 1, + changes: [ + ['dashboard.global', 0, '1'], + ['reserved.general', generalId, '1'], + ], + }, + attempts: 1, + availableAt: expect.any(Date), + lockedAt: null, + lockOwner: null, + deliveredAt: expect.any(Date), + lastError: null, + createdAt: expect.any(Date), + }, + ]); + const deliveredOutbox = allowedTablesAfter.readModelOutbox[0]; + if (!deliveredOutbox?.deliveredAt) throw new Error('read-model outbox was not delivered'); + expect(typeof deliveredOutbox.id).toBe('bigint'); + expect(deliveredOutbox.id).toBeGreaterThan(0n); + expect(deliveredOutbox.deliveredAt.getTime()).toBeGreaterThanOrEqual(deliveredOutbox.createdAt.getTime()); + expect( + withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [ + 'general_turn', + 'general_turn_revision', + 'general_access_log', + 'read_model_revision', + 'read_model_outbox', + ]) + ).toEqual( + withoutDurableTables(durableBefore, [ + 'general_turn', + 'general_turn_revision', + 'general_access_log', + 'read_model_revision', + 'read_model_outbox', + ]) + ); + expect(redisBefore.some(([key]) => key === readModelRedisRevisionKey)).toBe(false); + const expectedRedisAfter: Array<[string, string | null]> = [...redisBefore, [readModelRedisRevisionKey, '1']]; + expectedRedisAfter.sort(([left], [right]) => left.localeCompare(right)); + expect(await readRealtimeRedisState()).toEqual(expectedRedisAfter); + }, 15_000); + + it('accepts the minimum officer level into its own nation queue partition over HTTP', async () => { + const idempotencyKey = `${mutationRequestPrefix}nation-minimum-officer-success`; + const accessToken = await createAccessToken('matrix-nation-minimum-officer-success', {}, sameNationUserId); + const databaseBefore = await readReservedMutationState(); + const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); + const allowedTablesBefore = await readSuccessAllowedTableState(); + const redisBefore = await readRealtimeRedisState(); + + const result = await requestReservedNation(accessToken, idempotencyKey, sameNationGeneralId); + + expect(result.response.status).toBe(200); + expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } }); + const expectedTurns = Array.from({ length: 12 }, (_, turnIdx) => ({ + nationId: ownerNationId, + officerLevel: 5, + turnIdx, + actionCode: '휴식', + arg: {}, + })); + expect( + await db.nationTurn.findMany({ + where: { nationId: ownerNationId, officerLevel: 5 }, + select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true }, + orderBy: { turnIdx: 'asc' }, + }) + ).toEqual(expectedTurns); + expect( + await db.nationTurnRevision.findUnique({ + where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 5 } }, + }) + ).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null }); + await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { + actorUserId: sameNationUserId, + status: 'SUCCEEDED', + }); + + const committed = await readReservedMutationState(); + expect(committed.generals).toEqual(databaseBefore.generals); + expect(committed.generalTurns).toEqual(databaseBefore.generalTurns); + expect(committed.generalTurnRevisions).toEqual(databaseBefore.generalTurnRevisions); + expect(committed.nationTurns).toEqual(expectedTurns); + expect(committed.nationTurnRevisions).toEqual([ + { + nationId: ownerNationId, + officerLevel: 5, + revision: 1, + leaseOwner: null, + leaseExpiresAt: null, + }, + ]); + expect(committed.generalAccessLogs).toEqual([ + { + generalId: sameNationGeneralId, + userId: sameNationUserId, + lastRefresh: null, + refresh: 0, + refreshTotal: 0, + refreshScore: 0, + refreshScoreTotal: 0, + lastActionAt: expect.any(Date), + }, + ]); + expect(committed.readModelRevisions).toEqual(databaseBefore.readModelRevisions); + expect(committed.readModelOutbox).toEqual(databaseBefore.readModelOutbox); + expect(committed.messages).toEqual(databaseBefore.messages); + expect(committed.logs).toEqual(databaseBefore.logs); + expect(committed.engineInputEvents).toEqual(databaseBefore.engineInputEvents); + expect(committed.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount); + expect(committed.eventCount).toBe(databaseBefore.eventCount); + expect(committed.auctionCount).toBe(databaseBefore.auctionCount); + expect(committed.auctionBidCount).toBe(databaseBefore.auctionBidCount); + + const allowedTablesAfter = await readSuccessAllowedTableState(); + expect( + allowedTablesAfter.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 5) + ).toEqual( + allowedTablesBefore.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 5) + ); + const committedNationTurns = allowedTablesAfter.nationTurns.filter( + (row) => row.nationId === ownerNationId && row.officerLevel === 5 + ); + expect( + committedNationTurns.map(({ nationId, officerLevel, turnIdx, actionCode, arg }) => ({ + nationId, + officerLevel, + turnIdx, + actionCode, + arg, + })) + ).toEqual(expectedTurns); + expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12); + expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true); + expect( + allowedTablesAfter.nationTurnRevisions.filter( + (row) => row.nationId !== ownerNationId || row.officerLevel !== 5 + ) + ).toEqual( + allowedTablesBefore.nationTurnRevisions.filter( + (row) => row.nationId !== ownerNationId || row.officerLevel !== 5 + ) + ); + expect( + allowedTablesAfter.nationTurnRevisions.filter( + (row) => row.nationId === ownerNationId && row.officerLevel === 5 + ) + ).toEqual([ + { + nationId: ownerNationId, + officerLevel: 5, + revision: 1, + leaseOwner: null, + leaseExpiresAt: null, + updatedAt: expect.any(Date), + }, + ]); + expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== sameNationGeneralId)).toEqual( + allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== sameNationGeneralId) + ); + expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === sameNationGeneralId)).toEqual([ + { + id: expect.any(Number), + generalId: sameNationGeneralId, + userId: sameNationUserId, + lastRefresh: null, + lastActionAt: expect.any(Date), + refresh: 0, + refreshTotal: 0, + refreshScore: 0, + refreshScoreTotal: 0, + }, + ]); + expect( + withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [ + 'nation_turn', + 'nation_turn_revision', + 'general_access_log', + ]) + ).toEqual(withoutDurableTables(durableBefore, ['nation_turn', 'nation_turn_revision', 'general_access_log'])); + expect(await readRealtimeRedisState()).toEqual(redisBefore); + }, 15_000); + + it('commits an owned officer nation reservation and rejects duplicate idempotency replay without a second queue mutation', async () => { + const idempotencyKey = `${mutationRequestPrefix}nation-success`; + const accessToken = await createAccessToken('matrix-nation-success', {}); + const databaseBefore = await readReservedMutationState(); + const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); + const allowedTablesBefore = await readSuccessAllowedTableState(); + const redisBefore = await readRealtimeRedisState(); + + const first = await requestReservedNation(accessToken, idempotencyKey, generalId); + expect(first.response.status).toBe(200); + expect(first.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } }); + expect( + await db.nationTurn.findMany({ + where: { nationId: ownerNationId, officerLevel: 12 }, + select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true }, + orderBy: { turnIdx: 'asc' }, + }) + ).toEqual( + Array.from({ length: 12 }, (_, turnIdx) => ({ + nationId: ownerNationId, + officerLevel: 12, + turnIdx, + actionCode: '휴식', + arg: {}, + })) + ); + expect( + await db.nationTurnRevision.findUnique({ + where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } }, + }) + ).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null }); + await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { + actorUserId: userId, + status: 'SUCCEEDED', + }); + + const committed = await readReservedMutationState(); + expect(committed.generals).toEqual(databaseBefore.generals); + expect(committed.generalTurns).toEqual(databaseBefore.generalTurns); + expect(committed.generalTurnRevisions).toEqual(databaseBefore.generalTurnRevisions); + expect(committed.nationTurns).toEqual( + Array.from({ length: 12 }, (_, turnIdx) => ({ + nationId: ownerNationId, + officerLevel: 12, + turnIdx, + actionCode: '휴식', + arg: {}, + })) + ); + expect(committed.nationTurnRevisions).toEqual([ + { + nationId: ownerNationId, + officerLevel: 12, + revision: 1, + leaseOwner: null, + leaseExpiresAt: null, + }, + ]); + expect(committed.readModelRevisions).toEqual(databaseBefore.readModelRevisions); + expect(committed.readModelOutbox).toEqual(databaseBefore.readModelOutbox); + expect(committed.messages).toEqual(databaseBefore.messages); + expect(committed.logs).toEqual(databaseBefore.logs); + expect(committed.engineInputEvents).toEqual(databaseBefore.engineInputEvents); + expect(committed.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount); + expect(committed.eventCount).toBe(databaseBefore.eventCount); + expect(committed.auctionCount).toBe(databaseBefore.auctionCount); + expect(committed.auctionBidCount).toBe(databaseBefore.auctionBidCount); + expectSingleActorActivity(committed.generalAccessLogs); + const allowedTablesAfter = await readSuccessAllowedTableState(); + expect( + allowedTablesAfter.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 12) + ).toEqual( + allowedTablesBefore.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 12) + ); + const committedNationTurns = allowedTablesAfter.nationTurns.filter( + (row) => row.nationId === ownerNationId && row.officerLevel === 12 + ); + expect( + committedNationTurns.map(({ nationId, officerLevel, turnIdx, actionCode, arg }) => ({ + nationId, + officerLevel, + turnIdx, + actionCode, + arg, + })) + ).toEqual(committed.nationTurns); + expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12); + expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true); + expect( + allowedTablesAfter.nationTurnRevisions.filter( + (row) => row.nationId !== ownerNationId || row.officerLevel !== 12 + ) + ).toEqual( + allowedTablesBefore.nationTurnRevisions.filter( + (row) => row.nationId !== ownerNationId || row.officerLevel !== 12 + ) + ); + expect( + allowedTablesAfter.nationTurnRevisions.filter( + (row) => row.nationId === ownerNationId && row.officerLevel === 12 + ) + ).toEqual([ + { + nationId: ownerNationId, + officerLevel: 12, + revision: 1, + leaseOwner: null, + leaseExpiresAt: null, + updatedAt: expect.any(Date), + }, + ]); + expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== generalId)).toEqual( + allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== generalId) + ); + expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([ + { + id: expect.any(Number), + generalId, + userId, + lastRefresh: null, + lastActionAt: expect.any(Date), + refresh: 0, + refreshTotal: 0, + refreshScore: 0, + refreshScoreTotal: 0, + }, + ]); + expect( + withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [ + 'nation_turn', + 'nation_turn_revision', + 'general_access_log', + ]) + ).toEqual(withoutDurableTables(durableBefore, ['nation_turn', 'nation_turn_revision', 'general_access_log'])); + expect(await readRealtimeRedisState()).toEqual(redisBefore); + const replayDurableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); + const replayRedisBefore = await readRealtimeRedisState(); + const replayJournalBefore = await db.inputEvent.findUniqueOrThrow({ + where: { requestId: `${idempotencyKey}:turns.reserved.setNation` }, + }); + const replay = await requestReservedNation(accessToken, idempotencyKey, generalId); + expect(replay.response.status).toBe(409); + expect(replay.body).toMatchObject({ error: { data: { code: 'CONFLICT' } } }); + expect(await readReservedMutationState()).toEqual(committed); + expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(replayDurableBefore); + expect(await readRealtimeRedisState()).toEqual(replayRedisBefore); + expect( + await db.inputEvent.findUniqueOrThrow({ + where: { requestId: `${idempotencyKey}:turns.reserved.setNation` }, + }) + ).toEqual(replayJournalBefore); + expect(await db.inputEvent.count({ where: { requestId: `${idempotencyKey}:turns.reserved.setNation` } })).toBe( + 1 + ); + await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { + actorUserId: userId, + status: 'SUCCEEDED', + }); + }); + + it('delivers a non-UTC-session Web Push outbox row once with its original instant', async () => { + const eventId = `security-http-web-push-${process.pid}`; + const beforeInsert = Date.now(); + await db.$transaction(async (transaction) => { + await transaction.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`; + await expect( + enqueueWebPushOutboxEvents(transaction, [ + { + eventId, + eventType: 'PRIVATE_MESSAGE_RECEIVED', + userIds: [userId], + }, + ]) + ).resolves.toBe(1); + }); + const afterInsert = Date.now(); + + await expect.poll(() => receivedGatewayWebPushEvents.length, { timeout: 6_000, interval: 50 }).toBe(1); + await expect + .poll( + async () => { + const row = await db.webPushOutbox.findUniqueOrThrow({ where: { eventId } }); + return { + attempts: row.attempts, + locked: row.lockedAt instanceof Date, + lockOwner: row.lockOwner, + delivered: row.deliveredAt instanceof Date, + lastError: row.lastError, + }; + }, + { timeout: 6_000, interval: 50 } + ) + .toEqual({ attempts: 1, locked: false, lockOwner: null, delivered: true, lastError: null }); + const delivered = await db.webPushOutbox.findUniqueOrThrow({ where: { eventId } }); + expect(delivered).toMatchObject({ + attempts: 1, + lockedAt: null, + lockOwner: null, + deliveredAt: expect.any(Date), + lastError: null, + }); + const [storedInstant] = await db.$queryRaw>` + SELECT (EXTRACT(EPOCH FROM "created_at") * 1000)::double precision AS "createdMs" + FROM "web_push_outbox" + WHERE "event_id" = ${eventId} + `; + if (!storedInstant) throw new Error('web push outbox instant was not persisted'); + + const [received] = receivedGatewayWebPushEvents; + expect(received?.internalToken).toMatch(/^[a-f0-9]{64}$/u); + expect(received?.body).toEqual({ + version: 1, + eventId: `game:${profileName}:${eventId}`, + eventType: 'PRIVATE_MESSAGE_RECEIVED', + profileName, + userIds: [userId], + occurredAt: expect.any(String), + }); + const occurredAt = Date.parse((received?.body as { occurredAt: string }).occurredAt); + expect(occurredAt).toBeGreaterThanOrEqual(beforeInsert - 1_000); + expect(occurredAt).toBeLessThanOrEqual(afterInsert + 1_000); + expect(Math.abs(occurredAt - storedInstant.createdMs)).toBeLessThanOrEqual(1); + }, 10_000); + + it('keeps Web Push due, lease, and prune boundaries in UTC wall time under a KST database session', async () => { + const eventIds = { + future: `security-http-web-push-future-${process.pid}`, + recentLock: `security-http-web-push-recent-lock-${process.pid}`, + staleLock: `security-http-web-push-stale-lock-${process.pid}`, + retainedDelivery: `security-http-web-push-retained-delivery-${process.pid}`, + prunedDelivery: `security-http-web-push-pruned-delivery-${process.pid}`, + } as const; + const [databaseSession] = await db.$queryRaw>` + SELECT current_setting('TIMEZONE') AS "timeZone" + `; + expect(databaseSession?.timeZone).toBe('Asia/Seoul'); + + await db.$transaction(async (transaction) => { + await transaction.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`; + const [transactionSession] = await transaction.$queryRaw>` + SELECT current_setting('TIMEZONE') AS "timeZone" + `; + expect(transactionSession?.timeZone).toBe('Asia/Seoul'); + await transaction.$executeRaw` + INSERT INTO "web_push_outbox" ( + "event_id", + "event_type", + "user_ids", + "attempts", + "available_at", + "locked_at", + "lock_owner", + "delivered_at", + "created_at" + ) + VALUES + ( + ${eventIds.future}, + 'PRIVATE_MESSAGE_RECEIVED', + ARRAY[${userId}]::text[], + 0, + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '2 hours', + NULL, + NULL, + NULL, + CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + ), + ( + ${eventIds.recentLock}, + 'PRIVATE_MESSAGE_RECEIVED', + ARRAY[${userId}]::text[], + 4, + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 minute', + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 second', + 'previous-owner', + NULL, + CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + ), + ( + ${eventIds.staleLock}, + 'PRIVATE_MESSAGE_RECEIVED', + ARRAY[${userId}]::text[], + 2, + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 minute', + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '31 seconds', + 'previous-owner', + NULL, + CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + ), + ( + ${eventIds.retainedDelivery}, + 'PRIVATE_MESSAGE_RECEIVED', + ARRAY[${userId}]::text[], + 1, + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '20 hours', + NULL, + NULL, + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '20 hours', + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '21 hours' + ), + ( + ${eventIds.prunedDelivery}, + 'PRIVATE_MESSAGE_RECEIVED', + ARRAY[${userId}]::text[], + 1, + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '25 hours', + NULL, + NULL, + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '25 hours', + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '26 hours' + ) + `; + }); + + const boundaryWorker = new WebPushOutboxWorker(db, process.env.GATEWAY_INTERNAL_API_URL!, secret, profileName, { + intervalMs: 60_000, + }); + boundaryWorker.start(); + await boundaryWorker.stop(); + + await expect + .poll( + async () => { + const staleLock = await db.webPushOutbox.findUnique({ + where: { eventId: eventIds.staleLock }, + }); + return staleLock + ? { + attempts: staleLock.attempts, + lockedAt: staleLock.lockedAt, + lockOwner: staleLock.lockOwner, + delivered: staleLock.deliveredAt instanceof Date, + lastError: staleLock.lastError, + } + : null; + }, + { timeout: 6_000, interval: 50 } + ) + .toEqual({ attempts: 3, lockedAt: null, lockOwner: null, delivered: true, lastError: null }); + await expect + .poll( + async () => + db.webPushOutbox.count({ + where: { eventId: eventIds.prunedDelivery }, + }), + { timeout: 6_000, interval: 50 } + ) + .toBe(0); + + const remaining = await db.webPushOutbox.findMany({ + where: { eventId: { in: Object.values(eventIds) } }, + orderBy: { eventId: 'asc' }, + }); + const byEventId = new Map(remaining.map((row) => [row.eventId, row])); + expect(byEventId.get(eventIds.future)).toMatchObject({ + attempts: 0, + lockedAt: null, + lockOwner: null, + deliveredAt: null, + lastError: null, + }); + expect(byEventId.get(eventIds.recentLock)).toMatchObject({ + attempts: 4, + lockedAt: expect.any(Date), + lockOwner: 'previous-owner', + deliveredAt: null, + lastError: null, + }); + expect(byEventId.get(eventIds.retainedDelivery)).toMatchObject({ + attempts: 1, + lockedAt: null, + lockOwner: null, + deliveredAt: expect.any(Date), + lastError: null, + }); + expect(byEventId.has(eventIds.prunedDelivery)).toBe(false); + expect(receivedGatewayWebPushEvents).toHaveLength(1); + expect(receivedGatewayWebPushEvents[0]?.body).toMatchObject({ + eventId: `game:${profileName}:${eventIds.staleLock}`, + }); + }, 10_000); + + // Flush invalidates every token issued before the user watermark. Keep it + // last so this lifecycle assertion cannot invalidate the actor tokens used + // by the transport authorization matrix above. it('invalidates an existing access token after a gateway flush event', async () => { const accessToken = await createAccessToken('flush', {}); expect((await requestTrpc('general.me', { accessToken })).response.status).toBe(200); diff --git a/tools/conditional-integration-registry.tsv b/tools/conditional-integration-registry.tsv index b682d027..8c227581 100644 --- a/tools/conditional-integration-registry.tsv +++ b/tools/conditional-integration-registry.tsv @@ -15,8 +15,10 @@ PROFILE_SEED_DATABASE_URL core PROFILE_LOCK_SECONDARY_DATABASE_URL core READ_MODEL_JOURNAL_DATABASE_URL read_model_journal RESERVED_TURN_DATABASE_URL core +SECURITY_TRANSPORT_DATABASE_URL security_transport SELECT_POOL_DATABASE_URL select_pool TURN_DAEMON_LEASE_DATABASE_URL core +TURN_COMMAND_DURABLE_MATRIX_DATABASE_URL reference_command_durable_matrix TURN_DIFFERENTIAL_DATABASE_URL core TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index 11929486..c582ab55 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -127,6 +127,11 @@ const readString = (record: Record, key: string): string | null return typeof value === 'string' ? value : null; }; +const readNullableCode = (record: Record, key: string): string | null => { + const value = readString(record, key); + return value && value !== 'None' ? value : null; +}; + const readCommandInteger = (value: unknown, field: string, fallback: number | null): number | null => { if (value === null || value === undefined) { return fallback; @@ -388,13 +393,13 @@ export const projectCoreDatabaseSnapshot = (rows: { maxBelong: readNumber(meta, 'max_belong'), maxDomesticCritical: readNumber(meta, 'max_domestic_critical'), betray: row.betray, - personality: row.personality ?? null, - specialDomestic: row.specialDomestic ?? null, - specialWar: row.specialWar ?? null, - itemHorse: row.itemHorse ?? null, - itemWeapon: row.itemWeapon ?? null, - itemBook: row.itemBook ?? null, - itemExtra: row.itemExtra ?? null, + personality: readNullableCode(row, 'personality') ?? readNullableCode(row, 'personalCode'), + specialDomestic: readNullableCode(row, 'specialDomestic') ?? readNullableCode(row, 'specialCode'), + specialWar: readNullableCode(row, 'specialWar') ?? readNullableCode(row, 'special2Code'), + itemHorse: readNullableCode(row, 'itemHorse') ?? readNullableCode(row, 'horseCode'), + itemWeapon: readNullableCode(row, 'itemWeapon') ?? readNullableCode(row, 'weaponCode'), + itemBook: readNullableCode(row, 'itemBook') ?? readNullableCode(row, 'bookCode'), + itemExtra: readNullableCode(row, 'itemExtra') ?? readNullableCode(row, 'itemCode'), picture: row.picture ?? null, imageServer: readNumber(row, 'imageServer'), injury: row.injury, diff --git a/tools/integration-tests/src/turn-differential/commandDurabilityRisk.ts b/tools/integration-tests/src/turn-differential/commandDurabilityRisk.ts new file mode 100644 index 00000000..ef725341 --- /dev/null +++ b/tools/integration-tests/src/turn-differential/commandDurabilityRisk.ts @@ -0,0 +1,231 @@ +import type { GeneralTurnCommandKey, NationTurnCommandKey } from '@sammo-ts/logic'; + +export type CommandDurabilityRisk = 'R1' | 'R2' | 'R3'; +export type CommandDurabilityScope = 'general' | 'nation'; + +/** + * R1 mutates the actor or one aggregate, R2 crosses an actor/aggregate boundary, + * and R3 can fan out, create/delete entities, fight, or mutate relationships. + * The typed records deliberately fail compilation when a command is added + * without an explicit durability classification. + */ +export const generalCommandDurabilityRisk = { + che_거병: 'R3', + che_임관: 'R3', + che_랜덤임관: 'R3', + che_귀환: 'R1', + che_등용수락: 'R3', + che_장수대상임관: 'R3', + che_건국: 'R3', + cr_건국: 'R3', + che_무작위건국: 'R3', + che_훈련: 'R1', + cr_맹훈련: 'R1', + che_전투태세: 'R1', + che_단련: 'R1', + che_숙련전환: 'R1', + che_사기진작: 'R1', + che_요양: 'R1', + che_견문: 'R1', + che_장비매매: 'R2', + che_내정특기초기화: 'R1', + che_전투특기초기화: 'R1', + che_출병: 'R3', + che_주민선정: 'R2', + che_정착장려: 'R2', + che_농지개간: 'R2', + che_상업투자: 'R2', + che_기술연구: 'R2', + che_치안강화: 'R2', + che_수비강화: 'R2', + che_성벽보수: 'R2', + che_화계: 'R3', + che_집합: 'R3', + che_인재탐색: 'R3', + che_징병: 'R2', + che_모병: 'R2', + che_소집해제: 'R2', + che_군량매매: 'R2', + che_물자조달: 'R2', + che_헌납: 'R2', + che_이동: 'R3', + che_접경귀환: 'R1', + che_방랑: 'R3', + che_하야: 'R3', + che_은퇴: 'R3', + che_선양: 'R3', + che_모반시도: 'R3', + che_증여: 'R3', + che_해산: 'R3', + che_등용: 'R3', + che_첩보: 'R2', + che_파괴: 'R3', + che_선동: 'R3', + che_탈취: 'R3', + che_NPC능동: 'R1', + che_강행: 'R3', + 휴식: 'R1', +} as const satisfies Record; + +export const nationCommandDurabilityRisk = { + 휴식: 'R1', + che_포상: 'R3', + che_부대탈퇴지시: 'R3', + che_발령: 'R3', + che_선전포고: 'R3', + che_종전제의: 'R3', + che_불가침제의: 'R3', + che_불가침파기제의: 'R3', + che_의병모집: 'R3', + che_허보: 'R3', + che_필사즉생: 'R3', + che_백성동원: 'R3', + che_이호경식: 'R3', + che_수몰: 'R3', + che_급습: 'R3', + che_피장파장: 'R3', + che_초토화: 'R3', + che_천도: 'R2', + che_국호변경: 'R1', + che_무작위수도이전: 'R3', + che_국기변경: 'R1', + che_증축: 'R2', + che_감축: 'R2', + cr_인구이동: 'R3', + che_몰수: 'R3', + che_물자원조: 'R3', + event_원융노병연구: 'R1', + event_화시병연구: 'R1', + event_음귀병연구: 'R1', + event_대검병연구: 'R1', + event_화륜차연구: 'R1', + event_산저병연구: 'R1', + event_극병연구: 'R1', + event_상병연구: 'R1', + event_무희연구: 'R1', +} as const satisfies Record; + +export type CommandDurabilityFacet = + | 'single-actor' + | 'local-aggregate' + | 'cross-entity' + | 'placement-topology' + | 'hostile-rng-destructive' + | 'diplomacy-strategy' + | 'entity-creation-fanout' + | 'retirement-archive' + | 'multi-turn-research'; + +export interface CommandDurabilityEvidence { + scope: CommandDurabilityScope; + risk: CommandDurabilityRisk; + command: GeneralTurnCommandKey | NationTurnCommandKey; + facet: CommandDurabilityFacet; + testFile: string; + matrixRepresentative: boolean; +} + +/** + * This is a representative durable matrix, not a claim that all 90 commands + * execute against PostgreSQL. Every scope/risk cell runs in the dedicated + * matrix; high-risk battle, creation, and destructive paths retain their + * stronger dedicated rollback/reload suites. + */ +export const commandDurabilityEvidence = [ + { + scope: 'general', + risk: 'R1', + command: 'che_훈련', + facet: 'single-actor', + testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts', + matrixRepresentative: true, + }, + { + scope: 'general', + risk: 'R2', + command: 'che_농지개간', + facet: 'local-aggregate', + testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts', + matrixRepresentative: true, + }, + { + scope: 'general', + risk: 'R3', + command: 'che_증여', + facet: 'cross-entity', + testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts', + matrixRepresentative: true, + }, + { + scope: 'nation', + risk: 'R1', + command: 'che_국호변경', + facet: 'local-aggregate', + testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts', + matrixRepresentative: true, + }, + { + scope: 'nation', + risk: 'R2', + command: 'che_증축', + facet: 'local-aggregate', + testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts', + matrixRepresentative: true, + }, + { + scope: 'nation', + risk: 'R3', + command: 'che_선전포고', + facet: 'diplomacy-strategy', + testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts', + matrixRepresentative: true, + }, + { + scope: 'general', + risk: 'R3', + command: 'che_이동', + facet: 'placement-topology', + testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts', + matrixRepresentative: true, + }, + { + scope: 'nation', + risk: 'R3', + command: 'che_물자원조', + facet: 'cross-entity', + testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts', + matrixRepresentative: true, + }, + { + scope: 'nation', + risk: 'R1', + command: 'event_원융노병연구', + facet: 'multi-turn-research', + testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts', + matrixRepresentative: true, + }, + { + scope: 'general', + risk: 'R3', + command: 'che_출병', + facet: 'hostile-rng-destructive', + testFile: 'liveSortiePersistence.integration.test.ts', + matrixRepresentative: false, + }, + { + scope: 'nation', + risk: 'R3', + command: 'che_의병모집', + facet: 'entity-creation-fanout', + testFile: 'turnCommandFullLifecyclePersistence.integration.test.ts', + matrixRepresentative: false, + }, + { + scope: 'general', + risk: 'R3', + command: 'che_은퇴', + facet: 'retirement-archive', + testFile: 'generalTurnLifecyclePersistence.integration.test.ts', + matrixRepresentative: false, + }, +] as const satisfies readonly CommandDurabilityEvidence[]; diff --git a/tools/integration-tests/test/turnCommandFullLifecyclePersistence.integration.test.ts b/tools/integration-tests/test/turnCommandFullLifecyclePersistence.integration.test.ts index e69e0c92..1e3f8e35 100644 --- a/tools/integration-tests/test/turnCommandFullLifecyclePersistence.integration.test.ts +++ b/tools/integration-tests/test/turnCommandFullLifecyclePersistence.integration.test.ts @@ -485,11 +485,33 @@ databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence', } world.executeGeneralTurn(executableActor); - const createdIds = world - .peekDirtyState() - .createdGenerals.map((general) => general.id) - .sort((left, right) => left - right); + const createdGenerals = world.peekDirtyState().createdGenerals; + const createdIds = createdGenerals.map((general) => general.id).sort((left, right) => left - right); expect(createdIds).toEqual([102, 103, 104]); + const createdVolunteerIdentity = createdGenerals + .map((general) => ({ + id: general.id, + affinity: general.affinity, + npcState: general.npcState, + npcOrg: asRecord(general.meta).npc_org, + expLevel: asRecord(general.meta).explevel, + dedLevel: asRecord(general.meta).dedlevel, + })) + .sort((left, right) => left.id - right.id); + expect(createdVolunteerIdentity).toEqual( + createdIds.map((id) => ({ + id, + affinity: expect.any(Number), + npcState: 4, + npcOrg: 4, + expLevel: 0, + dedLevel: 1, + })) + ); + for (const volunteer of createdVolunteerIdentity) { + expect(volunteer.affinity).toBeGreaterThanOrEqual(1); + expect(volunteer.affinity).toBeLessThanOrEqual(150); + } expect(reservedTurns.peekDirtyState().generalInitializationIds.sort((left, right) => left - right)).toEqual( createdIds ); @@ -527,6 +549,22 @@ databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence', ).toEqual(Array.from({ length: 30 }, (_, turnIdx) => ({ turnIdx, action: '휴식', args: {} }))); } + const persistedVolunteerIdentity = ( + await db.general.findMany({ + where: { id: { in: createdIds } }, + select: { id: true, affinity: true, npcState: true, meta: true }, + orderBy: { id: 'asc' }, + }) + ).map((general) => ({ + id: general.id, + affinity: general.affinity, + npcState: general.npcState, + npcOrg: asRecord(general.meta).npc_org, + expLevel: asRecord(general.meta).explevel, + dedLevel: asRecord(general.meta).dedlevel, + })); + expect(persistedVolunteerIdentity).toEqual(createdVolunteerIdentity); + const persistedNation = await db.nation.findUnique({ where: { id: nationId }, select: { meta: true } }); expect(persistedNation?.meta).toMatchObject({ gennum: 4 }); @@ -534,9 +572,16 @@ databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence', expect( reloaded.snapshot.generals .filter((general) => createdIds.includes(general.id)) - .map((general) => general.id) - .sort((left, right) => left - right) - ).toEqual(createdIds); + .map((general) => ({ + id: general.id, + affinity: general.affinity, + npcState: general.npcState, + npcOrg: asRecord(general.meta).npc_org, + expLevel: asRecord(general.meta).explevel, + dedLevel: asRecord(general.meta).dedlevel, + })) + .sort((left, right) => left.id - right.id) + ).toEqual(createdVolunteerIdentity); expect(reloaded.snapshot.nations.find((nation) => nation.id === nationId)?.meta).toMatchObject({ gennum: 4 }); const reloadedReservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 30, diff --git a/tools/integration-tests/test/turnCommandRiskDurabilityMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandRiskDurabilityMatrix.integration.test.ts new file mode 100644 index 00000000..b9c40d60 --- /dev/null +++ b/tools/integration-tests/test/turnCommandRiskDurabilityMatrix.integration.test.ts @@ -0,0 +1,1227 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { asRecord } from '@sammo-ts/common'; +import { + GENERAL_TURN_COMMAND_KEYS, + NATION_TURN_COMMAND_KEYS, + type GeneralTurnCommandKey, + type NationTurnCommandKey, +} from '@sammo-ts/logic'; +import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js'; +import { createReservedTurnHandler } from '@sammo-ts/game-engine/turn/reservedTurnHandler.js'; +import { InMemoryReservedTurnStore } from '@sammo-ts/game-engine/turn/reservedTurnStore.js'; +import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js'; +import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; +import { loadTurnWorldFromDatabase } from '@sammo-ts/game-engine/turn/worldLoader.js'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { + commandDurabilityEvidence, + generalCommandDurabilityRisk, + nationCommandDurabilityRisk, + type CommandDurabilityRisk, +} from '../src/turn-differential/commandDurabilityRisk.js'; +import { + canonicalizeTurnCommandArgs, + type CanonicalTurnSnapshot, + type TurnSnapshotSelector, +} from '../src/turn-differential/canonical.js'; +import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; +import { + clearCoreTurnCommandPersistenceFixture, + seedCoreTurnCommandPersistenceFixture, +} from '../src/turn-differential/coreCommandPersistenceFixture.js'; +import { + buildCoreTurnCommandWorldInput, + createCoreTurnCommandProfile, + resolveCoreTurnCommandArgs, + runCoreTurnCommandTrace, + type TurnCommandFixtureRequest, +} from '../src/turn-differential/coreCommandTrace.js'; +import { readCoreDatabaseSnapshot } from '../src/turn-differential/databaseSnapshot.js'; +import { normalizeStoredTurnLogText, orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js'; +import { projectSemanticTurnMessages } from '../src/turn-differential/messageProjection.js'; +import { + findTurnDifferentialWorkspaceRoot, + runReferenceTurnCommandTraceRequest, +} from '../src/turn-differential/referenceSnapshot.js'; + +const databaseUrl = process.env.TURN_COMMAND_DURABLE_MATRIX_DATABASE_URL; +const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd()); +const referenceSourceRoot = workspaceRoot + ? path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot, 'ref/sam')) + : null; +const hasReferenceRunner = + referenceSourceRoot !== null && fs.existsSync(path.join(referenceSourceRoot, 'hwe/compare/turn_command_trace.php')); +const databaseIntegration = describe.skipIf( + !databaseUrl || !workspaceRoot || !hasReferenceRunner || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1' +); +const dedicatedSuffix = 'turn_command_durable_matrix'; +const leaseOwner = 'turn-command-durable-matrix-daemon'; +const siblingRulerGeneralId = 3; +const siblingGeneralTurnRevisionSentinel = { + generalId: siblingRulerGeneralId, + revision: 37, + leaseOwner, + leaseExpiresAt: new Date('2099-08-24T12:34:56.789Z'), +}; + +const buildSiblingNationTurnRevisionSentinel = (actorNationId: number, actorOfficerLevel: number) => ({ + nationId: actorOfficerLevel === 5 ? actorNationId : 2, + officerLevel: 12, + revision: 41, + leaseOwner, + leaseExpiresAt: new Date('2099-08-24T23:45:01.234Z'), +}); + +const readSiblingTurnRevisionSentinels = async ( + db: GamePrismaClient, + siblingNationSentinel: ReturnType +) => ({ + general: await db.generalTurnRevision.findUniqueOrThrow({ + where: { generalId: siblingGeneralTurnRevisionSentinel.generalId }, + select: { generalId: true, revision: true, leaseOwner: true, leaseExpiresAt: true }, + }), + nation: await db.nationTurnRevision.findUniqueOrThrow({ + where: { + nationId_officerLevel: { + nationId: siblingNationSentinel.nationId, + officerLevel: siblingNationSentinel.officerLevel, + }, + }, + select: { nationId: true, officerLevel: true, revision: true, leaseOwner: true, leaseExpiresAt: true }, + }), +}); + +export const assertDedicatedTurnCommandDurableMatrixDatabase = (rawUrl: string): void => { + const url = new URL(rawUrl); + const schema = url.searchParams.get('schema'); + const databaseName = decodeURIComponent(url.pathname.replace(/^\/+/, '')); + if (!schema?.endsWith(dedicatedSuffix) && !databaseName.endsWith(dedicatedSuffix)) { + throw new Error( + `Refusing to mutate non-dedicated turn command durable matrix database: schema=${schema ?? '(missing)'}, database=${databaseName || '(missing)'}` + ); + } +}; + +describe('turn command durability risk manifest', () => { + it('classifies the exact 55 general and 35 nation command registries without duplicates', () => { + expect(Object.keys(generalCommandDurabilityRisk).sort()).toEqual([...GENERAL_TURN_COMMAND_KEYS].sort()); + expect(Object.keys(nationCommandDurabilityRisk).sort()).toEqual([...NATION_TURN_COMMAND_KEYS].sort()); + expect(Object.keys(generalCommandDurabilityRisk)).toHaveLength(55); + expect(Object.keys(nationCommandDurabilityRisk)).toHaveLength(35); + }); + + it('keeps the reviewed R1/R2/R3 population stable and explicit', () => { + const counts = (values: CommandDurabilityRisk[]) => + Object.fromEntries( + (['R1', 'R2', 'R3'] as const).map((risk) => [risk, values.filter((value) => value === risk).length]) + ); + + expect(counts(Object.values(generalCommandDurabilityRisk))).toEqual({ R1: 14, R2: 16, R3: 25 }); + expect(counts(Object.values(nationCommandDurabilityRisk))).toEqual({ R1: 12, R2: 3, R3: 20 }); + }); + + it('assigns a dedicated PostgreSQL representative to every scope/risk cell and stronger R3 facet', () => { + const matrixCells = new Set( + commandDurabilityEvidence + .filter((entry) => entry.matrixRepresentative) + .map((entry) => `${entry.scope}:${entry.risk}`) + ); + const requiredCells = (['general', 'nation'] as const).flatMap((scope) => + (['R1', 'R2', 'R3'] as const).map((risk) => `${scope}:${risk}`) + ); + expect([...matrixCells].sort()).toEqual(requiredCells.sort()); + + const facets = new Set(commandDurabilityEvidence.map((entry) => entry.facet)); + expect(facets).toEqual( + new Set([ + 'single-actor', + 'local-aggregate', + 'cross-entity', + 'placement-topology', + 'hostile-rng-destructive', + 'diplomacy-strategy', + 'entity-creation-fanout', + 'retirement-archive', + 'multi-turn-research', + ]) + ); + }); + + it('keeps every declared PostgreSQL representative synchronized with its typed risk inventory and executed case', () => { + const evidence = commandDurabilityEvidence + .filter((entry) => entry.matrixRepresentative) + .map(({ scope, risk, command }) => `${scope}:${risk}:${command}`) + .sort(); + const cases = riskMatrixCases.map(({ scope, risk, action }) => `${scope}:${risk}:${action}`).sort(); + expect(cases).toEqual(evidence); + + for (const entry of riskMatrixCases) { + const classifiedRisk = + entry.scope === 'general' + ? generalCommandDurabilityRisk[entry.action] + : nationCommandDurabilityRisk[entry.action]; + expect(entry.risk, `${entry.scope}:${entry.action}`).toBe(classifiedRisk); + } + }); +}); + +describe('turn command durable matrix database guard', () => { + it('rejects a shared database and schema before connecting', () => { + expect(() => + assertDedicatedTurnCommandDurableMatrixDatabase( + 'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=public' + ) + ).toThrow('Refusing to mutate non-dedicated turn command durable matrix database'); + }); + + it('accepts only an explicitly dedicated schema or database name', () => { + expect(() => + assertDedicatedTurnCommandDurableMatrixDatabase( + 'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=ci_turn_command_durable_matrix' + ) + ).not.toThrow(); + expect(() => + assertDedicatedTurnCommandDurableMatrixDatabase( + 'postgresql://fixture:fixture@127.0.0.1:5432/ci_turn_command_durable_matrix' + ) + ).not.toThrow(); + }); +}); + +const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record => ({ + id, + name: `위험행렬장수${id}`, + nationId, + cityId, + troopId: 0, + leadership: 90, + strength: 80, + intelligence: 70, + leadershipExp: 0, + strengthExp: 0, + intelExp: 0, + experience: 1_000, + dedication: 1_000, + expLevel: 0, + officerLevel, + officerCityId: officerLevel >= 5 ? cityId : 0, + belong: 10, + permission: 'normal', + injury: 0, + age: 30, + gold: 100_000, + rice: 100_000, + crew: 1_000, + crewTypeId: 1_100, + train: 50, + atmos: 50, + killTurn: 24, + npcState: 0, + blockState: 0, + personality: 'None', + specialDomestic: 'None', + specialWar: 'None', + itemHorse: 'None', + itemWeapon: 'None', + itemBook: 'None', + itemExtra: 'None', + meta: {}, +}); + +interface RiskMatrixCaseBase { + label: string; + risk: CommandDurabilityRisk; + args?: Record; + nationPatch?: Record; + cityPatch?: Record; +} + +type RiskMatrixCase = + | (RiskMatrixCaseBase & { scope: 'general'; action: GeneralTurnCommandKey }) + | (RiskMatrixCaseBase & { scope: 'nation'; action: NationTurnCommandKey }); + +const riskMatrixCases: RiskMatrixCase[] = [ + { label: 'general actor aggregate', scope: 'general', risk: 'R1', action: 'che_훈련' }, + { label: 'general/city cross aggregate', scope: 'general', risk: 'R2', action: 'che_농지개간' }, + { + label: 'general multi-party resource transfer', + scope: 'general', + risk: 'R3', + action: 'che_증여', + args: { isGold: true, amount: 100, destGeneralID: 3 }, + }, + { + label: 'general placement topology', + scope: 'general', + risk: 'R3', + action: 'che_이동', + args: { destCityID: 70 }, + }, + { + label: 'nation aggregate', + scope: 'nation', + risk: 'R1', + action: 'che_국호변경', + args: { nationName: '위험행렬국' }, + }, + { + label: 'nation/capital topology', + scope: 'nation', + risk: 'R2', + action: 'che_증축', + nationPatch: { + capitalRevision: 0, + turnLastByOfficerLevel: { 12: { command: '증축', arg: {}, term: 5, seq: 0 } }, + }, + cityPatch: { level: 7 }, + }, + { + label: 'nation diplomacy relationship', + scope: 'nation', + risk: 'R3', + action: 'che_선전포고', + args: { destNationID: 2 }, + }, + { + label: 'nation cross-entity material aid', + scope: 'nation', + risk: 'R3', + action: 'che_물자원조', + args: { destNationID: 2, amountList: [100, 200] }, + nationPatch: { + turnLastByOfficerLevel: { + 12: { command: '국호 변경', arg: { nationName: '수뇌보존국' }, term: 7, seq: 3 }, + }, + }, + }, + { + label: 'nation multi-turn research completion', + scope: 'nation', + risk: 'R1', + action: 'event_원융노병연구', + nationPatch: { + turnLastByOfficerLevel: { 12: { command: '원융노병 연구', term: 23 } }, + }, + }, +]; + +const buildRiskMatrixRequest = (entry: RiskMatrixCase): TurnCommandFixtureRequest => { + const validatesMinimumChiefBoundary = entry.action === 'che_물자원조'; + const actorOfficerLevel = validatesMinimumChiefBoundary ? 5 : 12; + const siblingRulerTurns = validatesMinimumChiefBoundary + ? Array.from({ length: 12 }, (_, turnIndex) => ({ + nationId: 1, + officerLevel: 12, + turnIndex, + action: turnIndex === 0 ? 'che_국호변경' : '휴식', + args: turnIndex === 0 ? { nationName: '수뇌보존대기국' } : {}, + })) + : []; + + return { + kind: entry.scope, + actorGeneralId: 1, + action: entry.action, + ...(entry.args ? { args: entry.args } : {}), + includeLifecycle: true, + setup: { + isolateWorld: true, + world: { + startYear: 180, + year: 190, + month: 1, + hiddenSeed: `turn-command-durable-${entry.scope}-${entry.action}`, + freezeClock: true, + }, + nations: [ + { + id: 1, + name: '아국', + color: '#777777', + capitalCityId: 3, + gold: 1_000_000, + rice: 1_000_000, + tech: 1_000, + level: 1, + typeCode: 'che_명가', + war: 0, + diplomacyLimit: 0, + strategicCommandLimit: 0, + generalCount: 2, + meta: { can_국호변경: 1, can_국기변경: 1, surlimit: 0 }, + ...entry.nationPatch, + }, + { + id: 2, + name: '타국', + color: '#888888', + capitalCityId: 71, + gold: 1_000_000, + rice: 1_000_000, + tech: 1_000, + level: 1, + typeCode: 'che_명가', + war: 0, + diplomacyLimit: 0, + strategicCommandLimit: 0, + generalCount: 1, + meta: { surlimit: 0 }, + }, + ], + cities: [ + { + id: 3, + nationId: 1, + level: 5, + population: 100_000, + populationMax: 200_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + supplyState: 1, + frontState: 0, + state: 0, + term: 0, + trust: 80, + trade: 100, + ...entry.cityPatch, + }, + { + id: 70, + nationId: 1, + level: 5, + population: 100_000, + populationMax: 200_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + supplyState: 1, + frontState: 1, + state: 0, + term: 0, + trust: 80, + trade: 100, + }, + { + id: 71, + nationId: 2, + level: 5, + population: 100_000, + populationMax: 200_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + supplyState: 1, + frontState: 1, + state: 0, + term: 0, + trust: 80, + trade: 100, + }, + ], + generals: [ + general(1, 1, 3, actorOfficerLevel), + general(2, 2, 71, 12), + general(siblingRulerGeneralId, 1, 3, validatesMinimumChiefBoundary ? 12 : 1), + ], + diplomacy: [ + { fromNationId: 1, toNationId: 2, state: 3, term: 0, dead: 0 }, + { fromNationId: 2, toNationId: 1, state: 3, term: 0, dead: 0 }, + ], + ...(entry.scope === 'general' + ? { + generalTurns: Array.from({ length: 30 }, (_, turnIndex) => ({ + generalId: 1, + turnIndex, + action: turnIndex === 0 ? entry.action : '휴식', + args: turnIndex === 0 ? (entry.args ?? {}) : {}, + })), + } + : { + nationTurns: [ + ...Array.from({ length: 12 }, (_, turnIndex) => ({ + nationId: 1, + officerLevel: actorOfficerLevel, + turnIndex, + action: turnIndex === 0 ? entry.action : '휴식', + args: turnIndex === 0 ? (entry.args ?? {}) : {}, + })), + ...siblingRulerTurns, + ], + }), + }, + observe: { + allGenerals: true, + allCities: true, + allNations: true, + allTroops: true, + generalIds: [1, 2, 3], + cityIds: [3, 70, 71], + nationIds: [1, 2], + troopIds: [], + includeRankMirrors: true, + logAfterId: 0, + messageAfterId: 0, + includeNationHistoryLogs: true, + includeGlobalHistoryLogs: true, + }, + }; +}; + +const lifecycleIgnoredPaths = [ + /^generalTurns/, + /^nationTurns/, + /^logs/, + /^messages/, + /^world\.turnTime$/, + /^world\.gameNow$/, + /^world\.lastTurnTick$/, + /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet|turnTick|turnSecond|turnFraction)(?:\.|$)/, + /^generals\[[^\]]+\]\.meta(?:\.|$)/, + /^nations\[[^\]]+\]\.meta\.(?:turn_last_\d+|next_execute_.+|capset|tech|gennum|power|war|surlimit|strategic_cmd_limit)(?:\.|$)/, +]; + +const addedReferenceLogs = ( + before: { watermarks: { logId: number; historyLogId: number } }, + after: Array> +): Array> => + after.filter((entry) => { + const scope = String(entry.scope).toLowerCase(); + const category = String(entry.category).toLowerCase(); + const watermark = + scope === 'nation' || (scope === 'system' && category === 'history') + ? before.watermarks.historyLogId + : before.watermarks.logId; + return Number(entry.id) > watermark; + }); + +const isTrailingDefaultGeneralRestLog = (entry: Record): boolean => + String(entry.scope).toLowerCase() === 'general' && + String(entry.category).toLowerCase() === 'action' && + normalizeStoredTurnLogText(entry.text) === '아무것도 실행하지 않았습니다.'; + +const withoutVolatileGameNow = ({ world, ...snapshot }: CanonicalTurnSnapshot) => { + const { gameNow: _gameNow, ...stableWorld } = world; + return { ...snapshot, world: stableWorld }; +}; + +const projectDatabaseIndependentTurnMessages = ( + messages: CanonicalTurnSnapshot['messages'], + messageAfterId: number +) => { + const newMessages = messages.filter((message) => Number(message.id) > messageAfterId); + const projected = projectSemanticTurnMessages(messages, messageAfterId); + const projectedById = new Map(newMessages.map((message, index) => [Number(message.id), projected[index]!])); + + return projected.map((message) => { + const option = + typeof message.option === 'object' && message.option !== null && !Array.isArray(message.option) + ? (message.option as Record) + : null; + if (!option || option.receiverMessageID === undefined) return message; + + if ( + typeof option.receiverMessageID !== 'number' || + !Number.isSafeInteger(option.receiverMessageID) || + option.receiverMessageID <= 0 + ) { + throw new Error(`message receiverMessageID must be a positive safe integer number`); + } + const receiverMessageId = option.receiverMessageID; + const receiverCopy = projectedById.get(receiverMessageId); + if ( + !receiverCopy || + receiverCopy.mailbox !== message.destinationId || + receiverCopy.type !== message.type || + receiverCopy.sourceId !== message.sourceId || + receiverCopy.destinationId !== message.destinationId || + receiverCopy.text !== message.text + ) { + throw new Error(`message receiverMessageID ${String(option.receiverMessageID)} is not its receiver copy`); + } + + // Ref MariaDB and the dedicated PostgreSQL schema have independent + // sequences. Keep the cross-row link exact, but compare its database- + // local numeric key by relation rather than by an impossible shared id. + return { + ...message, + option: { ...option, receiverMessageID: 'receiver-copy' }, + }; + }); +}; + +const readForbiddenSideEffects = async (db: GamePrismaClient) => ({ + inputEvents: await db.inputEvent.count(), + webPushOutbox: await db.webPushOutbox.count(), + events: await db.event.count(), + auctions: await db.auction.count(), + auctionBids: await db.auctionBid.count(), +}); + +const expectNoDirtyWorldChanges = (world: InMemoryTurnWorld): void => { + const { realtimeBacklogShiftTicks, ...arrayChanges } = world.peekDirtyState(); + expect(realtimeBacklogShiftTicks).toBe(0); + for (const [changeName, entries] of Object.entries(arrayChanges)) { + expect(entries, `world dirty state ${changeName}`).toEqual([]); + } +}; + +const projectReloadedGeneralTurns = (store: InMemoryReservedTurnStore, generalId: number) => + store.getGeneralTurns(generalId).map((turn, turnIndex) => ({ + generalId, + turnIndex, + action: turn.action, + args: turn.args, + })); + +const projectReloadedNationTurns = (store: InMemoryReservedTurnStore, nationId: number, officerLevel: number) => + store.getNationTurns(nationId, officerLevel).map((turn, turnIndex) => ({ + nationId, + officerLevel, + turnIndex, + action: turn.action, + args: turn.args, + })); + +type ReloadedWorldSnapshot = Awaited>['snapshot']; + +const projectReloadableWorldGraph = ( + snapshot: Pick, + selector: TurnSnapshotSelector +) => { + const selectedIds = (all: boolean | undefined, ids: readonly number[]) => (all ? null : new Set(ids)); + const generalIds = selectedIds(selector.allGenerals, selector.generalIds); + const cityIds = selectedIds(selector.allCities, selector.cityIds); + const nationIds = selectedIds(selector.allNations, selector.nationIds); + const troopIds = selectedIds(selector.allTroops, selector.troopIds ?? []); + const byId = (rows: readonly Row[], ids: Set | null) => + structuredClone(rows) + .filter((row) => ids === null || ids.has(row.id)) + .sort((left, right) => left.id - right.id); + const generals = byId(snapshot.generals, generalIds).map((general) => { + const { itemInventory: _persistedItemInventory, legacyScanOrder: _legacyScanOrder, ...meta } = general.meta; + return { ...general, meta }; + }); + const nations = byId(snapshot.nations, nationIds).map((nation) => { + const { power: _projectedPower, ...meta } = nation.meta; + return { ...nation, meta }; + }); + + return { + // The database loader reconstructs itemInventory from its canonical + // top-level field and may materialize projected defaults in meta. Keep + // those three storage/fixture duplicates out, while comparing every + // command-owned entity field (including top-level itemInventory) exact. + generals, + cities: byId(snapshot.cities, cityIds), + nations, + troops: byId(snapshot.troops, troopIds), + diplomacy: structuredClone(snapshot.diplomacy) + .filter( + (entry) => nationIds === null || (nationIds.has(entry.fromNationId) && nationIds.has(entry.toNationId)) + ) + .sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId), + }; +}; + +const projectCanonicalCommandLifecycleState = ( + snapshot: CanonicalTurnSnapshot, + actorGeneralId: number, + nationId: number, + officerLevel: number +) => { + const general = snapshot.generals.find((row) => row.id === actorGeneralId); + const nation = snapshot.nations.find((row) => row.id === nationId); + if (!general) throw new Error(`canonical lifecycle actor is missing: ${actorGeneralId}`); + if (!nation) throw new Error(`canonical lifecycle nation is missing: ${nationId}`); + if (typeof nation.meta !== 'object' || nation.meta === null || Array.isArray(nation.meta)) { + throw new Error(`canonical lifecycle nation meta is invalid: ${nationId}`); + } + const nationMeta = nation.meta as Record; + return { + generalLastTurn: general.lastTurn, + nationOfficerLastTurn: nationMeta[`turn_last_${officerLevel}`], + nationCapitalRevision: nation.capitalRevision, + nationCapset: nationMeta.capset, + }; +}; + +const projectDomainCommandLifecycleState = ( + snapshot: Pick, + actorGeneralId: number, + nationId: number, + officerLevel: number +) => { + const general = snapshot.generals.find((row) => row.id === actorGeneralId); + const nation = snapshot.nations.find((row) => row.id === nationId); + if (!general) throw new Error(`domain lifecycle actor is missing: ${actorGeneralId}`); + if (!nation) throw new Error(`domain lifecycle nation is missing: ${nationId}`); + return { + generalLastTurn: general.lastTurn, + nationOfficerLastTurn: nation.meta[`turn_last_${officerLevel}`], + nationCapitalRevision: nation.meta.capset, + nationCapset: nation.meta.capset, + }; +}; + +const requireLastTurnRecord = (value: unknown, label: string): Record => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const record = value as Record; + if (typeof record.command !== 'string' || record.command === '') { + throw new Error(`${label}.command must be a non-empty string`); + } + return record; +}; + +const projectSemanticLastTurn = (value: unknown, label: string) => { + const record = requireLastTurnRecord(value, label); + const finiteIntegerOrZero = (candidate: unknown): number => + typeof candidate === 'number' && Number.isFinite(candidate) ? Math.floor(candidate) : 0; + const rawArg = record.arg ?? {}; + return { + command: record.command, + // PHP's no-argument LastTurn serializes `arg` as [], while Core's typed + // command contract uses {}. A non-empty list remains significant. + arg: Array.isArray(rawArg) && rawArg.length === 0 ? {} : canonicalizeTurnCommandArgs(rawArg), + term: finiteIntegerOrZero(record.term), + seq: finiteIntegerOrZero(record.seq), + }; +}; + +const projectReferenceOutcomeLastTurn = (outcome: unknown): unknown => { + const record = asRecord(outcome); + return record.lastTurn; +}; + +const compareCanonicalGeneralTurns = (left: Record, right: Record): number => + Number(left.generalId) - Number(right.generalId) || Number(left.turnIndex) - Number(right.turnIndex); + +const compareCanonicalNationTurns = (left: Record, right: Record): number => + Number(left.nationId) - Number(right.nationId) || + Number(left.officerLevel) - Number(right.officerLevel) || + Number(left.turnIndex) - Number(right.turnIndex); + +databaseIntegration('risk-based command PostgreSQL durability matrix', () => { + let db: GamePrismaClient | undefined; + let disconnect: (() => Promise) | undefined; + + beforeAll(async () => { + assertDedicatedTurnCommandDurableMatrixDatabase(databaseUrl!); + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + disconnect = () => connector.disconnect(); + await clearCoreTurnCommandPersistenceFixture(db); + }); + + beforeEach(async () => { + if (db) await clearCoreTurnCommandPersistenceFixture(db); + }); + + afterAll(async () => { + try { + if (db) await clearCoreTurnCommandPersistenceFixture(db); + } finally { + await disconnect?.(); + } + }); + + it.each(riskMatrixCases)( + '$scope $risk $action ($label) matches Ref/Core, commits through one flush, and survives a fresh PostgreSQL reload', + async (entry) => { + if (!db) throw new Error('fixture database is not connected'); + const request = buildRiskMatrixRequest(entry); + const selector = request.observe as TurnSnapshotSelector; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const expected = await runCoreTurnCommandTrace(request, reference.before); + expect(expected.execution.outcome).toMatchObject({ + requestedAction: entry.action, + actionKey: entry.action, + usedFallback: false, + }); + expect(expected.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, expected.before, expected.after, { + ignoredPathPatterns: lifecycleIgnoredPaths, + }) + ).toEqual([]); + + const unitSet = await loadUnitSetDefinitionByName('che'); + const map = await loadMapDefinitionByName('che'); + const worldInput = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map); + const actorBefore = reference.before.generals.find( + (generalRow) => generalRow.id === request.actorGeneralId + ); + if (!actorBefore) throw new Error('fixture actor is missing from the reference before snapshot'); + const actorNationId = Number(actorBefore.nationId); + const actorOfficerLevel = Number(actorBefore.officerLevel); + if (!Number.isSafeInteger(actorNationId) || !Number.isSafeInteger(actorOfficerLevel)) { + throw new Error('fixture actor nation/officer identity is invalid'); + } + const validatesMinimumChiefBoundary = entry.action === 'che_물자원조'; + if (validatesMinimumChiefBoundary) { + expect(actorOfficerLevel).toBe(5); + expect( + expected.before.generals.find((generalRow) => generalRow.id === siblingRulerGeneralId) + ).toMatchObject({ nationId: actorNationId, officerLevel: 12 }); + } + const siblingNationTurnRevisionSentinel = buildSiblingNationTurnRevisionSentinel( + actorNationId, + actorOfficerLevel + ); + const expectedSiblingTurnRevisionSentinels = { + general: siblingGeneralTurnRevisionSentinel, + nation: siblingNationTurnRevisionSentinel, + }; + const expectedBeforeLifecycle = projectCanonicalCommandLifecycleState( + expected.before, + request.actorGeneralId, + actorNationId, + actorOfficerLevel + ); + const expectedAfterLifecycle = projectCanonicalCommandLifecycleState( + expected.after, + request.actorGeneralId, + actorNationId, + actorOfficerLevel + ); + requireLastTurnRecord(expectedAfterLifecycle.generalLastTurn, 'expected actor lastTurn'); + requireLastTurnRecord(expectedAfterLifecycle.nationOfficerLastTurn, 'expected officer turn_last'); + const referenceAfterLifecycle = projectCanonicalCommandLifecycleState( + reference.after, + request.actorGeneralId, + actorNationId, + actorOfficerLevel + ); + if (entry.scope === 'general') { + expect( + projectSemanticLastTurn(referenceAfterLifecycle.generalLastTurn, 'Ref actor lastTurn') + ).toStrictEqual(projectSemanticLastTurn(expectedAfterLifecycle.generalLastTurn, 'Core actor lastTurn')); + expect(expectedAfterLifecycle.nationOfficerLastTurn).toStrictEqual({ command: '휴식', term: 0 }); + } else { + // Ref snapshots project nation.aux but not nation_env. The trace + // outcome is the exact resultTurnRaw value written to + // nation_env.turn_last_ by the comparison harness. + expect( + projectSemanticLastTurn( + projectReferenceOutcomeLastTurn(reference.execution.outcome), + 'Ref officer turn_last outcome' + ) + ).toStrictEqual( + projectSemanticLastTurn(expectedAfterLifecycle.nationOfficerLastTurn, 'Core officer turn_last') + ); + expect(expectedAfterLifecycle.generalLastTurn).toStrictEqual({ command: '휴식' }); + } + if (entry.action === 'che_증축') { + const referenceBeforeLifecycle = projectCanonicalCommandLifecycleState( + reference.before, + request.actorGeneralId, + actorNationId, + actorOfficerLevel + ); + // Ref persists capset as nation.capset; Core mirrors that column + // into both canonical capitalRevision and the domain meta value. + expect(referenceBeforeLifecycle.nationCapitalRevision).toBe(0); + expect(expectedBeforeLifecycle).toMatchObject({ nationCapitalRevision: 0, nationCapset: 0 }); + expect(referenceAfterLifecycle.nationCapitalRevision).toBe(1); + expect(expectedAfterLifecycle).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 }); + } + const expectedSiblingRulerLifecycle = validatesMinimumChiefBoundary + ? projectCanonicalCommandLifecycleState(expected.before, siblingRulerGeneralId, actorNationId, 12) + : null; + if (expectedSiblingRulerLifecycle) { + expect( + projectCanonicalCommandLifecycleState(expected.after, siblingRulerGeneralId, actorNationId, 12) + ).toStrictEqual(expectedSiblingRulerLifecycle); + expect(expectedSiblingRulerLifecycle.nationOfficerLastTurn).toStrictEqual({ + command: '국호 변경', + arg: { nationName: '수뇌보존국' }, + term: 7, + seq: 3, + }); + } + await seedCoreTurnCommandPersistenceFixture(db, { + worldInput, + scenarioCode: 'turn-command-durable-matrix', + generalTurns: reference.before.generalTurns.map((turn) => + entry.scope === 'general' && turn.generalId === request.actorGeneralId && turn.turnIndex === 0 + ? { ...turn, args: resolveCoreTurnCommandArgs(request) } + : turn + ), + nationTurns: reference.before.nationTurns.map((turn) => + entry.scope === 'nation' && + turn.nationId === actorBefore.nationId && + turn.officerLevel === actorBefore.officerLevel && + turn.turnIndex === 0 + ? { ...turn, args: resolveCoreTurnCommandArgs(request) } + : turn + ), + }); + // Keep the active daemon owner on both sentinels deliberately. A + // flush that releases leases by owner instead of by exact queue + // key would corrupt these unrelated command streams. + await db.generalTurnRevision.create({ data: siblingGeneralTurnRevisionSentinel }); + await db.nationTurnRevision.create({ data: siblingNationTurnRevisionSentinel }); + expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual( + expectedSiblingTurnRevisionSentinels + ); + const forbiddenSideEffectsBefore = await readForbiddenSideEffects(db); + const databaseBefore = await readCoreDatabaseSnapshot(databaseUrl!, selector); + expect( + projectCanonicalCommandLifecycleState( + databaseBefore, + request.actorGeneralId, + actorNationId, + actorOfficerLevel + ) + ).toStrictEqual(expectedBeforeLifecycle); + const siblingRulerTurnQueueBefore = validatesMinimumChiefBoundary + ? databaseBefore.nationTurns.filter( + (turn) => turn.nationId === actorNationId && turn.officerLevel === 12 + ) + : []; + if (expectedSiblingRulerLifecycle) { + expect(siblingRulerTurnQueueBefore).toHaveLength(12); + expect( + projectCanonicalCommandLifecycleState(databaseBefore, siblingRulerGeneralId, actorNationId, 12) + ).toStrictEqual(expectedSiblingRulerLifecycle); + } + if (entry.action === 'che_증축') { + expect( + projectCanonicalCommandLifecycleState( + databaseBefore, + request.actorGeneralId, + actorNationId, + actorOfficerLevel + ) + ).toMatchObject({ nationCapitalRevision: 0, nationCapset: 0 }); + } + + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + const reservedTurns = new InMemoryReservedTurnStore(db, { + maxGeneralTurns: 30, + maxNationTurns: 12, + leaseOwner, + leaseDurationMs: 60_000, + }); + await reservedTurns.loadAll(); + const loadedActor = loaded.snapshot.generals.find((generalRow) => generalRow.id === request.actorGeneralId); + if (!loadedActor) throw new Error('fixture actor is missing after database load'); + if (validatesMinimumChiefBoundary) { + expect( + loaded.snapshot.generals.find((generalRow) => generalRow.id === siblingRulerGeneralId) + ).toMatchObject({ nationId: actorNationId, officerLevel: 12 }); + } + await reservedTurns.prepareTurnsForExecution(loadedActor.id, { + nationId: loadedActor.nationId, + officerLevel: loadedActor.officerLevel, + }); + + const resolvedActions: Array<{ kind: string; requestedAction: string; usedFallback: boolean }> = []; + let world: InMemoryTurnWorld | null = null; + const gameNow = new Date(String(reference.before.world.gameNow)); + if (!Number.isFinite(gameNow.getTime())) { + throw new Error(`reference world.gameNow is invalid: ${String(reference.before.world.gameNow)}`); + } + const handler = await createReservedTurnHandler({ + reservedTurns, + scenarioConfig: loaded.snapshot.scenarioConfig, + scenarioMeta: loaded.snapshot.scenarioMeta, + map: loaded.snapshot.map, + unitSet: loaded.snapshot.unitSet, + getWorld: () => world, + now: () => new Date(gameNow.getTime()), + messageSharedIconBaseUrl: request.setup?.world?.messageSharedIconBaseUrl, + commandProfile: createCoreTurnCommandProfile(request), + onActionResolved: (resolved) => { + resolvedActions.push({ + kind: resolved.kind, + requestedAction: resolved.requestedAction, + usedFallback: resolved.usedFallback, + }); + }, + }); + world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { + schedule: { + entries: [ + { + startMinute: 0, + tickMinutes: Math.max(1, Math.round(loaded.state.tickSeconds / 60)), + }, + ], + }, + generalTurnHandler: handler, + }); + const actor = world.getGeneralById(request.actorGeneralId); + if (!actor) throw new Error('fixture actor is missing from executable world'); + world.executeGeneralTurn(actor); + expect(resolvedActions).toContainEqual({ + kind: entry.scope, + requestedAction: entry.action, + usedFallback: false, + }); + const liveAfterLifecycle = projectDomainCommandLifecycleState( + { generals: world.listGenerals(), nations: world.listNations() }, + request.actorGeneralId, + actor.nationId, + actor.officerLevel + ); + expect(liveAfterLifecycle).toStrictEqual(expectedAfterLifecycle); + if (expectedSiblingRulerLifecycle) { + expect( + projectDomainCommandLifecycleState( + { generals: world.listGenerals(), nations: world.listNations() }, + siblingRulerGeneralId, + actor.nationId, + 12 + ) + ).toStrictEqual(expectedSiblingRulerLifecycle); + } + if (entry.action === 'che_증축') { + expect(liveAfterLifecycle).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 }); + } + if (entry.action === 'che_훈련') { + const expectedActorAfter = expected.after.generals.find( + (generalRow) => generalRow.id === request.actorGeneralId + ); + const dirtyActor = world + .peekDirtyState() + .generals.find((generalRow) => generalRow.id === request.actorGeneralId); + expect(dirtyActor?.role.items.weapon).toBe(expectedActorAfter?.itemWeapon); + } + + const beforeFlush = await readCoreDatabaseSnapshot(databaseUrl!, selector); + expect(withoutVolatileGameNow(beforeFlush)).toStrictEqual(withoutVolatileGameNow(databaseBefore)); + expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual( + expectedSiblingTurnRevisionSentinels + ); + + const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); + try { + if (!hooks.hooks.flushChanges) throw new Error('database turn hooks do not expose flushChanges'); + await hooks.hooks.flushChanges({ + lastTurnTime: loaded.state.lastTurnTime.toISOString(), + processedGenerals: 1, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + const receipt = hooks.takeCommittedReadModelChangeReceipt(); + if (!receipt) throw new Error('flush did not publish a read-model change receipt'); + expect(receipt.invalidation.revisions.length).toBeGreaterThan(0); + expect( + await db.readModelRevision.findMany({ + select: { domain: true, entityId: true, revision: true }, + orderBy: [{ domain: 'asc' }, { entityId: 'asc' }], + }) + ).toEqual(receipt.invalidation.revisions); + expect(await db.readModelOutbox.count()).toBe(1); + expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull(); + } finally { + await hooks.close(); + } + + expectNoDirtyWorldChanges(world); + expect(reservedTurns.peekDirtyState()).toStrictEqual({ + generalIds: [], + generalInitializationIds: [], + generalLeaseIds: [], + nationKeys: [], + nationInitializationKeys: [], + nationLeaseKeys: [], + }); + expect(await readForbiddenSideEffects(db)).toEqual(forbiddenSideEffectsBefore); + expect(await db.inputEvent.count()).toBe(0); + expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual( + expectedSiblingTurnRevisionSentinels + ); + + const after = await readCoreDatabaseSnapshot(databaseUrl!, selector); + const expectedPersistedGeneralTurns = [ + ...databaseBefore.generalTurns.filter((turn) => turn.generalId !== request.actorGeneralId), + ...expected.after.generalTurns.filter((turn) => turn.generalId === request.actorGeneralId), + ].sort(compareCanonicalGeneralTurns); + const expectedPersistedNationTurns = [ + ...databaseBefore.nationTurns.filter( + (turn) => turn.nationId !== actor.nationId || turn.officerLevel !== actor.officerLevel + ), + ...expected.after.nationTurns.filter( + (turn) => turn.nationId === actor.nationId && turn.officerLevel === actor.officerLevel + ), + ].sort(compareCanonicalNationTurns); + expect(after.generalTurns).toStrictEqual(expectedPersistedGeneralTurns); + expect(after.nationTurns).toStrictEqual(expectedPersistedNationTurns); + if (expectedSiblingRulerLifecycle) { + expect( + after.nationTurns.filter((turn) => turn.nationId === actor.nationId && turn.officerLevel === 12) + ).toStrictEqual(siblingRulerTurnQueueBefore); + expect( + projectCanonicalCommandLifecycleState(after, siblingRulerGeneralId, actor.nationId, 12) + ).toStrictEqual(expectedSiblingRulerLifecycle); + } + expect( + projectCanonicalCommandLifecycleState(after, request.actorGeneralId, actor.nationId, actor.officerLevel) + ).toStrictEqual(expectedAfterLifecycle); + if (entry.action === 'che_증축') { + expect( + projectCanonicalCommandLifecycleState( + after, + request.actorGeneralId, + actor.nationId, + actor.officerLevel + ) + ).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 }); + } + expect( + await db.generalTurnRevision.findMany({ + select: { generalId: true, revision: true, leaseOwner: true, leaseExpiresAt: true }, + orderBy: { generalId: 'asc' }, + }) + ).toStrictEqual([ + { generalId: request.actorGeneralId, revision: 1, leaseOwner: null, leaseExpiresAt: null }, + siblingGeneralTurnRevisionSentinel, + ]); + expect( + await db.nationTurnRevision.findMany({ + select: { + nationId: true, + officerLevel: true, + revision: true, + leaseOwner: true, + leaseExpiresAt: true, + }, + orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }], + }) + ).toStrictEqual([ + { + nationId: actor.nationId, + officerLevel: actor.officerLevel, + revision: 1, + leaseOwner: null, + leaseExpiresAt: null, + }, + siblingNationTurnRevisionSentinel, + ]); + if (entry.action === 'che_물자원조') { + const sourceNation = after.nations.find((nation) => nation.id === 1); + const destinationNation = after.nations.find((nation) => nation.id === 2); + expect(sourceNation).toMatchObject({ gold: 999_900, rice: 999_800 }); + expect(destinationNation).toMatchObject({ gold: 1_000_100, rice: 1_000_200 }); + expect(asRecord(sourceNation?.meta).surlimit).toBe(12); + expect(asRecord(asRecord(destinationNation?.meta).recv_assist).n1).toEqual([1, 300]); + } + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, databaseBefore, after, { + ignoredPathPatterns: lifecycleIgnoredPaths, + }) + ).toEqual([]); + expect( + compareTurnSnapshotDeltas(expected.before, expected.after, databaseBefore, after, { + ignoredPathPatterns: lifecycleIgnoredPaths, + }) + ).toEqual([]); + const trailingDefaultGeneralRestLogs = after.logs.filter(isTrailingDefaultGeneralRestLog); + if (entry.scope === 'nation') { + // The production lifecycle always follows an officer's nation command with + // that officer's general queue. turn_command_trace.php intentionally executes + // only the requested command, so assert and remove this one known harness delta. + expect(trailingDefaultGeneralRestLogs).toHaveLength(1); + } else { + expect(trailingDefaultGeneralRestLogs).toHaveLength(0); + } + const comparablePersistedLogs = after.logs.filter( + (log) => entry.scope !== 'nation' || !isTrailingDefaultGeneralRestLog(log) + ); + expect(orderedSemanticLogStreams(comparablePersistedLogs)).toEqual( + orderedSemanticLogStreams(addedReferenceLogs(reference.before, reference.after.logs)) + ); + expect(projectDatabaseIndependentTurnMessages(after.messages, 0)).toEqual( + projectDatabaseIndependentTurnMessages(reference.after.messages, reference.before.watermarks.messageId) + ); + + const committedWorldGraph = projectReloadableWorldGraph( + { + generals: world.listGenerals(), + cities: world.listCities(), + nations: world.listNations(), + troops: world.listTroops(), + diplomacy: world.listDiplomacy(), + }, + selector + ); + const reloadedWorld = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect(projectReloadableWorldGraph(reloadedWorld.snapshot, selector)).toStrictEqual(committedWorldGraph); + const reloadedLifecycle = projectDomainCommandLifecycleState( + reloadedWorld.snapshot, + request.actorGeneralId, + actor.nationId, + actor.officerLevel + ); + expect(reloadedLifecycle).toStrictEqual(expectedAfterLifecycle); + if (expectedSiblingRulerLifecycle) { + expect( + projectDomainCommandLifecycleState( + reloadedWorld.snapshot, + siblingRulerGeneralId, + actor.nationId, + 12 + ) + ).toStrictEqual(expectedSiblingRulerLifecycle); + } + if (entry.action === 'che_증축') { + expect(reloadedLifecycle).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 }); + } + + const reloadedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 30, maxNationTurns: 12 }); + await reloadedTurns.loadAll(); + expect(projectReloadedGeneralTurns(reloadedTurns, request.actorGeneralId)).toStrictEqual( + expected.after.generalTurns.filter((turn) => turn.generalId === request.actorGeneralId) + ); + expect(projectReloadedNationTurns(reloadedTurns, actor.nationId, actor.officerLevel)).toStrictEqual( + expected.after.nationTurns.filter( + (turn) => turn.nationId === actor.nationId && turn.officerLevel === actor.officerLevel + ) + ); + if (expectedSiblingRulerLifecycle) { + expect(projectReloadedNationTurns(reloadedTurns, actor.nationId, 12)).toStrictEqual( + siblingRulerTurnQueueBefore + ); + } + expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual( + expectedSiblingTurnRevisionSentinels + ); + }, + 180_000 + ); +}); diff --git a/tools/integration-tests/test/turnSnapshotCanonicalCoverage.test.ts b/tools/integration-tests/test/turnSnapshotCanonicalCoverage.test.ts index ac44d3a3..033c876d 100644 --- a/tools/integration-tests/test/turnSnapshotCanonicalCoverage.test.ts +++ b/tools/integration-tests/test/turnSnapshotCanonicalCoverage.test.ts @@ -39,6 +39,13 @@ const databaseSnapshot = ( cityId: 1, troopId: 1, userId: 'owner-a', + personalCode: 'che_안전', + specialCode: 'che_농업', + special2Code: 'che_신산', + horseCode: 'che_명마_01_적토마', + weaponCode: 'che_무기_07_맥궁', + bookCode: 'che_서적_01_손자병법', + itemCode: 'che_도구_01_옥새', meta: commandStateFixture?.generalMeta ?? {}, penalty: {}, ...commandStateFixture?.generalFields, @@ -92,6 +99,18 @@ const databaseSnapshot = ( }); describe('turn snapshot canonical blind-spot coverage', () => { + it('projects Prisma general role and item column names into canonical fields', () => { + expect(databaseSnapshot().generals[0]).toMatchObject({ + personality: 'che_안전', + specialDomestic: 'che_농업', + specialWar: 'che_신산', + itemHorse: 'che_명마_01_적토마', + itemWeapon: 'che_무기_07_맥궁', + itemBook: 'che_서적_01_손자병법', + itemExtra: 'che_도구_01_옥새', + }); + }); + it('projects troop rows and detects a troop mutant', () => { const reference = databaseSnapshot(); const core = { diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index 48d1a95b..e3524f6f 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -41,7 +41,7 @@ node_tag=$(printf '%s' "${CI_NODE_INDEX:-local}" | tr -cd 'a-zA-Z0-9_' | tr 'A-Z run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag} export CONDITIONAL_INTEGRATION_RUN_ID=$run_id schema_ownership_token="sammo-conditional-integration:$run_id" -supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_full_lifecycle reference_live_sortie reference_npc_possession select_pool web_push_gateway" +supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_command_durable_matrix reference_full_lifecycle reference_live_sortie reference_npc_possession security_transport select_pool web_push_gateway" term_grace_seconds=${CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS:-10} case "$term_grace_seconds" in ''|*[!0-9]*) @@ -62,8 +62,10 @@ immediate_action_schema=${IMMEDIATE_ACTION_INTEGRATION_SCHEMA:-ci_${run_id}_imme gateway_runtime_schema=${GATEWAY_RUNTIME_INTEGRATION_SCHEMA:-ci_${run_id}_gateway_runtime_integration} web_push_gateway_schema=${WEB_PUSH_GATEWAY_INTEGRATION_SCHEMA:-ci_${run_id}_web_push_integration} read_model_journal_schema=${READ_MODEL_JOURNAL_INTEGRATION_SCHEMA:-ci_${run_id}_read_model_journal_integration} +security_transport_schema=${SECURITY_TRANSPORT_SCHEMA:-ci_${run_id}_security_transport} npc_possession_differential_schema=${NPC_POSSESSION_DIFFERENTIAL_SCHEMA:-ci_${run_id}_npc_possession_differential} live_sortie_schema=${LIVE_SORTIE_PERSISTENCE_SCHEMA:-ci_${run_id}_live_sortie_persistence} +turn_command_durable_matrix_schema=${TURN_COMMAND_DURABLE_MATRIX_SCHEMA:-ci_${run_id}_turn_command_durable_matrix} turn_full_lifecycle_schema=${TURN_FULL_LIFECYCLE_PERSISTENCE_SCHEMA:-ci_${run_id}_turn_full_lifecycle_persistence} for schema in \ @@ -76,8 +78,10 @@ for schema in \ "$gateway_runtime_schema" \ "$web_push_gateway_schema" \ "$read_model_journal_schema" \ + "$security_transport_schema" \ "$npc_possession_differential_schema" \ "$live_sortie_schema" \ + "$turn_command_durable_matrix_schema" \ "$turn_full_lifecycle_schema"; do case "$schema" in ''|[!a-z_]*|*[!a-z0-9_]*) @@ -196,6 +200,7 @@ delete_owned_redis_keys() { const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID; const patterns = [ `sammo:game:*:che:security-http-${runId}:*`, + `sammo:che:security-http-${runId}:*`, `sammo:game:*:che:nation-html-${runId}:*`, `sammo:che:battle-sim-e2e-${runId}-*:battle-sim:*`, ]; @@ -443,14 +448,14 @@ run_marked_tests() { run_redis_only_tests() { package_dir=app/game-api - database_marker=$1 + database_markers=$1 test_files=$( cd "$workspace_root/$package_dir" redis_files=$(rg -l 'process\.env\.REDIS_URL' test -g '*.integration.test.ts' | sort) # Files already selected through a database marker run once in that # database group, where Redis is also available. # shellcheck disable=SC2086 - rg --files-without-match "$database_marker" $redis_files + rg --files-without-match "$database_markers" $redis_files ) if [ -z "$test_files" ]; then echo "no Redis-only integration tests found under $package_dir" >&2 @@ -473,6 +478,8 @@ pnpm --filter @sammo-ts/game-engine build GATEWAY_MIGRATION_TEST_DATABASE_URL=$base_database_url \ pnpm --filter @sammo-ts/infra verify:migration:account-icon +GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL=$base_database_url \ + pnpm --filter @sammo-ts/infra verify:migration:outbox-utc cleanup_resources_started=1 create_owned_schema "$integration_schema" @@ -518,6 +525,22 @@ run_marked_tests app/game-engine \ "$(markers_for_mode read_model_journal)" \ "read_model_journal_engine_postgresql" +create_owned_schema "$security_transport_schema" +security_transport_database_url=$(build_database_url "$security_transport_schema") +( + export POSTGRES_SCHEMA=$security_transport_schema + export DATABASE_URL=$security_transport_database_url + pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game +) +export POSTGRES_SCHEMA=$security_transport_schema +export DATABASE_URL=$security_transport_database_url +export SECURITY_TRANSPORT_DATABASE_URL=$security_transport_database_url +run_marked_tests app/game-api \ + "$(markers_for_mode security_transport)" \ + "security_transport_postgresql" +export POSTGRES_SCHEMA=$integration_schema +export DATABASE_URL=$database_url + create_owned_schema "$create_general_schema" create_general_database_url=$(build_database_url "$create_general_schema") ( @@ -672,6 +695,20 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then "$(markers_for_mode reference_live_sortie)" \ "live_sortie_postgresql" + create_owned_schema "$turn_command_durable_matrix_schema" + turn_command_durable_matrix_database_url=$(build_database_url "$turn_command_durable_matrix_schema") + ( + export POSTGRES_SCHEMA=$turn_command_durable_matrix_schema + export DATABASE_URL=$turn_command_durable_matrix_database_url + pnpm --filter @sammo-ts/infra prisma:db:push:game + ) + export POSTGRES_SCHEMA=$turn_command_durable_matrix_schema + export DATABASE_URL=$turn_command_durable_matrix_database_url + export TURN_COMMAND_DURABLE_MATRIX_DATABASE_URL=$turn_command_durable_matrix_database_url + run_marked_tests tools/integration-tests \ + "$(markers_for_mode reference_command_durable_matrix)" \ + "turn_command_durable_matrix_postgresql" + create_owned_schema "$turn_full_lifecycle_schema" turn_full_lifecycle_database_url=$(build_database_url "$turn_full_lifecycle_schema") ( @@ -689,7 +726,8 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then export DATABASE_URL=$database_url fi -run_redis_only_tests "$core_database_markers" +all_database_markers=$(cut -f1 "$validated_registry_file" | paste -sd '|' -) +run_redis_only_tests "$all_database_markers" scenario_database_url=$(build_database_url "$scenario_schema") export POSTGRES_SCHEMA=$scenario_schema From 821b8a0723c88dbad6743b64a4345743fa0e976a Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 09:18:08 +0000 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20NPC=20=EB=B9=99=EC=9D=98=20seed?= =?UTF-8?q?=EB=A5=BC=20Ref=20=EB=85=BC=EB=A6=AC=20tick=EA=B3=BC=20?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=EC=8B=9C=ED=82=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/join/index.ts | 7 ++++ .../test/npcPossession.integration.test.ts | 37 +++++++++++++++- .../src/turn/npcPossessionService.ts | 19 ++++----- .../test/npcPossessionService.test.ts | 6 +-- ...sionSelectionReference.integration.test.ts | 42 ++++++++++++++++--- 5 files changed, 90 insertions(+), 21 deletions(-) diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 01a09ffd..37a503f2 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -589,6 +589,12 @@ export const joinRouter = router({ } try { const gameTime = await loadCurrentGameTime(ctx.db); + if (gameTime.tick === null) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Game clock is not initialized.', + }); + } return await reserveNpcPossessionCandidates({ db: ctx.db, worldState, @@ -597,6 +603,7 @@ export const joinRouter = router({ refresh: input.refresh, keepIds: input.keepIds, now: gameTime.now, + acceptedGameTick: gameTime.tick, }); } catch (error) { if (error instanceof NpcPossessionError) { diff --git a/app/game-api/test/npcPossession.integration.test.ts b/app/game-api/test/npcPossession.integration.test.ts index 3a9f9f6c..dc5cf994 100644 --- a/app/game-api/test/npcPossession.integration.test.ts +++ b/app/game-api/test/npcPossession.integration.test.ts @@ -1,7 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; -import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine'; +import { + buildNpcSelectionTokenSeed, + createTurnDaemonRuntime, + seedScenarioToDatabase, + type TurnDaemonRuntime, +} from '@sammo-ts/game-engine'; import { acquireGameSchemaAdvisoryXactLock, createGamePostgresConnector, @@ -199,6 +205,32 @@ integration('mode 1 NPC possession through token reservation and the durable dae const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig(); expect(config.npcPossession).toEqual({ enabled: true }); + const worldState = await db.worldState.findFirstOrThrow(); + const acceptedGameTick = Number(worldState.clockTick); + const hiddenSeed = asRecord(worldState.meta).hiddenSeed; + expect(Number.isSafeInteger(acceptedGameTick)).toBe(true); + if (typeof hiddenSeed !== 'string' && typeof hiddenSeed !== 'number') { + throw new Error('NPC possession integration hidden seed is missing'); + } + const selectable = await db.general.findMany({ + where: { userId: null, npcState: 2 }, + orderBy: { id: 'asc' }, + select: { id: true, leadership: true, strength: true, intel: true }, + }); + const weights = Object.fromEntries( + selectable.map((candidate) => [ + String(candidate.id), + Math.pow(candidate.leadership + candidate.strength + candidate.intel, 1.5), + ]) + ); + const rng = new RandUtil( + new LiteHashDRBG(buildNpcSelectionTokenSeed(hiddenSeed, 7_701, acceptedGameTick)) + ); + const expectedCandidateIds = new Set(); + while (expectedCandidateIds.size < Math.min(5, selectable.length)) { + expectedCandidateIds.add(Number(rng.choiceUsingWeight(weights))); + } + const [first, concurrentSameOwner] = await Promise.all([ appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}), appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}), @@ -208,6 +240,9 @@ integration('mode 1 NPC possession through token reservation and the durable dae expect(first.candidates.length).toBeGreaterThan(0); expect(first.candidates.length).toBeLessThanOrEqual(5); expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length); + expect(first.candidates.map(({ id }) => id).sort((left, right) => left - right)).toEqual( + [...expectedCandidateIds].sort((left, right) => left - right) + ); expect(first.pickMoreSeconds).toBe(0); expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true); const rows = await db.general.findMany({ diff --git a/app/game-engine/src/turn/npcPossessionService.ts b/app/game-engine/src/turn/npcPossessionService.ts index 96fc7ce0..8af9b0f4 100644 --- a/app/game-engine/src/turn/npcPossessionService.ts +++ b/app/game-engine/src/turn/npcPossessionService.ts @@ -89,7 +89,6 @@ interface NpcSelectionTokenRow { nonce: number; } -const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000; const VALID_SECONDS = 90; const PICK_MORE_SECONDS = 10; const KEEP_COUNT = 3; @@ -103,19 +102,11 @@ const fail = (code: NpcPossessionErrorCode, message: string): never => { const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTime() / 1000) * 1000); -const formatLegacySeedTime = (value: Date): string => { - const pad = (part: number): string => String(part).padStart(2, '0'); - const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS); - return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad( - koreaTime.getUTCDate() - )} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`; -}; - export const buildNpcSelectionTokenSeed = ( hiddenSeed: string | number, ownerIdentity: string | number, - now: Date -): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, formatLegacySeedTime(now)); + acceptedGameTick: number +): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, acceptedGameTick); const readHiddenSeed = (worldState: WorldStateRow): string | number => { const meta = asRecord(worldState.meta); @@ -298,11 +289,15 @@ export const reserveNpcPossessionCandidates = async (options: { refresh?: boolean; keepIds?: number[]; now?: Date; + acceptedGameTick: number; selectionObserver?: NpcPossessionSelectionObserver; }): Promise => { const { db, worldState, userId } = options; requireNpcPossessionWorld(worldState); const now = truncateToSeconds(options.now ?? new Date()); + if (!Number.isSafeInteger(options.acceptedGameTick)) { + fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 수락 tick이 올바르지 않습니다.'); + } await lockNpcPossession(db, userId); if (await db.general.findFirst({ where: { userId }, select: { id: true } })) { @@ -402,7 +397,7 @@ export const reserveNpcPossessionCandidates = async (options: { generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId))) ); const selectionRng = new LiteHashDRBG( - buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, now) + buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.acceptedGameTick) ); const rng = options.selectionObserver?.onRandomDraw ? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw) diff --git a/app/game-engine/test/npcPossessionService.test.ts b/app/game-engine/test/npcPossessionService.test.ts index 17d94061..8c06d961 100644 --- a/app/game-engine/test/npcPossessionService.test.ts +++ b/app/game-engine/test/npcPossessionService.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'; import { buildNpcSelectionTokenSeed } from '../src/turn/npcPossessionService.js'; describe('NPC possession legacy token contracts', () => { - it('builds the Ref SelectNPCToken seed from the Seoul whole-second timestamp', () => { - expect(buildNpcSelectionTokenSeed('seed', 42, new Date('2026-07-30T23:59:58.987Z'))).toBe( - 'str(4,seed)|str(14,SelectNPCToken)|int(42)|str(19,2026-07-31 08:59:58)' + it('builds the Ref SelectNPCToken seed from the accepted game tick', () => { + expect(buildNpcSelectionTokenSeed('seed', 42, 72_000_001)).toBe( + 'str(4,seed)|str(14,SelectNPCToken)|int(42)|int(72000001)' ); }); }); diff --git a/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts b/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts index 37b71b55..dc210fe1 100644 --- a/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts +++ b/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts @@ -174,7 +174,7 @@ class TracingRandUtil extends RandUtil { } } -const runCoreKernelCase = (fixture: Fixture, testCase: FixtureCase): KernelTrace => { +const runCoreKernelCase = (fixture: Fixture, testCase: FixtureCase, acceptedGameTick: number): KernelTrace => { const reserved = new Set([...testCase.reservedIds, ...(testCase.boundaryReservedIds ?? [])]); const candidates = fixture.candidates .filter(({ id }) => !reserved.has(id)) @@ -200,7 +200,7 @@ const runCoreKernelCase = (fixture: Fixture, testCase: FixtureCase): KernelTrace }; } - const seed = buildNpcSelectionTokenSeed(fixture.hiddenSeed, fixture.owner, acceptedAt(fixture)); + const seed = buildNpcSelectionTokenSeed(fixture.hiddenSeed, fixture.owner, acceptedGameTick); const rng = new TracingRandUtil(new LiteHashDRBG(seed)); const draws: number[] = []; const picked = chooseNpcPossessionCandidates(candidates, kept, rng, (selectedId) => { @@ -227,6 +227,27 @@ const assertDedicatedDatabase = (rawUrl: string): void => { } }; +const readReferenceAcceptedGameTick = (trace: ReferenceTrace): number => { + const ticks = new Set( + trace.cases.flatMap(({ seed }) => { + if (seed === null) return []; + const match = seed.match(/\|int\((-?\d+)\)$/); + if (!match) { + throw new Error(`Ref SelectNPCToken seed does not end with an integer game tick: ${seed}`); + } + const tick = Number(match[1]); + if (!Number.isSafeInteger(tick)) { + throw new Error(`Ref SelectNPCToken tick is outside the safe integer range: ${match[1]}`); + } + return [tick]; + }) + ); + if (ticks.size !== 1) { + throw new Error(`Ref comparison returned ${ticks.size} distinct accepted game ticks`); + } + return [...ticks][0]!; +}; + integration('NPC possession selector Ref differential', () => { if (!workspaceRoot || !databaseUrl || !referenceEnabled) { return; @@ -317,7 +338,10 @@ integration('NPC possession selector Ref differential', () => { await closeDb?.(); }); - const runCoreReservationCase = async (testCase: FixtureCase): Promise => + const runCoreReservationCase = async ( + testCase: FixtureCase, + acceptedGameTick: number + ): Promise => db.$transaction(async (transaction) => { await transaction.npcSelectionToken.deleteMany(); const validUntil = new Date('2099-12-31T23:59:59.000Z'); @@ -383,6 +407,7 @@ integration('NPC possession selector Ref differential', () => { refresh: hasPreviousToken, keepIds: testCase.keepIds, now: acceptedAt(fixture), + acceptedGameTick, selectionObserver: { onRandomDraw: (value) => randomDraws.push(value), onCandidateDraw: (selectedId) => draws.push(Number(selectedId)), @@ -427,11 +452,18 @@ integration('NPC possession selector Ref differential', () => { ); expect(firstReference.cases).toHaveLength(fixture.cases.length); - const coreKernelCases = fixture.cases.map((testCase) => runCoreKernelCase(fixture, testCase)); + // The shared Ref database and disposable Core scenario intentionally have + // different clock bases. This selector differential isolates RNG by injecting + // the exact safe integer tick observed at the Ref endpoint into both Core paths; + // it does not claim clock-base parity between the two fixtures. + const referenceAcceptedGameTick = readReferenceAcceptedGameTick(firstReference); + const coreKernelCases = fixture.cases.map((testCase) => + runCoreKernelCase(fixture, testCase, referenceAcceptedGameTick) + ); for (const [index, referenceCase] of firstReference.cases.entries()) { const testCase = fixture.cases[index]!; const coreKernel = coreKernelCases[index]!; - const coreReservation = await runCoreReservationCase(testCase); + const coreReservation = await runCoreReservationCase(testCase, referenceAcceptedGameTick); expect(referenceCase.name).toBe(coreKernel.name); expect(referenceCase.selectionStateUnchanged).toBe(true); expect(referenceCase.cancelled).toBe(coreKernel.cancelled); From 7a1254cb3ab8cd165366380d2cf8cda38865bd74 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 09:18:42 +0000 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=ED=88=AC=ED=91=9C=20UTC=20wall?= =?UTF-8?q?=EA=B3=BC=20migration=20=EC=8B=9C=EA=B0=84=EB=8C=80=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=EB=A5=BC=20=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/vote/index.ts | 39 +++- .../inputEventBoundary.integration.test.ts | 7 + .../voteCommentTimestamp.integration.test.ts | 177 ++++++++++++++++ app/game-api/test/voteRouter.test.ts | 57 +++++ .../src/turn/worldCommandHandler.ts | 6 +- .../test/gameCancellation.integration.test.ts | 33 +++ app/game-engine/test/voteReward.test.ts | 15 +- .../src/orchestrator/gatewayOrchestrator.ts | 198 +++++++++++++++++- app/gateway-api/test/orchestratorPlan.test.ts | 82 +++++++- .../test/profileDeployOperation.test.ts | 18 +- app/gateway-api/test/releaseManifest.test.ts | 2 +- packages/infra/prisma/game.prisma | 16 +- .../migration.sql | 15 ++ packages/infra/prisma/migrations/README.md | 25 +++ release-manifest.json | 2 +- 15 files changed, 667 insertions(+), 25 deletions(-) create mode 100644 app/game-api/test/voteCommentTimestamp.integration.test.ts create mode 100644 packages/infra/prisma/migrations/20260824080000_vote_utc_wall_timestamps/migration.sql diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index 0eb358af..ca92a4cf 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -394,10 +394,27 @@ export const voteRouter = router({ ? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } }) : null; const nationName = nation?.name ?? '재야'; + const createdAt = new Date(); await ctx.db.$queryRaw(GamePrisma.sql` - INSERT INTO vote_comment (vote_id, general_id, nation_id, general_name, nation_name, text) - VALUES (${input.voteId}, ${general.id}, ${general.nationId}, ${general.name}, ${nationName}, ${input.text}) + INSERT INTO vote_comment ( + vote_id, + general_id, + nation_id, + general_name, + nation_name, + text, + created_at + ) + VALUES ( + ${input.voteId}, + ${general.id}, + ${general.nationId}, + ${general.name}, + ${nationName}, + ${input.text}, + ${createdAt} + ) `); return { ok: true }; @@ -430,6 +447,7 @@ export const voteRouter = router({ if (endAt && endAt < gameTime.now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); } + const operationalAt = new Date(); let multipleOptions = input.multipleOptions; if (multipleOptions < 0) { @@ -442,7 +460,7 @@ export const voteRouter = router({ if (input.closePrevious) { await ctx.db.$queryRaw(GamePrisma.sql` UPDATE vote_poll - SET closed_at = ${gameTime.now}, updated_at = NOW() + SET closed_at = ${gameTime.now}, updated_at = ${operationalAt} WHERE closed_at IS NULL `); } @@ -459,7 +477,9 @@ export const voteRouter = router({ start_at, start_tick, end_at, - end_tick + end_tick, + created_at, + updated_at ) VALUES ( ${input.title}, @@ -472,7 +492,9 @@ export const voteRouter = router({ ${gameTime.now}, ${gameTime.tick === null ? null : BigInt(gameTime.tick)}, ${endAt}, - ${toGameTickOrNull(gameTime, endAt)} + ${toGameTickOrNull(gameTime, endAt)}, + ${operationalAt}, + ${operationalAt} ) `); @@ -547,6 +569,7 @@ export const voteRouter = router({ if (endAt && endAt < gameTime.now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); } + const updatedAt = new Date(); if ( input.title === undefined && @@ -569,7 +592,7 @@ export const voteRouter = router({ reveal_mode = COALESCE(${input.revealMode}, reveal_mode), end_at = ${endAt ?? poll.end_at}, end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick}, - updated_at = NOW() + updated_at = ${updatedAt} WHERE id = ${input.voteId} `); @@ -581,9 +604,11 @@ export const voteRouter = router({ closePoll: adminProcedure .input(z.object({ voteId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { + const gameTime = await loadCurrentGameTime(ctx.db); + const updatedAt = new Date(); const rows = await ctx.db.$queryRaw>(GamePrisma.sql` UPDATE vote_poll - SET closed_at = ${(await loadCurrentGameTime(ctx.db)).now}, updated_at = NOW() + SET closed_at = ${gameTime.now}, updated_at = ${updatedAt} WHERE id = ${input.voteId} RETURNING id `); diff --git a/app/game-api/test/inputEventBoundary.integration.test.ts b/app/game-api/test/inputEventBoundary.integration.test.ts index 55e07850..02112b59 100644 --- a/app/game-api/test/inputEventBoundary.integration.test.ts +++ b/app/game-api/test/inputEventBoundary.integration.test.ts @@ -99,6 +99,8 @@ integration('API input event boundary', () => { actorUserId: 'user-7', attempts: 1, }); + expect(event.processingAt).toBeInstanceOf(Date); + expect(Math.abs(event.createdAt.getTime() - (event.processingAt?.getTime() ?? 0))).toBeLessThan(1_000); expect(marker.status).toBe('PENDING'); }); @@ -254,7 +256,12 @@ integration('API input event boundary', () => { it('reuses the same engine child event but rejects a changed retry payload', async () => { const transport = new DatabaseTurnDaemonTransport(db, 100); const requestId = 'integration:api:engine-child'; + const acceptedWindowStart = Date.now(); await transport.sendCommand({ type: 'vacation', requestId, generalId: 7 }); + const acceptedWindowEnd = Date.now(); + const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); + expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart); + expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd); await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 7 })).resolves.toBe(requestId); await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf( ConflictingTurnDaemonCommandError diff --git a/app/game-api/test/voteCommentTimestamp.integration.test.ts b/app/game-api/test/voteCommentTimestamp.integration.test.ts new file mode 100644 index 00000000..055c9c50 --- /dev/null +++ b/app/game-api/test/voteCommentTimestamp.integration.test.ts @@ -0,0 +1,177 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import type { GameApiContext } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const fixtureId = 9_984_241; +const fallbackPollId = fixtureId + 1; +const fixtureUserId = 'vote-comment-timestamp-user'; +const routeText = '설문 댓글 UTC writer 검증'; +const fallbackText = '설문 댓글 UTC default 검증'; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:vote-comment-timestamp', + issuedAt: '2026-08-24T00:00:00.000Z', + expiresAt: '2027-08-24T00:00:00.000Z', + sessionId: 'vote-comment-timestamp-session', + user: { + id: fixtureUserId, + username: fixtureUserId, + displayName: fixtureUserId, + roles: [], + }, + sanctions: {}, +}; + +integration('vote comment operational timestamp', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const cleanup = async (): Promise => { + await db.inputEvent.deleteMany({ + where: { requestId: { startsWith: 'integration:vote-comment-timestamp' } }, + }); + await db.voteComment.deleteMany({ where: { voteId: { in: [fixtureId, fallbackPollId] } } }); + await db.vote.deleteMany({ where: { voteId: { in: [fixtureId, fallbackPollId] } } }); + await db.votePoll.deleteMany({ where: { id: { in: [fixtureId, fallbackPollId] } } }); + await db.general.deleteMany({ where: { id: fixtureId } }); + await db.nation.deleteMany({ where: { id: fixtureId } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await cleanup(); + await db.nation.create({ data: { id: fixtureId, name: '시각국', color: '#123456' } }); + await db.general.create({ + data: { + id: fixtureId, + userId: fixtureUserId, + name: '시각장수', + nationId: fixtureId, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + }, + }); + await db.votePoll.create({ + data: { + id: fixtureId, + title: '시각 설문', + options: ['찬성', '반대'], + multipleOptions: 1, + revealMode: 'after_vote', + openerGeneralId: fixtureId, + openerName: '시각장수', + startAt: new Date('0200-01-01T00:00:00.000Z'), + }, + }); + }); + + afterAll(async () => { + await cleanup(); + await closeDb?.(); + }); + + it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => { + const [session] = await db.$queryRaw>` + SELECT current_setting('TIMEZONE') AS "timeZone" + `; + expect(session?.timeZone).toBe('Asia/Seoul'); + + const routeWindowStart = Date.now(); + const caller = appRouter.createCaller({ + requestId: 'integration:vote-comment-timestamp', + db, + auth, + profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' }, + turnDaemon: {}, + } as unknown as GameApiContext); + await expect(caller.vote.addComment({ voteId: fixtureId, text: routeText })).resolves.toEqual({ ok: true }); + const routeWindowEnd = Date.now(); + + const fallbackWindowStart = Date.now(); + await db.$executeRaw` + INSERT INTO "vote_comment" ( + "vote_id", + "general_id", + "nation_id", + "general_name", + "nation_name", + "text" + ) + VALUES ( + ${fixtureId}, + ${fixtureId}, + ${fixtureId}, + '시각장수', + '시각국', + ${fallbackText} + ) + `; + const fallbackWindowEnd = Date.now(); + + const pollFallbackWindowStart = Date.now(); + await db.$executeRaw` + INSERT INTO "vote_poll" ( + "id", + "title", + "body", + "options", + "multiple_options", + "reveal_mode", + "opener_general_id", + "opener_name", + "start_at" + ) + VALUES ( + ${fallbackPollId}, + '이전 writer 기본값 설문', + '', + '["찬성", "반대"]'::jsonb, + 1, + 'after_vote', + ${fixtureId}, + '시각장수', + ${new Date('0200-01-01T00:00:00.000Z')} + ) + `; + const pollFallbackWindowEnd = Date.now(); + + const voteFallbackWindowStart = Date.now(); + await db.$executeRaw` + INSERT INTO "vote" ("vote_id", "general_id", "nation_id", "selection") + VALUES (${fixtureId}, ${fixtureId}, ${fixtureId}, '[0]'::jsonb) + `; + const voteFallbackWindowEnd = Date.now(); + + const rows = await db.voteComment.findMany({ + where: { voteId: fixtureId }, + select: { text: true, createdAt: true }, + }); + const routeRow = rows.find((row) => row.text === routeText); + const fallbackRow = rows.find((row) => row.text === fallbackText); + expect(routeRow?.createdAt.getTime()).toBeGreaterThanOrEqual(routeWindowStart); + expect(routeRow?.createdAt.getTime()).toBeLessThanOrEqual(routeWindowEnd); + expect(fallbackRow?.createdAt.getTime()).toBeGreaterThanOrEqual(fallbackWindowStart); + expect(fallbackRow?.createdAt.getTime()).toBeLessThanOrEqual(fallbackWindowEnd); + + const fallbackPoll = await db.votePoll.findUniqueOrThrow({ where: { id: fallbackPollId } }); + expect(fallbackPoll.createdAt.getTime()).toBeGreaterThanOrEqual(pollFallbackWindowStart); + expect(fallbackPoll.createdAt.getTime()).toBeLessThanOrEqual(pollFallbackWindowEnd); + expect(fallbackPoll.updatedAt.getTime()).toBeGreaterThanOrEqual(pollFallbackWindowStart); + expect(fallbackPoll.updatedAt.getTime()).toBeLessThanOrEqual(pollFallbackWindowEnd); + + const fallbackVote = await db.vote.findUniqueOrThrow({ + where: { voteId_generalId: { voteId: fixtureId, generalId: fixtureId } }, + }); + expect(fallbackVote.createdAt.getTime()).toBeGreaterThanOrEqual(voteFallbackWindowStart); + expect(fallbackVote.createdAt.getTime()).toBeLessThanOrEqual(voteFallbackWindowEnd); + }); +}); diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts index a55ce09c..dbb75419 100644 --- a/app/game-api/test/voteRouter.test.ts +++ b/app/game-api/test/voteRouter.test.ts @@ -130,6 +130,9 @@ const buildContext = (options: { if (text.includes('INSERT INTO vote_comment')) { return []; } + if (text.includes('UPDATE vote_poll')) { + return [{ id: 1 }]; + } return []; }); const db = { @@ -289,6 +292,60 @@ describe('vote router actor and permission boundaries', () => { expect(insert?.values).not.toContain('관리자 장수'); }); + it('binds current operational timestamps in every raw SQL vote writer', async () => { + const auth = buildAuth(['admin.survey.open']); + const fixture = buildContext({ auth }); + const caller = appRouter.createCaller(fixture.context); + const windowStart = Date.now(); + + await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true }); + await expect( + caller.vote.createPoll({ + title: '시각 설문', + options: ['찬성', '반대'], + revealMode: 'after_vote', + closePrevious: true, + }) + ).resolves.toEqual({ ok: true }); + await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true }); + await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true }); + const windowEnd = Date.now(); + + const mutationQueries = fixture.queryRaw.mock.calls + .map(([query]) => query) + .filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query))); + const commentInsert = mutationQueries.find((query) => sqlText(query).includes('INSERT INTO vote_comment')); + const pollInsert = mutationQueries.find((query) => sqlText(query).includes('INSERT INTO vote_poll')); + const pollUpdates = mutationQueries.filter((query) => sqlText(query).includes('UPDATE vote_poll')); + const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL')); + const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE')); + const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id')); + const expectCurrentDateAt = (query: GamePrisma.Sql | undefined, index: number): Date => { + expect(query).toBeDefined(); + const value = query?.values.at(index); + expect(value).toBeInstanceOf(Date); + expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart); + expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd); + return value as Date; + }; + + expect(sqlText(commentInsert!)).toContain('created_at'); + expectCurrentDateAt(commentInsert, -1); + expect(sqlText(pollInsert!)).toContain('created_at'); + expect(sqlText(pollInsert!)).toContain('updated_at'); + const pollCreatedAt = expectCurrentDateAt(pollInsert, -2); + const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1); + expect(pollUpdatedAt).toBe(pollCreatedAt); + + expect(pollUpdates).toHaveLength(3); + expect(sqlText(closePreviousUpdate!)).toContain('updated_at'); + expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt); + expect(sqlText(editPollUpdate!)).toContain('updated_at'); + expectCurrentDateAt(editPollUpdate, -2); + expect(sqlText(closePollUpdate!)).toContain('updated_at'); + expectCurrentDateAt(closePollUpdate, -2); + }); + it('reports the current world develcost as the legacy five-times survey reward', async () => { const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } }); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index e1e69a84..e015373c 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -2479,12 +2479,14 @@ const insertVoteSelection = async ( if (!ctx.commandDb) { return 'missing'; } + const createdAt = new Date(); const rows = await ctx.commandDb.$queryRaw>(GamePrisma.sql` - INSERT INTO vote (vote_id, general_id, nation_id, selection) + INSERT INTO vote (vote_id, general_id, nation_id, selection, created_at) SELECT poll.id, ${general.id}, ${general.nationId}, - CAST(${JSON.stringify(selection)} AS jsonb) + CAST(${JSON.stringify(selection)} AS jsonb), + ${createdAt} FROM vote_poll poll WHERE poll.id = ${command.voteId} ON CONFLICT (vote_id, general_id) DO NOTHING diff --git a/app/game-engine/test/gameCancellation.integration.test.ts b/app/game-engine/test/gameCancellation.integration.test.ts index fd770e18..8dbc5c4b 100644 --- a/app/game-engine/test/gameCancellation.integration.test.ts +++ b/app/game-engine/test/gameCancellation.integration.test.ts @@ -286,4 +286,37 @@ integration('game cancellation transaction', () => { generalMode: 'DELETE', }); }); + + it('keeps a native inheritance log inside the cancellation boundary under a KST session', async () => { + const text = '신규/복귀 생성으로 포인트 1500 지급'; + await db.inheritanceLog.deleteMany({ where: { userId, text } }); + const [session] = await db.$queryRaw>` + SELECT current_setting('TIMEZONE') AS "timeZone" + `; + expect(session?.timeZone).toBe('Asia/Seoul'); + + const createdAfter = Date.now(); + const log = await db.inheritanceLog.create({ + data: { userId, serverId, year: 190, month: 7, text }, + }); + const createdBefore = Date.now(); + expect(log.createdAt.getTime()).toBeGreaterThanOrEqual(createdAfter); + expect(log.createdAt.getTime()).toBeLessThanOrEqual(createdBefore); + + const result = await cancelGame({ + cancellationId: 'game-cancellation-kst-default-fixture', + databaseUrl: databaseUrl!, + cancelledBy: 'admin', + reason: 'KST 기본값 경계 검증', + historyMode: 'RETAIN_ABANDONED', + generalMode: 'RETAIN', + earnedPointRetentionPercent: 40, + cancelledAt: new Date(createdBefore + 1_000), + }); + expect(result.settlements[userId]).toMatchObject({ + earnedPoint: 1_790.005, + retainedEarnedPoint: 716, + finalPoint: 10_716, + }); + }); }); diff --git a/app/game-engine/test/voteReward.test.ts b/app/game-engine/test/voteReward.test.ts index 5583dd4f..ec8be31c 100644 --- a/app/game-engine/test/voteReward.test.ts +++ b/app/game-engine/test/voteReward.test.ts @@ -222,11 +222,12 @@ describe('voteReward command', () => { let voteInserted = false; let voteQueryCount = 0; + let voteInsertQuery: { strings: readonly string[]; values: readonly unknown[] } | undefined; const commandDb = { auction: { findMany: async () => [], }, - $queryRaw: async (query: { strings: readonly string[] }) => { + $queryRaw: async (query: { strings: readonly string[]; values: readonly unknown[] }) => { voteQueryCount += 1; if (query.strings.join(' ').includes('SELECT options')) { return [ @@ -239,6 +240,7 @@ describe('voteReward command', () => { }, ]; } + voteInsertQuery = query; if (voteInserted) return []; voteInserted = true; return [{ id: 11 }]; @@ -255,12 +257,23 @@ describe('voteReward command', () => { acceptedGameTick: 0, }; + const writerWindowStart = Date.now(); const result = await handler.handle(command, { db: commandDb as any }); + const writerWindowEnd = Date.now(); expect(result && result.type).toBe('voteReward'); if (!result || result.type !== 'voteReward' || !result.ok) { throw new Error('voteReward result missing'); } expect(result.awardedUnique).toBe(true); + expect(voteInsertQuery?.strings.join(' ')).toContain('created_at'); + expect( + voteInsertQuery?.values.filter( + (value) => + value instanceof Date && + value.getTime() >= writerWindowStart && + value.getTime() <= writerWindowEnd + ) + ).toHaveLength(1); const updated = world.getGeneralById(1); // ENGINE is the single reward linearization point, so it uses the diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index b541f0af..b758423d 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -684,6 +684,197 @@ export const buildWorkspaceCommands = ( return commands; }; +const PROFILE_MIGRATION_TIME_ZONE = 'Asia/Seoul'; +const PROFILE_MIGRATION_TIME_ZONE_OPTION = `-c TimeZone=${PROFILE_MIGRATION_TIME_ZONE}`; +const PROFILE_MIGRATION_TIME_ZONE_MENTION = /(^|[^A-Z0-9_])timezone(?=$|[^A-Z0-9_])/iu; + +const profileMigrationTimeZoneError = (source: string): Error => + new Error( + `Profile migration refused: ${source} must not configure a TimeZone other than ${PROFILE_MIGRATION_TIME_ZONE}.` + ); + +const tokenizePostgresOptions = (rawOptions: string, source: string): string[] => { + const tokens: string[] = []; + let token = ''; + let quote: "'" | '"' | null = null; + let escaping = false; + + for (const character of rawOptions) { + if (escaping) { + token += character; + escaping = false; + continue; + } + if (character === '\\') { + escaping = true; + continue; + } + if (quote) { + if (character === quote) quote = null; + else token += character; + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if (/\s/u.test(character)) { + if (token) { + tokens.push(token); + token = ''; + } + continue; + } + token += character; + } + + if (escaping || quote) throw profileMigrationTimeZoneError(source); + if (token) tokens.push(token); + return tokens; +}; + +const readPostgresOptionTimeZones = (rawOptions: string, source: string): string[] => { + const tokens = tokenizePostgresOptions(rawOptions, source); + const timeZones: string[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]!; + let setting: string | undefined; + if (token === '-c') { + setting = tokens[index + 1]; + index += 1; + } else if (token.startsWith('-c') && token.length > 2) { + setting = token.slice(2); + } else if (token.startsWith('--') && token.length > 2) { + setting = token.slice(2); + } + + if (!setting) { + if (PROFILE_MIGRATION_TIME_ZONE_MENTION.test(token)) throw profileMigrationTimeZoneError(source); + continue; + } + + const separator = setting.indexOf('='); + const name = separator >= 0 ? setting.slice(0, separator).trim() : setting.trim(); + if (name.toLowerCase() !== 'timezone') continue; + const value = separator >= 0 ? setting.slice(separator + 1).trim() : ''; + if (!value) throw profileMigrationTimeZoneError(source); + timeZones.push(value); + } + + if (timeZones.length === 0 && PROFILE_MIGRATION_TIME_ZONE_MENTION.test(rawOptions)) { + throw profileMigrationTimeZoneError(source); + } + return timeZones; +}; + +const assertProfileMigrationTimeZone = (timeZone: string, source: string): void => { + if (timeZone.trim().toLowerCase() !== PROFILE_MIGRATION_TIME_ZONE.toLowerCase()) { + throw profileMigrationTimeZoneError(source); + } +}; + +const inspectProfileMigrationDatabaseUrl = ( + profileDatabaseUrl: string +): { url: URL; optionKeys: string[]; existingOptions: string[]; configuredTimeZones: string[] } => { + let url: URL; + try { + url = new URL(profileDatabaseUrl); + } catch { + throw new Error('Profile migration refused: DATABASE_URL is not a valid URL.'); + } + + const optionKeys = [...new Set([...url.searchParams.keys()].filter((key) => key.toLowerCase() === 'options'))]; + const existingOptions = optionKeys + .flatMap((key) => url.searchParams.getAll(key)) + .map((value) => value.trim()) + .filter(Boolean); + const configuredTimeZones = existingOptions.flatMap((options) => + readPostgresOptionTimeZones(options, 'DATABASE_URL options') + ); + for (const timeZone of configuredTimeZones) { + assertProfileMigrationTimeZone(timeZone, 'DATABASE_URL options'); + } + for (const [key, value] of url.searchParams) { + if (key.toLowerCase() === 'timezone') assertProfileMigrationTimeZone(value, 'DATABASE_URL'); + } + + return { url, optionKeys, existingOptions, configuredTimeZones }; +}; + +const buildProfileMigrationDatabaseUrl = (profileDatabaseUrl: string): string => { + const { url, optionKeys, existingOptions, configuredTimeZones } = + inspectProfileMigrationDatabaseUrl(profileDatabaseUrl); + + for (const key of optionKeys) url.searchParams.delete(key); + if (configuredTimeZones.length === 0) existingOptions.push(PROFILE_MIGRATION_TIME_ZONE_OPTION); + url.searchParams.set('options', existingOptions.join(' ')); + return url.href; +}; + +const assertProfileMigrationEnvironmentTimeZone = (env?: Record): void => { + const pgOptions = env?.PGOPTIONS?.trim(); + if (pgOptions) { + for (const timeZone of readPostgresOptionTimeZones(pgOptions, 'PGOPTIONS')) { + assertProfileMigrationTimeZone(timeZone, 'PGOPTIONS'); + } + } + const pgTimeZone = env?.PGTZ?.trim(); + if (pgTimeZone) assertProfileMigrationTimeZone(pgTimeZone, 'PGTZ'); +}; + +const buildProfileMigrationEnv = ( + profileDatabaseUrl: string, + env?: Record +): Record => { + assertProfileMigrationEnvironmentTimeZone(env); + return { ...(env ?? {}), DATABASE_URL: buildProfileMigrationDatabaseUrl(profileDatabaseUrl) }; +}; + +const buildProfileMigrationPreflightEnv = ( + profileDatabaseUrl: string, + env?: Record +): Record => { + inspectProfileMigrationDatabaseUrl(profileDatabaseUrl); + assertProfileMigrationEnvironmentTimeZone(env); + return { ...(env ?? {}), DATABASE_URL: profileDatabaseUrl }; +}; + +const PROFILE_MIGRATION_TIME_ZONE_PREFLIGHT = ` +import pg from 'pg'; +const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); +let connected = false; +try { + await client.connect(); + connected = true; + const result = await client.query("SELECT current_setting('TimeZone') AS timezone"); + if (result.rows[0]?.timezone !== '${PROFILE_MIGRATION_TIME_ZONE}') { + throw new Error('Profile migration refused: database session TimeZone does not match the required migration contract.'); + } +} finally { + if (connected) await client.end(); +} +`.trim(); + +export const buildProfileMigrationPreflightCommand = ( + workspaceRoot: string, + profileDatabaseUrl: string, + env?: Record +): BuildCommand => ({ + command: 'pnpm', + args: [ + '--filter', + '@sammo-ts/infra', + 'exec', + 'node', + '--input-type=module', + '--eval', + PROFILE_MIGRATION_TIME_ZONE_PREFLIGHT, + ], + cwd: workspaceRoot, + env: buildProfileMigrationPreflightEnv(profileDatabaseUrl, env), +}); + export const buildProfileMigrationCommand = ( workspaceRoot: string, profileDatabaseUrl: string, @@ -692,7 +883,7 @@ export const buildProfileMigrationCommand = ( command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], cwd: workspaceRoot, - env: { ...(env ?? {}), DATABASE_URL: profileDatabaseUrl }, + env: buildProfileMigrationEnv(profileDatabaseUrl, env), }); const mapRuntimeStates = (profileNames: string[], processNames: Map): ProfileRuntimeSnapshot[] => @@ -2269,7 +2460,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { onProgress?: BuildProgressObserver ): Promise>> { return this.buildRunner.run( - [buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv)], + [ + buildProfileMigrationPreflightCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv), + buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv), + ], onProgress, { signal: this.activeOperationAbortSignal } ); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 60cc4069..cff3997e 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { buildProfileFrontendCommands, buildProfileMigrationCommand, + buildProfileMigrationPreflightCommand, buildProcessDefinitions, buildSharedProfileFrontendCommands, buildWorkspaceCommands, @@ -437,10 +438,89 @@ describe('buildWorkspaceCommands', () => { cwd: workspaceRoot, env: { NODE_ENV: 'production', - DATABASE_URL: databaseUrl, + DATABASE_URL: 'postgresql://integration.invalid/sammo?schema=che&options=-c+TimeZone%3DAsia%2FSeoul', }, }); }); + + it('preserves existing non-timezone options and adds the migration KST session', () => { + const command = buildProfileMigrationCommand( + '/srv/sammo/worktrees/0123456789abcdef', + 'postgresql://integration.invalid/sammo?schema=che&options=-c%20statement_timeout%3D30000' + ); + const migrationUrl = new URL(command.env?.DATABASE_URL ?? ''); + + expect(migrationUrl.searchParams.getAll('options')).toEqual(['-c statement_timeout=30000 -c TimeZone=Asia/Seoul']); + }); + + it('keeps an already explicit KST migration contract without adding another override', () => { + const command = buildProfileMigrationCommand( + '/srv/sammo/worktrees/0123456789abcdef', + 'postgresql://integration.invalid/sammo?schema=che&options=-c%20TimeZone%3DAsia%2FSeoul' + ); + const migrationUrl = new URL(command.env?.DATABASE_URL ?? ''); + + expect(migrationUrl.searchParams.getAll('options')).toEqual(['-c TimeZone=Asia/Seoul']); + }); + + it('fails closed on conflicting or ambiguous migration timezone sources without exposing the URL', () => { + const secretUrl = + 'postgresql://migration:super-secret@integration.invalid/sammo?schema=che&options=-c%20TimeZone%3DUTC'; + + for (const build of [ + () => buildProfileMigrationCommand('/srv/sammo/worktree', secretUrl), + () => + buildProfileMigrationCommand( + '/srv/sammo/worktree', + 'postgresql://integration.invalid/sammo?schema=che&timezone=UTC' + ), + () => + buildProfileMigrationCommand('/srv/sammo/worktree', 'postgresql://integration.invalid/sammo', { + PGOPTIONS: '-c statement_timeout=30000 --TimeZone=UTC', + }), + () => + buildProfileMigrationCommand('/srv/sammo/worktree', 'postgresql://integration.invalid/sammo', { + PGTZ: 'UTC', + }), + () => + buildProfileMigrationCommand( + '/srv/sammo/worktree', + 'postgresql://integration.invalid/sammo?options=--TimeZone%20UTC' + ), + ]) { + let error: unknown; + try { + build(); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('Profile migration refused'); + expect((error as Error).message).not.toContain('super-secret'); + expect((error as Error).message).not.toContain(secretUrl); + } + }); + + it('checks the unmodified runtime URL before building the separate migration-only URL', () => { + const profileDatabaseUrl = 'postgresql://integration.invalid/sammo?schema=che'; + const preflight = buildProfileMigrationPreflightCommand('/srv/sammo/worktree', profileDatabaseUrl); + const migration = buildProfileMigrationCommand('/srv/sammo/worktree', profileDatabaseUrl); + + expect(preflight.args.slice(0, 6)).toEqual([ + '--filter', + '@sammo-ts/infra', + 'exec', + 'node', + '--input-type=module', + '--eval', + ]); + expect(preflight.args.at(-1)).toContain("current_setting('TimeZone')"); + expect(preflight.env?.DATABASE_URL).toBe(profileDatabaseUrl); + expect(migration.env?.DATABASE_URL).toBe( + 'postgresql://integration.invalid/sammo?schema=che&options=-c+TimeZone%3DAsia%2FSeoul' + ); + expect(preflight.env?.DATABASE_URL).not.toBe(migration.env?.DATABASE_URL); + }); }); describe('buildProfileFrontendCommands', () => { diff --git a/app/gateway-api/test/profileDeployOperation.test.ts b/app/gateway-api/test/profileDeployOperation.test.ts index b50a5059..f19848b4 100644 --- a/app/gateway-api/test/profileDeployOperation.test.ts +++ b/app/gateway-api/test/profileDeployOperation.test.ts @@ -225,12 +225,26 @@ describe('profile DEPLOY operation', () => { expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_API_URL'); expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_SSE_URL'); expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_PROFILE'); - expect(commandGroups[1]?.map((command) => command.args)).toEqual([ - ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], + expect(commandGroups[1]?.[0]?.args.slice(0, 6)).toEqual([ + '--filter', + '@sammo-ts/infra', + 'exec', + 'node', + '--input-type=module', + '--eval', + ]); + expect(commandGroups[1]?.[0]?.args.at(-1)).toContain("current_setting('TimeZone')"); + expect(commandGroups[1]?.[1]?.args).toEqual([ + '--filter', + '@sammo-ts/infra', + 'prisma:migrate:deploy:game', ]); expect(commandGroups[1]?.[0]?.env?.DATABASE_URL).toBe( 'postgresql://user:encoded%23password@integration.invalid/sammo?schema=che' ); + expect(commandGroups[1]?.[1]?.env?.DATABASE_URL).toBe( + 'postgresql://user:encoded%23password@integration.invalid/sammo?schema=che&options=-c+TimeZone%3DAsia%2FSeoul' + ); expect(startedDefinitions).toHaveLength(backendProcessNames.length); for (const definition of startedDefinitions) { expect(definition.env?.DATABASE_URL).toBe( diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 021a96e1..327a3702 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -39,7 +39,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20260823010000_add_web_push_notifications', - gameSchemaHead: '20260824070000_game_outbox_utc_wall_timestamps', + gameSchemaHead: '20260824080000_vote_utc_wall_timestamps', }); }); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 791db1ca..4a71cdd0 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -97,12 +97,12 @@ model ReadModelOutbox { id BigInt @id @default(autoincrement()) payload Json attempts Int @default(0) - availableAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("available_at") @db.Timestamp(3) + availableAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("available_at") @db.Timestamp(3) lockedAt DateTime? @map("locked_at") @db.Timestamp(3) lockOwner String? @map("lock_owner") deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3) lastError String? @map("last_error") - createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("created_at") @db.Timestamp(3) + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) @@index([deliveredAt, availableAt, id], map: "read_model_outbox_delivered_at_available_at_id_idx") @@map("read_model_outbox") @@ -116,12 +116,12 @@ model WebPushOutbox { year Int? month Int? attempts Int @default(0) - availableAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("available_at") @db.Timestamp(3) + availableAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("available_at") @db.Timestamp(3) lockedAt DateTime? @map("locked_at") @db.Timestamp(3) lockOwner String? @map("lock_owner") deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3) lastError String? @map("last_error") - createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("created_at") @db.Timestamp(3) + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) @@index([deliveredAt, availableAt, id], map: "web_push_outbox_delivered_at_available_at_id_idx") @@map("web_push_outbox") @@ -901,8 +901,8 @@ model VotePoll { endAt DateTime? @map("end_at") endTick BigInt? @map("end_tick") closedAt DateTime? @map("closed_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at") @db.Timestamp(3) votes Vote[] comments VoteComment[] @@ -916,7 +916,7 @@ model Vote { generalId Int @map("general_id") nationId Int @map("nation_id") selection Json - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade) @@ -933,7 +933,7 @@ model VoteComment { generalName String @map("general_name") nationName String @map("nation_name") text String - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade) diff --git a/packages/infra/prisma/migrations/20260824080000_vote_utc_wall_timestamps/migration.sql b/packages/infra/prisma/migrations/20260824080000_vote_utc_wall_timestamps/migration.sql new file mode 100644 index 00000000..0f9a8be0 --- /dev/null +++ b/packages/infra/prisma/migrations/20260824080000_vote_utc_wall_timestamps/migration.sql @@ -0,0 +1,15 @@ +-- Vote mutations use raw SQL to preserve their locked transaction and Ref +-- ordering. Game connections retain the Ref-compatible Seoul session, so make +-- timestamp-omitting insert fallbacks safe for older and future writers. +-- Historical rows are intentionally preserved because +-- Prisma-seeded UTC values and DB-default Seoul values have no durable +-- provenance marker that would allow a safe blanket rewrite. +ALTER TABLE "vote_poll" + ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + ALTER COLUMN "updated_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); + +ALTER TABLE "vote" + ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); + +ALTER TABLE "vote_comment" + ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); diff --git a/packages/infra/prisma/migrations/README.md b/packages/infra/prisma/migrations/README.md index 6af16e99..0d1cf44e 100644 --- a/packages/infra/prisma/migrations/README.md +++ b/packages/infra/prisma/migrations/README.md @@ -35,6 +35,11 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여 - `read_model_revision_meta.id=1`의 `coverage_version=0` - `diplomacy`, `event`, `log_entry`, `error_log` - auction, board, vote, yearbook, archive와 inheritance table +- `vote_poll.created_at/updated_at`, `vote.created_at`, `vote_comment.created_at`의 + 신규 raw-SQL fallback은 KST game session에서도 UTC wall 값을 기록한다. 현재 + writer는 JavaScript `Date`를 명시하고, 이전 writer 형태의 column 생략도 새 + default로 안전해야 한다. 기존 vote timestamp는 Prisma UTC와 raw KST 출처를 + 구분할 표식이 없어 소급 이동하지 않는다. - `nation.chief_general_id` - `city.trade` nullable, `city.trust` REAL - `auction_bid.meta` JSONB NOT NULL @@ -62,6 +67,26 @@ WebPush `created_at` 보존, 두 pending outbox의 requeue·lease 해제, delive 보존, target checksum과 이전 DML shape 호환성을 확인합니다. 이전 binary를 별도 build해 실행하거나 down migration을 제공한다는 뜻은 아닙니다. +운영 release-controller의 게임 migration 명령은 profile runtime URL을 바꾸지 +않고 migration 연결에만 `options=-c TimeZone=Asia/Seoul`을 추가합니다. 이미 +명시된 `DATABASE_URL` option/query, `PGOPTIONS` 또는 `PGTZ`가 다른 timezone을 +요구하면 마지막 옵션으로 덮지 않고 migration 시작 전에 실패합니다. 그 다음 +변경하지 않은 원본 profile URL로 `current_setting('TimeZone')`을 조회해 기존 +writer session이 실제로 `Asia/Seoul`인지 확인한 뒤에만, KST option을 고정한 +별도 URL로 Prisma migration을 실행합니다. 이는 이미 배포된 migration checksum을 +보존하면서 legacy game wall-clock provenance가 다른 과거 시각을 0700 migration이 +잘못 재해석하는 일을 막습니다. + +실제 fail-closed 경계는 timezone option이 없는 일회성 UTC-default role URL을 +`PROFILE_MIGRATION_UTC_DATABASE_URL`로 주입하고 다음처럼 재현합니다. 이 URL은 +격리 DB의 disposable role만 사용하며 문서·로그에 값을 남기지 않습니다. +marker는 `external_fixture`로 등록되어 일반 조건부 runner가 일회성 role을 만들거나 +이 테스트를 실행하지 않으며, 위 URL을 준비한 명시적 실행에서만 활성화됩니다. + +```sh +pnpm --filter @sammo-ts/gateway-api test profileMigrationTimezone.integration.test.ts +``` + ## NPC selection 중복 owner preflight `20260731000000_add_npc_selection_token`은 `general.user_id` 중복을 발견하면 diff --git a/release-manifest.json b/release-manifest.json index cf4fb87f..773e420f 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260823010000_add_web_push_notifications", - "gameSchemaHead": "20260824070000_game_outbox_utc_wall_timestamps", + "gameSchemaHead": "20260824080000_vote_utc_wall_timestamps", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] } From 69000d4cb968176d635f2256f413f0bb6a1451bb Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 09:18:52 +0000 Subject: [PATCH 6/8] =?UTF-8?q?test:=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20fixture=EC=9D=98=20=EC=8B=9C=EA=B3=84?= =?UTF-8?q?=EC=99=80=20=EC=86=8C=EC=9C=A0=EA=B6=8C=EC=9D=84=20=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/auctionWorker.integration.test.ts | 5 ++++ .../test/selectPool.integration.test.ts | 9 +++++-- ...eralActionsPersistence.integration.test.ts | 17 +++++++----- ...thlyInvaderPersistence.integration.test.ts | 4 +-- ...PoolReleasePersistence.integration.test.ts | 26 +++++++++---------- ...ofileMigrationTimezone.integration.test.ts | 23 ++++++++++++++++ tools/conditional-integration-registry.tsv | 2 ++ ...NationBettingLifecycle.integration.test.ts | 2 +- tools/run-conditional-integration.sh | 16 +++++++++++- 9 files changed, 78 insertions(+), 26 deletions(-) create mode 100644 app/gateway-api/test/profileMigrationTimezone.integration.test.ts diff --git a/app/game-api/test/auctionWorker.integration.test.ts b/app/game-api/test/auctionWorker.integration.test.ts index 17e83935..a93c97a1 100644 --- a/app/game-api/test/auctionWorker.integration.test.ts +++ b/app/game-api/test/auctionWorker.integration.test.ts @@ -34,6 +34,7 @@ liveDescribe('auction worker durable recovery', () => { const createdRequestPrefixes: string[] = []; const createdWorldIds: number[] = []; const createdGeneralIds: number[] = []; + const lifecycleUserIds = ['auction-durable-host', 'auction-durable-bidder']; beforeAll(async () => { await connector.connect(); @@ -56,6 +57,8 @@ liveDescribe('auction worker durable recovery', () => { await connector.prisma.worldState.deleteMany({ where: { id: { in: createdWorldIds } } }); } if (createdGeneralIds.length > 0) { + await connector.prisma.message.deleteMany({ where: { mailbox: { in: createdGeneralIds } } }); + await connector.prisma.webPushOutbox.deleteMany({ where: { userIds: { hasSome: lifecycleUserIds } } }); await connector.prisma.logEntry.deleteMany({ where: { generalId: { in: createdGeneralIds } } }); await connector.prisma.general.deleteMany({ where: { id: { in: createdGeneralIds } } }); } @@ -366,6 +369,8 @@ liveDescribe('auction worker durable recovery', () => { }; const lifecycleGeneralIds = [8_032, 8_033]; + await connector.prisma.message.deleteMany({ where: { mailbox: { in: lifecycleGeneralIds } } }); + await connector.prisma.webPushOutbox.deleteMany({ where: { userIds: { hasSome: lifecycleUserIds } } }); await connector.prisma.logEntry.deleteMany({ where: { generalId: { in: lifecycleGeneralIds } } }); await connector.prisma.general.deleteMany({ where: { id: { in: lifecycleGeneralIds } } }); await connector.prisma.worldState.deleteMany({ where: { id: worldId } }); diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index 11df30cd..3ae96910 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -472,7 +472,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => { }, 30_000); it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => { - const logicalNowMs = runtime!.world.getGameNow(new Date()).getTime(); + const logicalNow = runtime!.world.getGameNow(new Date()); + const logicalNowMs = logicalNow.getTime(); + const logicalNowTick = runtime!.world.dateToGameTick(logicalNow); const reservation = await appRouter .createCaller(buildContext('select-pool-other-reserve', otherAuth)) .join.getSelectionPool(); @@ -488,7 +490,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => { await db.selectPoolEntry.update({ where: { uniqueName: candidate.uniqueName }, - data: { reservedUntil: new Date(logicalNowMs - 60_000) }, + data: { + reservedUntil: new Date(logicalNowMs - 60_000), + reservedUntilTick: BigInt(logicalNowTick - 1), + }, }); await expect( appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({ diff --git a/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts b/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts index 1894b9f4..11233213 100644 --- a/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts +++ b/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts @@ -560,12 +560,6 @@ integration('immediate general action persistence', () => { generalId, text: expect.stringContaining('낙양'), }), - expect.objectContaining({ - scope: 'NATION', - category: 'HISTORY', - nationId: existingNationId + 1, - text: expect.stringContaining('통합장수'), - }), expect.objectContaining({ scope: 'SYSTEM', category: 'SUMMARY', @@ -578,6 +572,17 @@ integration('immediate general action persistence', () => { }), ]) ); + // Ref creates this action logger while the actor is still unaffiliated, + // so its queued national history is discarded when nationID is 0. + await expect( + db.logEntry.count({ + where: { + scope: 'NATION', + category: 'HISTORY', + nationId: existingNationId + 1, + }, + }) + ).resolves.toBe(0); const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); expect(reloaded.snapshot.generals.find((entry) => entry.id === generalId)).toMatchObject({ diff --git a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts index e5125874..1afbed48 100644 --- a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts @@ -502,7 +502,6 @@ integration('RaiseInvader database persistence', () => { diplomacyCountsPerNation: [2], diplomacyStates: ['1:24'], isunited: 1, - isUnited: 1, blockChangeScout: false, }); } @@ -633,7 +632,6 @@ integration('RaiseInvader database persistence', () => { result: 'Deleted', endingEventPresent: false, isunited: 3, - isUnited: 3, refreshLimit: 300, logs: [ '●200년 4월:【이벤트】이민족을 모두 소탕했습니다!', @@ -644,5 +642,5 @@ integration('RaiseInvader database persistence', () => { } finally { await dbHooks.close(); } - }); + }, 30_000); }); diff --git a/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts b/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts index 019dc151..4e902924 100644 --- a/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts +++ b/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts @@ -43,8 +43,8 @@ integration('select pool release during general deletion', () => { 'release-candidate', 'claim-candidate', 'conflict-candidate', - 'early-protected-candidate', - 'later-free-candidate', + 'early-protected', + 'later-free', ], }, }, @@ -203,8 +203,8 @@ integration('select pool release during general deletion', () => { 'release-candidate', 'claim-candidate', 'conflict-candidate', - 'early-protected-candidate', - 'later-free-candidate', + 'early-protected', + 'later-free', ], }, }, @@ -366,12 +366,12 @@ integration('select pool release during general deletion', () => { await db.selectPoolEntry.createMany({ data: [ { - uniqueName: 'early-protected-candidate', + uniqueName: 'early-protected', ownerUserId: 'protected-user', generalId: null, reservedUntil, info: { - uniqueName: 'early-protected-candidate', + uniqueName: 'early-protected', generalName: '보호후보', leadership: 70, strength: 80, @@ -383,12 +383,12 @@ integration('select pool release during general deletion', () => { } as GamePrisma.InputJsonValue, }, { - uniqueName: 'later-free-candidate', + uniqueName: 'later-free', ownerUserId: null, generalId: null, reservedUntil: null, info: { - uniqueName: 'later-free-candidate', + uniqueName: 'later-free', generalName: '후행후보', leadership: 70, strength: 80, @@ -406,8 +406,8 @@ integration('select pool release during general deletion', () => { schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] }, }); const entries = world.listGeneralPoolEntries()!; - const protectedCandidate = entries.find((entry) => entry.uniqueName === 'early-protected-candidate')!.candidate; - const laterCandidate = entries.find((entry) => entry.uniqueName === 'later-free-candidate')!.candidate; + const protectedCandidate = entries.find((entry) => entry.uniqueName === 'early-protected')!.candidate; + const laterCandidate = entries.find((entry) => entry.uniqueName === 'later-free')!.candidate; const template = world.getGeneralById(generalId)!; expect( world.addGeneral({ @@ -446,7 +446,7 @@ integration('select pool release during general deletion', () => { durationMs: 0, partial: false, }) - ).rejects.toThrow('select_pool 후보를 점유하지 못했습니다: early-protected-candidate'); + ).rejects.toThrow('select_pool 후보를 점유하지 못했습니다: early-protected'); } finally { await hooks.close(); } @@ -454,14 +454,14 @@ integration('select pool release during general deletion', () => { await expect(db.general.findUnique({ where: { id: protectedGeneralId } })).resolves.toBeNull(); await expect(db.general.findUnique({ where: { id: laterGeneralId } })).resolves.toBeNull(); await expect( - db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'early-protected-candidate' } }) + db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'early-protected' } }) ).resolves.toMatchObject({ generalId: null, ownerUserId: 'protected-user', reservedUntil, }); await expect( - db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'later-free-candidate' } }) + db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'later-free' } }) ).resolves.toMatchObject({ generalId: null, ownerUserId: null, reservedUntil: null }); }); diff --git a/app/gateway-api/test/profileMigrationTimezone.integration.test.ts b/app/gateway-api/test/profileMigrationTimezone.integration.test.ts new file mode 100644 index 00000000..436ba7a4 --- /dev/null +++ b/app/gateway-api/test/profileMigrationTimezone.integration.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { PnpmBuildRunner } from '../src/orchestrator/buildRunner.js'; +import { buildProfileMigrationPreflightCommand } from '../src/orchestrator/gatewayOrchestrator.js'; + +const utcDatabaseUrl = process.env.PROFILE_MIGRATION_UTC_DATABASE_URL?.trim(); + +describe('profile migration timezone preflight', () => { + it.skipIf(!utcDatabaseUrl)('rejects an actual database role whose default session timezone is UTC', async () => { + if (!utcDatabaseUrl) throw new Error('PROFILE_MIGRATION_UTC_DATABASE_URL is required'); + const path = process.env.PATH; + if (!path) throw new Error('PATH is required'); + const command = buildProfileMigrationPreflightCommand(process.cwd(), utcDatabaseUrl, { PATH: path }); + + const result = await new PnpmBuildRunner().run([command]); + + expect(result.ok).toBe(false); + expect(result.output).toContain('database session TimeZone does not match the required migration contract'); + expect(result.output).not.toContain(utcDatabaseUrl); + const password = decodeURIComponent(new URL(utcDatabaseUrl).password); + if (password) expect(result.output).not.toContain(password); + }); +}); diff --git a/tools/conditional-integration-registry.tsv b/tools/conditional-integration-registry.tsv index 8c227581..41d1bc1f 100644 --- a/tools/conditional-integration-registry.tsv +++ b/tools/conditional-integration-registry.tsv @@ -13,10 +13,12 @@ NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession PROFILE_SEED_CLI_DATABASE_URL core PROFILE_SEED_DATABASE_URL core PROFILE_LOCK_SECONDARY_DATABASE_URL core +PROFILE_MIGRATION_UTC_DATABASE_URL external_fixture READ_MODEL_JOURNAL_DATABASE_URL read_model_journal RESERVED_TURN_DATABASE_URL core SECURITY_TRANSPORT_DATABASE_URL security_transport SELECT_POOL_DATABASE_URL select_pool +SCENARIO_LIFECYCLE_DATABASE_URL scenario_lifecycle TURN_DAEMON_LEASE_DATABASE_URL core TURN_COMMAND_DURABLE_MATRIX_DATABASE_URL reference_command_durable_matrix TURN_DIFFERENTIAL_DATABASE_URL core diff --git a/tools/integration-tests/test/scenario29NationBettingLifecycle.integration.test.ts b/tools/integration-tests/test/scenario29NationBettingLifecycle.integration.test.ts index e6475e32..46b5d89d 100644 --- a/tools/integration-tests/test/scenario29NationBettingLifecycle.integration.test.ts +++ b/tools/integration-tests/test/scenario29NationBettingLifecycle.integration.test.ts @@ -14,7 +14,7 @@ import { type RedisConnector, } from '@sammo-ts/infra'; -const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const databaseUrl = process.env.SCENARIO_LIFECYCLE_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const scenarioId = 2900; const userIds = ['scenario29-betting-winner', 'scenario29-betting-loser'] as const; diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index e3524f6f..7439da19 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -41,7 +41,7 @@ node_tag=$(printf '%s' "${CI_NODE_INDEX:-local}" | tr -cd 'a-zA-Z0-9_' | tr 'A-Z run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag} export CONDITIONAL_INTEGRATION_RUN_ID=$run_id schema_ownership_token="sammo-conditional-integration:$run_id" -supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_command_durable_matrix reference_full_lifecycle reference_live_sortie reference_npc_possession security_transport select_pool web_push_gateway" +supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_command_durable_matrix reference_full_lifecycle reference_live_sortie reference_npc_possession scenario_lifecycle security_transport select_pool web_push_gateway" term_grace_seconds=${CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS:-10} case "$term_grace_seconds" in ''|*[!0-9]*) @@ -55,6 +55,7 @@ if [ "$term_grace_seconds" -lt 1 ] || [ "$term_grace_seconds" -gt 60 ]; then fi integration_schema=${CONDITIONAL_INTEGRATION_SCHEMA:-ci_${run_id}_integration} scenario_schema=${SCENARIO_SEED_INTEGRATION_SCHEMA:-ci_${run_id}_scenario_seed} +scenario_lifecycle_schema=${SCENARIO_LIFECYCLE_INTEGRATION_SCHEMA:-ci_${run_id}_scenario_lifecycle} npc_possession_schema=${NPC_POSSESSION_INTEGRATION_SCHEMA:-ci_${run_id}_npc_possession_integration} create_general_schema=${CREATE_GENERAL_INTEGRATION_SCHEMA:-ci_${run_id}_create_general_integration} select_pool_schema=${SELECT_POOL_INTEGRATION_SCHEMA:-ci_${run_id}_select_pool_integration} @@ -71,6 +72,7 @@ turn_full_lifecycle_schema=${TURN_FULL_LIFECYCLE_PERSISTENCE_SCHEMA:-ci_${run_id for schema in \ "$integration_schema" \ "$scenario_schema" \ + "$scenario_lifecycle_schema" \ "$npc_possession_schema" \ "$create_general_schema" \ "$select_pool_schema" \ @@ -485,6 +487,7 @@ cleanup_resources_started=1 create_owned_schema "$integration_schema" create_owned_schema "$npc_possession_schema" create_owned_schema "$scenario_schema" +create_owned_schema "$scenario_lifecycle_schema" database_url=$(build_database_url "$integration_schema") export POSTGRES_SCHEMA=$integration_schema @@ -510,6 +513,17 @@ run_marked_tests app/game-api "$core_database_markers" "game_api_postgresql" run_marked_tests app/game-engine "$core_database_markers" "game_engine_postgresql" run_marked_tests tools/integration-tests "$core_database_markers" "snapshot_postgresql" +scenario_lifecycle_database_url=$(build_database_url "$scenario_lifecycle_schema") +( + export POSTGRES_SCHEMA=$scenario_lifecycle_schema + export DATABASE_URL=$scenario_lifecycle_database_url + pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game +) +export SCENARIO_LIFECYCLE_DATABASE_URL=$scenario_lifecycle_database_url +run_marked_tests tools/integration-tests \ + "$(markers_for_mode scenario_lifecycle)" \ + "scenario_lifecycle_postgresql" + create_owned_schema "$read_model_journal_schema" read_model_journal_database_url=$(build_database_url "$read_model_journal_schema") ( From d6a9a426fb93f693c84bf1c4fc629b11394d65d4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 09:18:58 +0000 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20Game=20API=20daemon=20transaction?= =?UTF-8?q?=20inventory=EB=A5=BC=20=ED=98=84=EC=9E=AC=ED=99=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game-api-daemon-procedure-inventory.md | 103 +++++++++++------- 1 file changed, 65 insertions(+), 38 deletions(-) diff --git a/docs/architecture/game-api-daemon-procedure-inventory.md b/docs/architecture/game-api-daemon-procedure-inventory.md index 6d29ed45..81998e02 100644 --- a/docs/architecture/game-api-daemon-procedure-inventory.md +++ b/docs/architecture/game-api-daemon-procedure-inventory.md @@ -3,12 +3,14 @@ ## 범위와 판정 기준 `app/game-api/src/router/**`의 mutation에서 직접 또는 router 전용 helper를 거쳐 -`ctx.turnDaemon.requestCommand()`를 호출하는 46개 route를 조사했다. `authedProcedure`, -`accessAuthedProcedure`, `accessAuthedInputProcedure`는 mutation일 때 -`app/game-api/src/trpc.ts:42-75`의 API `input_event` transaction을 만든다. -`engineAuthedProcedure`, `accessEngineAuthedProcedure`, +`ctx.turnDaemon.requestCommand()`를 호출하는 **49개 route**를 조사했다. 하나의 route가 +여러 command를 보내더라도 route는 한 번만 세었다. + +`authedProcedure`, `accessAuthedProcedure`, `accessAuthedInputProcedure`는 mutation일 때 +`app/game-api/src/trpc.ts:61-104`, `:168-180`, `:215-221`의 API `input_event` +transaction을 만든다. `engineAuthedProcedure`, `accessEngineAuthedProcedure`, `accessEngineAuthedInputProcedure`는 인증/접속 계측만 수행하며 API outer transaction을 -만들지 않는다(`app/game-api/src/trpc.ts:150-175`). +만들지 않는다(`app/game-api/src/trpc.ts:182-192`, `:222-227`). 판정은 다음과 같다. @@ -16,48 +18,65 @@ transaction이 소유하고, ENGINE handler가 mutation 직전 mutable state를 다시 검증한다. - **혼합/saga 필요**: API DB write, Redis 원본 상태, 보상 명령 또는 API snapshot에서만 수행하는 권한/값 합성이 ENGINE 변경과 결합한다. procedure만 바꾸지 않는다. -- **기존 정상**: 이미 ENGINE procedure이고 API outer input event가 없다. +- **기존 ENGINE**: 이 inventory의 기존 기준선에서 이미 ENGINE procedure였고 API + outer input event가 없었다. +- **ENGINE 소유 + 불필요한 API outer**: gameplay durable mutation은 ENGINE이 전부 + 소유하지만 route가 아직 API `input_event`/journal transaction에 감싸여 있다. -ENGINE 전환 route의 명시적 `requestId`는 기존 middleware가 만든 child identity -`::engine:0:`를 유지한다. 따라서 deploy 전후 -동일 HTTP request identity의 ENGINE event가 달라지지 않는다. +현재 합계는 **ENGINE 전환 26 + 혼합 13 + 기존 ENGINE 9 + 불필요한 API +outer 1 = 49**다. -## 이번에 ENGINE procedure로 전환 +ENGINE 전환 route의 explicit `requestId`는 각 route가 기존에 사용하던 HTTP 요청 +또는 user/client-scoped durable identity를 유지한다. `inheritanceAction`은 +`:inherit.:engine:0:inheritanceAction`을 사용하고, +`join.getSelectionPool`은 `select-pool:::reserve`를 사용한다. +selection-pool create/reselect는 client request ID가 있을 때 +`select-pool:::`을, 없을 때 HTTP request/path identity를 사용한다 +(`app/game-api/src/router/join/index.ts:75-91`). -| route | 이전 outer transaction | API-side 작업 | ENGINE 소유 근거 | -| ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `general.vacation`, `general.setMySetting`, `general.dropItem` | 있음 | session-owned general 조회만 수행 | route `app/game-api/src/router/general/index.ts:657-706`; ENGINE이 general 존재/현재 설정/보유 item을 다시 검사하고 변경 `app/game-engine/src/turn/worldCommandHandler.ts:1476`, `:1514`, `:1571` | -| `nation.appoint`, `nation.changePermission`, `nation.kick` | 있음 | session actor 조회만 수행 | route `app/game-api/src/router/nation/endpoints/appoint.ts:7`, `changePermission.ts:7`, `kick.ts:7`; ENGINE이 actor 직위, 국가, 대상/도시를 다시 검사 `app/game-engine/src/turn/worldCommandHandler.ts:1648`, `:1718`, `:1888` | -| `troop.create`, `troop.join`, `troop.exit`, `troop.kick`, `troop.rename` | 있음 | actor 및 조기 권한/대상 조회; API DB write 없음 | route `app/game-api/src/router/troop/index.ts:165-314`; 동일 membership/leader/nation/name 검증과 mutation은 ENGINE `app/game-engine/src/turn/worldCommandHandler.ts:924`, `:1012`, `:1079`, `:1128`, `:1180` | -| `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`, `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique` | 있음 | auction/general/world 조기 validation; commit 뒤 Redis timer index 갱신 | route `app/game-api/src/router/auction/index.ts:325-649`; auction open/bid DB mutation과 경합/resource 재검증은 ENGINE transaction `app/game-engine/src/turn/worldCommandHandler.ts:1613`, `app/game-engine/src/auction/bidder.ts:183-550`. Redis zset은 durable auction row에서 재구성 가능한 scheduler index이며 API DB transaction의 일부가 아니었다. | -| `inherit.openUniqueAuction` | 있음 | world/general/minimum bid 조기 validation; inheritance point 차감 없음 | route `app/game-api/src/router/inherit/index.ts:794-835`; 공통 `auctionOpen` ENGINE handler와 Redis timer index만 사용 | +## ENGINE procedure로 전환된 route -합계 18개 route다. 모든 전환은 procedure 변경과 stable ENGINE request ID만 포함하며 -ENGINE handler, DB schema, journal/publisher foundation은 변경하지 않았다. +| route | 현재 procedure / API-side 작업 | ENGINE 소유 근거 | +| --- | --- | --- | +| `general.vacation`, `general.setMySetting`, `general.dropItem` | `engineAuthedProcedure`/`accessEngineAuthedInputProcedure`; session-owned general 조회만 수행 (`app/game-api/src/router/general/index.ts:767-815`) | ENGINE이 general 존재, 현재 설정과 item 보유를 다시 검증하고 변경 (`app/game-engine/src/turn/worldCommandHandler.ts:1712-1841`) | +| `nation.appoint`, `nation.changePermission`, `nation.kick` | `engineAuthedProcedure`; session actor 조회만 수행 (`app/game-api/src/router/nation/endpoints/appoint.ts:7-32`, `changePermission.ts:7-35`, `kick.ts:7-24`) | ENGINE이 actor 직위, 국가, 대상/도시를 다시 검증 (`app/game-engine/src/turn/worldCommandHandler.ts:1894-2291`) | +| `troop.create`, `troop.join`, `troop.exit`, `troop.kick`, `troop.rename` | `engineAuthedProcedure`; actor 및 조기 권한/대상 조회, API DB write 없음 (`app/game-api/src/router/troop/index.ts:429-577`) | membership/leader/nation/name 검증과 mutation을 ENGINE이 소유 (`app/game-engine/src/turn/worldCommandHandler.ts:1131-1430`) | +| `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`, `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique` | `engineAuthedProcedure`; auction/general/world 조기 validation과 commit 뒤 Redis timer index 갱신 (`app/game-api/src/router/auction/index.ts:337-662`) | open/bid DB mutation과 경합/resource 재검증은 ENGINE transaction (`app/game-engine/src/turn/worldCommandHandler.ts:1859-1885`, `app/game-engine/src/auction/bidder.ts`). Redis zset은 durable auction row에서 재구성하는 scheduler index다. | +| `inherit.openUniqueAuction` | `engineAuthedProcedure`; world/general/minimum bid 조기 validation (`app/game-api/src/router/inherit/index.ts:389-428`) | 공통 `auctionOpen` ENGINE handler가 mutation을 소유하고 API는 Redis timer index만 갱신 (`app/game-api/src/auction/open.ts:22-51`) | +| `join.getSelectionPool` | `engineAuthedProcedure`; actor와 accepted game time만 전달 (`app/game-api/src/router/join/index.ts:390-406`) | `selectPoolReserve` ENGINE handler가 world/DB 상태에서 예약을 재검증하고 저장 (`app/game-engine/src/turn/worldCommandHandler.ts:401-437`) | +| `inherit.buyHiddenBuff`, `inherit.setNextSpecialWar`, `inherit.resetSpecialWar`, `inherit.resetTurnTime`, `inherit.resetStat`, `inherit.buyRandomUnique`, `inherit.checkOwner` | 모두 `engineAuthedProcedure`; 인증 user ID와 action 입력만 전달 (`app/game-api/src/router/inherit/index.ts:107-124`, `:301-388`, `:429-445`) | `inheritanceAction` ENGINE handler가 general/user 소유권, 통일 상태, 잔액, 대상, RNG, general patch, inheritance point/log/message를 한 transaction에서 처리 (`app/game-engine/src/turn/worldCommandHandler.ts:807-818`, `app/game-engine/src/turn/inheritanceActionService.ts:313-669`) | + +합계 **26개 route**다. ## 혼합 또는 validation 이관이 먼저 필요한 route -| route | 현재 procedure / outer transaction | 보류 근거 | -| -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `messages.respond` | `authedProcedure`, action별 분기 (`app/game-api/src/router/messages/index.ts`) | `scout`와 `raiseInvader`는 stable `messageRespond` ENGINE command가 actor/message를 다시 잠그고 처리한다. `noAggression`/`cancelNA`/`stopWar`는 기존 API transaction의 `respondToDiplomaticMessage`가 처리하므로 route 전체를 ENGINE-owned로 분류하지 않는다. 두 경로 모두 commit journal을 남긴다. | -| `inherit.buyHiddenBuff`, `inherit.setNextSpecialWar`, `inherit.resetSpecialWar`, `inherit.resetTurnTime`, `inherit.resetStat`, `inherit.buyRandomUnique` | `authedProcedure`, 있음 (`app/game-api/src/router/inherit/index.ts:343`, `:405`, `:486`, `:542`, `:599`, `:746`) | ENGINE `patchGeneral` 뒤 API transaction이 inheritance point, inheritance log, 일부 user-state를 쓴다(`:388-402`, `:464-483`, `:523-539`, `:581-596`, `:700-742`, `:777-790`). 현재 outer transaction도 먼저 commit된 ENGINE 변경을 rollback하지 못한다. 한 ENGINE command로 합치거나 durable saga가 필요하다. | -| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `authedProcedure`, 있음 (`app/game-api/src/router/nation/endpoints/setNotice.ts:11`, `setScoutMsg.ts:11`, `setSecretLimit.ts:10`, `setRate.ts:10`, `setBlockWar.ts:10`, `setBill.ts:10`, `setBlockScout.ts:10`) | API가 actor 권한과 nation meta를 읽어 full metadata patch를 합성한다. ENGINE `setNationMeta`는 `_updatedAt` CAS만 검사하고 actor 권한을 알지 못한다(`app/game-engine/src/turn/worldCommandHandler.ts:430-472`). actor/permission을 command와 ENGINE validation으로 옮긴 뒤 전환한다. | -| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessAuthedInputProcedure`, 있음 (`app/game-api/src/router/npc/index.ts:540`, `:703`, `:756`) | API가 nation/general/world를 읽어 권한, unit-set 기반 기본값과 full policy object를 합성한 뒤 같은 `setNationMeta` CAS를 사용한다(`:540-702`, `:703-755`, `:756-807`). ENGINE이 권한/합성 의미를 소유하지 않는다. | -| `tournament.join`, `tournament.placeBet` | `authedProcedure`, 있음 (`app/game-api/src/router/tournament/index.ts:376`, `:523`) | PostgreSQL ENGINE resource/meta 명령과 Redis-owned participants/bets를 결합하고 실패 시 보상 ENGINE 명령을 보낸다(`:376-463`, `:523-628`). 하나의 DB transaction이 아니며 durable saga/Redis atomic revision이 필요하다. | -| `vote.submitVote` | `authedProcedure`, 있음 (`app/game-api/src/router/vote/index.ts:349-528`) | API transaction이 vote row를 insert한 뒤 ENGINE `voteReward`를 기다리고 commit 뒤 front-status publish를 수행한다. vote/reward 단일 소유 command 또는 idempotent saga 없이는 분리할 수 없다. 이 작업에서는 vote journal/publisher를 수정하지 않았다. | +| route | 현재 procedure / outer transaction | 보류 근거 | +| --- | --- | --- | +| `messages.respond` | `authedProcedure`, action별 분기 (`app/game-api/src/router/messages/index.ts:318-372`) | `scout`/`raiseInvader`는 `messageRespond` ENGINE command가 처리하지만 `noAggression`/`cancelNA`/`stopWar`는 API transaction의 `respondToDiplomaticMessage` 경로가 처리한다. route 전체를 ENGINE-owned로 보지 않는다. | +| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `authedProcedure`, outer 있음 (`app/game-api/src/router/nation/endpoints/setNotice.ts:11`, `setScoutMsg.ts:11`, `setSecretLimit.ts:10`, `setRate.ts:10`, `setBlockWar.ts:10`, `setBill.ts:10`, `setBlockScout.ts:10`) | API가 actor 권한과 nation meta를 읽어 full metadata patch를 합성한다. `setNationMeta`는 `_updatedAt` CAS만 검사하므로 actor/permission과 합성 의미를 ENGINE으로 옮겨야 한다 (`app/game-api/src/router/nation/shared.ts:431-458`, `app/game-engine/src/turn/worldCommandHandler.ts:499-542`). | +| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessAuthedInputProcedure`, outer 있음 (`app/game-api/src/router/npc/index.ts:545-812`) | API가 nation/general/world를 읽어 권한, unit-set 기반 기본값과 full policy object를 합성한 뒤 `setNationMeta` CAS를 사용한다. ENGINE은 권한/합성 의미를 소유하지 않는다. | +| `tournament.join`, `tournament.placeBet` | `authedProcedure`, outer 있음 (`app/game-api/src/router/tournament/index.ts:393-458`, `:515-620`) | PostgreSQL ENGINE resource/meta 명령과 Redis-owned participants/bets를 결합하고 실패 시 보상 ENGINE 명령을 보낸다. 하나의 DB transaction이 아니며 durable saga/Redis atomic revision이 필요하다. | -합계 20개 route다. 특히 inheritance/vote의 현재 outer transaction은 API 절반만 -rollback하므로 “원자적”이라고 간주하면 안 된다. +합계 **13개 route**다. -## 이미 API outer transaction이 없는 정상 route +## 기존에 API outer transaction이 없던 ENGINE route -| route | 근거 | -| ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `general.adjustIcon` | `engineAuthedProcedure`; `app/game-api/src/router/general/index.ts:584-610`. helper가 stable account-icon request ID로 ENGINE command를 보냄. | -| `general.ensureDieOnPrestartStatus`, `general.dieOnPrestart`, `general.buildNationCandidate`, `general.instantRetreat` | `accessEngineAuthedProcedure`/`accessEngineAuthedInputProcedure`; `app/game-api/src/router/general/index.ts:612-656`. user/general 조회는 outer transaction 밖이고 command마다 stable request ID가 있다. | -| `join.selectPoolGeneral`, `join.reselectPoolGeneral`, `join.createGeneral`, `join.possessGeneral` | `engineAuthedProcedure`; `app/game-api/src/router/join/index.ts:390`, `:434`, `:462`, `:585`. client request ID가 있으면 user-scoped durable ENGINE identity를 사용한다. | +| route | 근거 | +| --- | --- | +| `general.adjustIcon` | `engineAuthedProcedure`; helper가 stable account-icon request ID로 ENGINE command를 보냄 (`app/game-api/src/router/general/index.ts:694-720`, `app/game-api/src/services/accountIconSync.ts:40-75`) | +| `general.ensureDieOnPrestartStatus`, `general.dieOnPrestart`, `general.buildNationCandidate`, `general.instantRetreat` | `accessEngineAuthedProcedure`/`accessEngineAuthedInputProcedure`; user/general 조회는 outer transaction 밖이고 command마다 stable request ID가 있음 (`app/game-api/src/router/general/index.ts:104-144`, `:722-766`) | +| `join.selectPoolGeneral`, `join.reselectPoolGeneral`, `join.createGeneral`, `join.possessGeneral` | `engineAuthedProcedure`; client request ID가 있으면 user-scoped durable identity를 사용 (`app/game-api/src/router/join/index.ts:407-564`, `:608-642`) | -합계 9개 route다. +합계 **9개 route**다. + +## ENGINE이 소유하지만 API outer transaction이 남은 route + +| route | 현재 상태 | 남은 일 | +| --- | --- | --- | +| `vote.submitVote` | `authedProcedure`가 API outer `input_event` transaction을 만든다. API는 poll/actor 조기 validation 후 `voteReward`를 보내고 `front.general` journal을 표시한다 (`app/game-api/src/router/vote/index.ts:294-369`). ENGINE은 poll row lock, 선택 validation, vote insert, reward/idempotency marker, 금·아이템·로그 변경을 한 mutation transaction에서 소유한다 (`app/game-engine/src/turn/worldCommandHandler.ts:2430-2847`). | viewer-specific `front.general` invalidation을 ENGINE commit journal로 옮기고, 현재 middleware가 만드는 `voteReward` child identity를 explicit request ID로 보존한 뒤 ENGINE procedure로 전환한다. | + +합계 **1개 route**다. Gameplay DB mutation 소유권 기준으로는 불필요한 outer이지만, +`front.general` journal이 API에 남아 있으므로 procedure만 바꾸면 실시간 갱신 계약을 잃는다. ## 검증 계약 @@ -68,6 +87,14 @@ rollback하므로 “원자적”이라고 간주하면 안 된다. - `app/game-api/test/troopRouter.test.ts`: troop mutation의 동일 계약을 검증한다. - `app/game-api/test/auctionRouter.test.ts`: auction mutation이 API transaction 없이 daemon command와 Redis timer projection을 완료하는 계약을 검증한다. +- `app/game-api/test/inheritRouter.test.ts`, + `app/game-engine/test/inheritanceActionPersistence.integration.test.ts`: 인증 actor, point/log, + general/message 변경이 `inheritanceAction` ENGINE transaction에 함께 있는지 검증한다. +- `app/game-api/test/voteRouter.test.ts`, `app/game-engine/test/voteReward.test.ts`: API 조기 + validation과 ENGINE의 vote insert/reward/idempotency 경계를 검증한다. API outer/journal + 제거는 아직 검증 대상이 아니다. - raw inventory 재검색: `rg -n "requestCommand\\(" app/game-api/src/router`와 - `openAuctionWithDaemon`, `patchGeneral`, `updateNationMeta`, - `adjustAccountIconForUser` caller 검색을 함께 실행해야 helper 경유 route를 놓치지 않는다. + `openAuctionWithDaemon`, `requestInheritanceAction`, `updateNationMeta`, + `adjustAccountIconForUser`, `requestImmediateAction`, `requestJoinCreateCommand`, + `requestNpcPossessionCommand` caller 검색을 함께 실행해 helper 경유 route를 놓치지 + 않는다. From b41fac08c1a6199306b1b8a32d3c286a5ab2508c Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 09:42:25 +0000 Subject: [PATCH 8/8] =?UTF-8?q?test:=20=EC=B5=9C=EC=8B=A0=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=EA=B3=BC=20=EB=8F=99=EC=8B=9C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20fixture=EB=A5=BC=20=EA=B5=90=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/createGeneral.integration.test.ts | 10 ++++++++ .../test/selectPool.integration.test.ts | 24 +++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts index 52ff8068..1056ee91 100644 --- a/app/game-api/test/createGeneral.integration.test.ts +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -24,6 +24,7 @@ const profile = 'hwe:2'; const userId = 'create-general-integration-user'; const failureUserId = 'create-general-integration-failure-user'; const rejectedUserId = 'create-general-integration-rejected-user'; +const customIconId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : ''; const assertDedicatedDatabase = (rawUrl: string): void => { @@ -52,6 +53,14 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam imageServer: 2, iconUpdatedAt: '2026-07-30T00:00:00.000Z', canUseGeneralPicture: true, + icons: [ + { + id: customIconId, + picture: 'custom-owner.webp', + imageServer: 2, + createdAt: '2026-07-30T00:00:00.000Z', + }, + ], }, sanctions: { legacyPenalty: { @@ -239,6 +248,7 @@ integration('generic general creation through the durable turn daemon', () => { strength: 55, intel: 55, pic: true, + iconId: customIconId, character: 'che_안전' as const, clientRequestId, inheritTurntimeZone: 7, diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index 12f6da45..43c3a50f 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -241,26 +241,40 @@ integration('scenario 903 select pool through the durable turn daemon', () => { }, }); + const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const; const attempts = await Promise.allSettled([ - appRouter.createCaller(buildContext('select-pool-create-a')).join.selectPoolGeneral({ + appRouter.createCaller(buildContext(createRequestIds[0])).join.selectPoolGeneral({ uniqueName: firstReservation.candidates[0]!.uniqueName, personality: 'che_안전', }), - appRouter.createCaller(buildContext('select-pool-create-b')).join.selectPoolGeneral({ + appRouter.createCaller(buildContext(createRequestIds[1])).join.selectPoolGeneral({ uniqueName: firstReservation.candidates[1]!.uniqueName, personality: 'che_유지', }), ]); expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(1); expect(attempts.filter((attempt) => attempt.status === 'rejected')).toHaveLength(1); + const successfulAttemptIndex = attempts.findIndex((attempt) => attempt.status === 'fulfilled'); + if (successfulAttemptIndex < 0) { + throw new Error('one concurrent selection request must succeed'); + } + const successfulRequestId = createRequestIds[successfulAttemptIndex]; + if (!successfulRequestId) { + throw new Error('successful selection request must have a request ID'); + } const initial = await db.general.findFirstOrThrow({ where: { userId } }); const initialRuntime = runtime!.world.getGeneralById(initial.id); const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } }); expect(initial).toMatchObject({ picture: 'default.jpg', imageServer: 0 }); - const acceptedEvent = await db.inputEvent.findFirstOrThrow({ - where: { actorUserId: userId, eventType: 'selectPoolCreate', status: 'SUCCEEDED' }, - orderBy: { sequence: 'desc' }, + const acceptedEvent = await db.inputEvent.findUniqueOrThrow({ + where: { requestId: `${successfulRequestId}:join.selectPoolGeneral` }, + }); + expect(acceptedEvent).toMatchObject({ + actorUserId: userId, + eventType: 'selectPoolCreate', + status: 'SUCCEEDED', + result: { type: 'selectPoolCreate', ok: true, generalId: initial.id }, }); if (!initialAccess.lastRefresh) { throw new Error('selected general must have an initial access timestamp');