From 92ad49db45766ddf3614681638ebd1ea6b0f91e6 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 06:13:28 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=86=B5=EC=9D=BC=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=20=EB=92=A4=20=ED=84=B4=20=EC=8B=A4=ED=96=89=EC=9D=84=20?= =?UTF-8?q?=EC=A4=91=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref처럼 isunited 2/3에서 예약 스케줄을 멈추고 processor 직접 호출도 no-op 처리한다. 이미 기본값으로 내려간 종료 게임의 refreshLimit만 식별해 복구하는 마이그레이션과 회귀 테스트를 추가한다. --- .../src/lifecycle/turnDaemonLifecycle.ts | 6 ++++ app/game-engine/src/lifecycle/types.ts | 1 + .../src/turn/inMemoryStateStore.ts | 6 ++++ .../src/turn/inMemoryTurnProcessor.ts | 11 ++++++ .../test/turnDaemonLifecycle.test.ts | 34 +++++++++++++++++++ app/game-engine/test/turnOrder.test.ts | 9 +++++ app/gateway-api/test/releaseManifest.test.ts | 2 +- .../migration.sql | 14 ++++++++ release-manifest.json | 2 +- 9 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 packages/infra/prisma/migrations/20260820001000_restore_united_turn_halt/migration.sql diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 5ee64911..08c19558 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -152,6 +152,12 @@ export class TurnDaemonLifecycle { this.status.state = 'idle'; } + if ((await this.stateStore.shouldHaltScheduledRuns?.()) ?? false) { + this.status.nextTurnTime = undefined; + await this.clock.sleepMs(500); + continue; + } + if (this.pendingRun) { await this.runOnce(this.pendingRun); this.pendingRun = null; diff --git a/app/game-engine/src/lifecycle/types.ts b/app/game-engine/src/lifecycle/types.ts index 507c8004..f6efd4da 100644 --- a/app/game-engine/src/lifecycle/types.ts +++ b/app/game-engine/src/lifecycle/types.ts @@ -52,6 +52,7 @@ export interface TurnStateStore { saveLastTurnTime(turnTime: Date): Promise; loadCheckpoint(): Promise; saveCheckpoint(checkpoint?: TurnCheckpoint): Promise; + shouldHaltScheduledRuns?(): Promise; loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>; advanceGameClockTo?(target: Date, wallNow: Date): Promise; } diff --git a/app/game-engine/src/turn/inMemoryStateStore.ts b/app/game-engine/src/turn/inMemoryStateStore.ts index 2d7012cb..989b9195 100644 --- a/app/game-engine/src/turn/inMemoryStateStore.ts +++ b/app/game-engine/src/turn/inMemoryStateStore.ts @@ -1,4 +1,5 @@ import type { TurnCheckpoint, TurnStateStore } from '../lifecycle/types.js'; +import { asNumber, asRecord } from '@sammo-ts/common'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; export class InMemoryTurnStateStore implements TurnStateStore { @@ -29,6 +30,11 @@ export class InMemoryTurnStateStore implements TurnStateStore { this.world.setCheckpoint(checkpoint); } + async shouldHaltScheduledRuns(): Promise { + const meta = asRecord(this.world.getState().meta); + return asNumber(meta.isunited ?? meta.isUnited, 0) >= 2; + } + async loadGameClock(wallNow = new Date(Date.now())): Promise<{ mode: 'realtime' | 'manual'; now: Date }> { return { mode: this.world.getGameClockState().mode, diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index 6ba01021..1dc66e8a 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -51,6 +51,17 @@ export class InMemoryTurnProcessor implements TurnProcessor { const deadlineMs = startMs + Math.max(0, budget.budgetMs); const isBudgetExpired = () => Date.now() >= deadlineMs; + if (isWorldUnited(this.world)) { + return { + lastTurnTime: this.world.getState().lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 0, + durationMs: Math.max(0, Date.now() - startMs), + partial: false, + checkpoint, + }; + } + this.world.setCheckpoint(checkpoint); this.world.updateWorldMeta({ refreshLimit: calculateAccessRefreshLimit(this.world.getState().tickSeconds), diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index c26befe6..176db4f4 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -14,6 +14,40 @@ import { const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); describe('TurnDaemonLifecycle', () => { + it('does not schedule another processor run after the world reaches a terminal united state', async () => { + const clock = new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()); + const controlQueue = new InMemoryControlQueue(); + const processor = { run: vi.fn() }; + const lifecycle = new TurnDaemonLifecycle( + { + clock, + controlQueue, + getNextTickTime: (value) => addMinutes(value, 60), + stateStore: { + loadLastTurnTime: async () => new Date('2042-01-01T00:00:00.000Z'), + loadNextGeneralTurnTime: async () => new Date('2042-01-01T00:30:00.000Z'), + saveLastTurnTime: async () => {}, + loadCheckpoint: async () => undefined, + saveCheckpoint: async () => {}, + shouldHaltScheduledRuns: async () => { + controlQueue.enqueue({ type: 'shutdown', reason: 'terminal world verified' }); + return true; + }, + }, + processor, + }, + { + profile: 'terminal-united', + defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 }, + } + ); + + await lifecycle.start(); + + expect(processor.run).not.toHaveBeenCalled(); + expect(lifecycle.getStatus().nextTurnTime).toBeUndefined(); + }); + it('runs manual game time to each monthly snapshot without waiting for wall time', async () => { const wallNow = new Date('2026-01-01T00:00:00.000Z'); const operationalClock = new ManualClock(wallNow.getTime()); diff --git a/app/game-engine/test/turnOrder.test.ts b/app/game-engine/test/turnOrder.test.ts index 46b649bc..e324748f 100644 --- a/app/game-engine/test/turnOrder.test.ts +++ b/app/game-engine/test/turnOrder.test.ts @@ -286,5 +286,14 @@ describe('InMemoryTurnProcessor ordering', () => { expect(result.processedTurns).toBe(1); expect(world.getState()).toMatchObject({ currentYear: 189, currentMonth: 2, meta: { isUnited: 2 } }); + + world.updateWorldMeta({ refreshLimit: 12_000 }); + const haltedResult = await processor.run(addMinutes(baseTime, 60), { + budgetMs: 1_000, + maxGenerals: 10, + catchUpCap: 10, + }); + expect(haltedResult).toMatchObject({ processedGenerals: 0, processedTurns: 0 }); + expect(world.getState().meta).toMatchObject({ isUnited: 2, refreshLimit: 12_000 }); }); }); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 05bd741e..be4a7d3d 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: '20260819000000_backfill_profile_release_source', - gameSchemaHead: '20260820000000_widen_emperor_officer_pictures', + gameSchemaHead: '20260820001000_restore_united_turn_halt', }); }); diff --git a/packages/infra/prisma/migrations/20260820001000_restore_united_turn_halt/migration.sql b/packages/infra/prisma/migrations/20260820001000_restore_united_turn_halt/migration.sql new file mode 100644 index 00000000..da711b5d --- /dev/null +++ b/packages/infra/prisma/migrations/20260820001000_restore_united_turn_halt/migration.sql @@ -0,0 +1,14 @@ +-- isunited 2/3에서 Ref 턴 실행은 중단되고, 통일 시 늘린 refreshLimit가 유지된다. +-- 기존 core daemon pass가 기본값으로 되돌린 행만 식별해 100배 값을 복구한다. +UPDATE "world_state" +SET "meta" = jsonb_set( + "meta", + '{refreshLimit}', + to_jsonb((("meta" ->> 'refreshLimit')::integer * 100)), + true +) +WHERE COALESCE("meta" ->> 'isunited', "meta" ->> 'isUnited', '0') ~ '^[0-9]+$' + AND COALESCE("meta" ->> 'isunited', "meta" ->> 'isUnited', '0')::integer >= 2 + AND COALESCE("meta" ->> 'refreshLimit', '') ~ '^[0-9]+$' + AND ("meta" ->> 'refreshLimit')::integer = + ROUND(POWER("tick_seconds"::numeric / 60, 0.6) * 3)::integer * 10; diff --git a/release-manifest.json b/release-manifest.json index 60eadb22..4cac89dc 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260819000000_backfill_profile_release_source", - "gameSchemaHead": "20260820000000_widen_emperor_officer_pictures", + "gameSchemaHead": "20260820001000_restore_united_turn_halt", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }