diff --git a/app/game-engine/src/turn/monthlyInvaderAction.ts b/app/game-engine/src/turn/monthlyInvaderAction.ts index a4537630..f60f9dde 100644 --- a/app/game-engine/src/turn/monthlyInvaderAction.ts +++ b/app/game-engine/src/turn/monthlyInvaderAction.ts @@ -549,6 +549,7 @@ export const createInvaderEndingHandler = (options: { isUnited: 3, refreshLimit: readNumber(meta.refreshLimit) * 100, }); + world.completeGameClock(); world.removeEvent(environment.currentEventID); }; }; diff --git a/app/game-engine/test/monthlyInvaderAction.test.ts b/app/game-engine/test/monthlyInvaderAction.test.ts index 657e2187..bc0d5b15 100644 --- a/app/game-engine/test/monthlyInvaderAction.test.ts +++ b/app/game-engine/test/monthlyInvaderAction.test.ts @@ -126,6 +126,7 @@ const buildHarness = (options?: { currentMonth: 1, tickSeconds: 600, lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + clockPhase: 'RUNNING', meta: { hiddenSeed: 'raise-invader-fixture', serverId: 'fixture-server', @@ -410,6 +411,7 @@ describe('invader monthly actions', () => { await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); expect(world.getState().meta).toMatchObject({ isunited: 3, isUnited: 3, refreshLimit: 300 }); + expect(world.getState().clockPhase).toBe('COMPLETED'); expect(world.listEvents()).toHaveLength(0); expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([ '【이벤트】이민족을 모두 소탕했습니다!', @@ -449,6 +451,7 @@ describe('invader monthly actions', () => { await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); expect(world.getState().meta).toMatchObject({ isunited: 3, isUnited: 3, refreshLimit: 300 }); + expect(world.getState().clockPhase).toBe('COMPLETED'); expect(world.listEvents()).toHaveLength(0); expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([ '【이벤트】중원은 이민족에 의해 혼란에 빠졌습니다.', diff --git a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts index 1afbed48..27534cdb 100644 --- a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts @@ -338,6 +338,12 @@ integration('RaiseInvader database persistence', () => { currentYear: 199, currentMonth: 12, tickSeconds: 600, + clockBaseTime: new Date('0200-01-01T00:00:00.000Z'), + clockTick: 0n, + clockMode: 'realtime', + clockWallAnchor: new Date('0200-01-01T00:00:00.000Z'), + lastTurnTick: 0n, + clockPhase: 'RUNNING', config: {}, meta: { hiddenSeed: serverId, @@ -354,6 +360,14 @@ integration('RaiseInvader database persistence', () => { currentMonth: 12, tickSeconds: 600, lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + clockBaseTime: new Date('0200-01-01T00:00:00.000Z'), + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: new Date('0200-01-01T00:00:00.000Z'), + lastTurnTick: 0, + clockPhase: 'RUNNING', + clockRevision: 1, + deadlineGeneration: 1, meta: { hiddenSeed: serverId, lastGeneralId: firstCreatedGeneralId - 1, @@ -610,6 +624,7 @@ integration('RaiseInvader database persistence', () => { leasedNationKeys: [], }); expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({ + clockPhase: 'COMPLETED', meta: expect.objectContaining({ isunited: 3, isUnited: 3, refreshLimit: 300 }), }); expect( diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 5bbcd0ea..50a0cb49 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: '20260825000000_add_bulk_release_batches', - gameSchemaHead: '20260903183000_turn_daemon_lease_utc_wall', + gameSchemaHead: '20260903201500_complete_invader_game_clock', }); }); diff --git a/packages/infra/prisma/migrations/20260903201500_complete_invader_game_clock/migration.sql b/packages/infra/prisma/migrations/20260903201500_complete_invader_game_clock/migration.sql new file mode 100644 index 00000000..1fa87b74 --- /dev/null +++ b/packages/infra/prisma/migrations/20260903201500_complete_invader_game_clock/migration.sql @@ -0,0 +1,33 @@ +-- InvaderEnding is the terminal gameplay boundary. Older rows can have +-- isUnited=3 while the logical game clock remains RUNNING because the handler +-- historically removed its event without completing the clock. +-- +-- Deployment runs game-schema migrations while the profile runtime is stopped, +-- so this backfill installs the missing terminal state without racing a daemon. +-- Any unchosen raise-invader alternatives are no longer actionable after the +-- world leaves the unification-wait state; close their GAME_TIME action state at +-- the terminal authoritative tick while preserving the WALL_TIME envelopes. +BEGIN; + +UPDATE "world_state" +SET "clock_phase" = 'COMPLETED' +WHERE "clock_phase" IN ('RUNNING', 'MANUAL') + AND GREATEST( + CASE WHEN "meta"->>'isUnited' ~ '^[0-9]+$' THEN ("meta"->>'isUnited')::integer ELSE 0 END, + CASE WHEN "meta"->>'isunited' ~ '^[0-9]+$' THEN ("meta"->>'isunited')::integer ELSE 0 END + ) >= 3; + +UPDATE "message_action" AS action +SET "status" = 'RESOLVED', + "resolved_game_tick" = GREATEST(action."created_game_tick", world."clock_tick"), + "updated_at_wall" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' +FROM "world_state" AS world +WHERE action."action_type" = 'raiseInvader' + AND action."status" = 'PENDING' + AND world."clock_tick" IS NOT NULL + AND GREATEST( + CASE WHEN world."meta"->>'isUnited' ~ '^[0-9]+$' THEN (world."meta"->>'isUnited')::integer ELSE 0 END, + CASE WHEN world."meta"->>'isunited' ~ '^[0-9]+$' THEN (world."meta"->>'isunited')::integer ELSE 0 END + ) <> 2; + +COMMIT; diff --git a/release-manifest.json b/release-manifest.json index bda80525..9373355e 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260825000000_add_bulk_release_batches", - "gameSchemaHead": "20260903183000_turn_daemon_lease_utc_wall", + "gameSchemaHead": "20260903201500_complete_invader_game_clock", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }