From 380020ab2f813a6f48ccb05bc4580029dec5d788 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 14 Sep 2026 23:56:38 +0000 Subject: [PATCH] =?UTF-8?q?=EC=9C=A0=EC=82=B0=20=EB=9E=9C=EB=8D=A4=20?= =?UTF-8?q?=ED=84=B4=20=EC=B4=88=EA=B8=B0=ED=99=94=EC=9D=98=20=EB=8B=A4?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C=20=ED=84=B4=20=EC=A0=81=EC=9A=A9=EA=B3=BC=20?= =?UTF-8?q?=EB=B6=84=EC=B4=88=20=ED=91=9C=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/inherit/index.ts | 6 +- .../test/inheritResetTurnTime.test.ts | 4 +- app/game-engine/src/turn/inMemoryWorld.ts | 16 +++- .../src/turn/inheritanceActionService.ts | 6 +- .../src/turn/reservedTurnHandler.ts | 10 +-- .../test/generalTurnLifecycle.test.ts | 75 ++++++++++++++++++- ...tanceActionPersistence.integration.test.ts | 64 +++++++++++++++- .../test/inheritanceActionService.test.ts | 44 +++++++++++ .../inheritance-management.spec.ts | 23 +++++- 9 files changed, 223 insertions(+), 25 deletions(-) diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index 2bb0886e..928f7a30 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -139,9 +139,9 @@ const buildTurnTimeZoneList = (tickMinutes: number): string[] => { const formatTurnTimeBaseLabel = (value: number): string => { const wholeSeconds = Math.trunc(value); - const hours = String(Math.trunc(wholeSeconds / 3600)).padStart(2, '0'); - const minutes = String(Math.trunc((wholeSeconds % 3600) / 60)).padStart(2, '0'); - return `${hours}:${minutes}`; + const minutes = String(Math.trunc(wholeSeconds / 60)).padStart(2, '0'); + const seconds = String(wholeSeconds % 60).padStart(2, '0'); + return `${minutes}:${seconds}`; }; export const resolveResetTurnTimeBase = (options: { diff --git a/app/game-api/test/inheritResetTurnTime.test.ts b/app/game-api/test/inheritResetTurnTime.test.ts index cb679d7b..15577cf0 100644 --- a/app/game-api/test/inheritResetTurnTime.test.ts +++ b/app/game-api/test/inheritResetTurnTime.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { resolveResetTurnTimeBase } from '../src/router/inherit/index.js'; describe('inherit reset turn time Ref compatibility', () => { - it('matches the Ref PHP deterministic seed, offset, and displayed minute', () => { + it('matches the Ref PHP deterministic seed, offset, and displayed minutes and seconds', () => { const result = resolveResetTurnTimeBase({ hiddenSeed: 'hidden-seed', userId: 'user-7', @@ -12,7 +12,7 @@ describe('inherit reset turn time Ref compatibility', () => { }); expect(result.nextTurnTimeBase).toBeCloseTo(302.5143852464758, 12); - expect(result.nextTurnTimeLabel).toBe('00:05'); + expect(result.nextTurnTimeLabel).toBe('05:02'); }); it('uses the prior pending base as the next deterministic seed input', () => { diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 6d660f16..6a86fae2 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1905,13 +1905,25 @@ export class InMemoryTurnWorld { schedule: this.schedule, }); - const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule); + let nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule); if (!result.deleted?.general) { const resolvedGeneral = result.general ?? currentGeneral; const clock = this.getGameClock(); const currentTurnTick = currentGeneral.turnTick ?? clock.dateToTick(currentGeneral.turnTime); - const nextTurnTick = + let nextTurnTick = currentTurnTick + (clock.dateToTick(nextTurnAt) - clock.dateToTick(currentGeneral.turnTime)); + const nextTurnTimeBase = readMetaNumber(resolvedGeneral.meta, 'nextTurnTimeBase'); + if (nextTurnTimeBase !== null && nextTurnTimeBase >= 0) { + // Ref: addTurn → cutTurn → 새 offset. 기존 분·초를 더하지 않고 + // 논리 월 경계에서 교체해야 다다음 턴과 로그가 일치한다. + // Date를 경유하면 난수의 sub-ms tick과 기존 tick 꼬리가 섞인다. + nextTurnTick = + Math.floor(nextTurnTick / GAME_TICKS_PER_TURN) * GAME_TICKS_PER_TURN + + Math.round(nextTurnTimeBase * clock.ticksPerSecond); + nextTurnAt = clock.tickToDate(nextTurnTick); + resolvedGeneral.meta = { ...resolvedGeneral.meta }; + delete resolvedGeneral.meta.nextTurnTimeBase; + } const recentWarTimeChanged = (resolvedGeneral.recentWarTime?.getTime() ?? null) !== (currentGeneral.recentWarTime?.getTime() ?? null); diff --git a/app/game-engine/src/turn/inheritanceActionService.ts b/app/game-engine/src/turn/inheritanceActionService.ts index a3b89a19..6b3bbc36 100644 --- a/app/game-engine/src/turn/inheritanceActionService.ts +++ b/app/game-engine/src/turn/inheritanceActionService.ts @@ -177,9 +177,9 @@ export const buildResetStatRandomBonus = ( const formatTurnTimeBaseLabel = (value: number): string => { const wholeSeconds = Math.trunc(value); - const hours = String(Math.trunc(wholeSeconds / 3_600)).padStart(2, '0'); - const minutes = String(Math.trunc((wholeSeconds % 3_600) / 60)).padStart(2, '0'); - return `${hours}:${minutes}`; + const minutes = String(Math.trunc(wholeSeconds / 60)).padStart(2, '0'); + const seconds = String(wholeSeconds % 60).padStart(2, '0'); + return `${minutes}:${seconds}`; }; const resolveResetTurnTimeBase = (options: { diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index adad2135..bab44756 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -35,7 +35,6 @@ import { resolveUniqueConfig, readScenarioGeneralPoolClaim, rollUniqueLotteryDetailed, - getNextTurnAt, getBillByLevel, LEGACY_DEFAULT_MAX_LEVEL, orderLegacyActionLoggerFlush, @@ -2196,7 +2195,7 @@ export const createReservedTurnHandler = async (options: { aiDecisionDurationNs: generalAiDecisionDurationNs, actionDurationNs: generalActionDurationNs, }); - let nextTurnAt = 'nextTurnAt' in generalResult ? generalResult.nextTurnAt : undefined; + const nextTurnAt = 'nextTurnAt' in generalResult ? generalResult.nextTurnAt : undefined; options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1); const worldMeta = asRecord(context.world.meta); @@ -2241,13 +2240,6 @@ export const createReservedTurnHandler = async (options: { Math.trunc(autorunLimitMinutes / turnMinutes); } - const nextTurnTimeBase = readMetaNumber(currentGeneral.meta, 'nextTurnTimeBase', -1); - if (nextTurnTimeBase >= 0) { - const alignedNextTurn = nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, context.schedule); - nextTurnAt = new Date(alignedNextTurn.getTime() + nextTurnTimeBase * 1000); - delete currentGeneral.meta.nextTurnTimeBase; - } - const explicitlyRetired = generalResult.actionKey === 'che_은퇴' && generalResult.completed; let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = explicitlyRetired ? 'retired' diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts index 5eaa5ac1..72ac8ec4 100644 --- a/app/game-engine/test/generalTurnLifecycle.test.ts +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it } from 'vitest'; -import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; +import { describe, expect, it, vi } from 'vitest'; +import { GAME_TICKS_PER_TURN, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; import { finalizeLogEntry, type TurnSchedule } from '@sammo-ts/logic'; +import type { GamePrisma } from '@sammo-ts/infra'; +import { executeInheritanceAction } from '../src/turn/inheritanceActionService.js'; + import { rankMetaKey } from '../src/turn/rankData.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js'; @@ -282,6 +285,74 @@ describe('legacy general turn lifecycle', () => { expect(updated.turnTime.toISOString()).toBe('0200-01-01T00:10:30.000Z'); }); + it('applies the purchased and logged random offset on the second upcoming turn, then keeps it', async () => { + const generalTurnTime = new Date('0200-01-01T00:07:43.000Z'); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([ + makeGeneral({ + userId: 'user-7', + turnTime: generalTurnTime, + meta: { killturn: 24, nextTurnTimeBase: 123_456 }, + }), + ]), + state: makeState({ hiddenSeed: 'hidden-seed' }), + schedule, + map, + }); + const createLog = vi.fn(async () => ({})); + const db = { + $queryRaw: vi.fn(async () => [{ value: 100_000 }]), + inheritanceLog: { create: createLog }, + } as unknown as GamePrisma.TransactionClient; + const result = await executeInheritanceAction({ + db, + world: harness.world, + command: { type: 'inheritanceAction', userId: 'user-7', input: { action: 'resetTurnTime' } }, + gameNow: start, + }); + expect(result).toMatchObject({ ok: true, nextTurnTimeBase: 302.5143852464758, nextTurnTimeLabel: '05:02' }); + expect(createLog).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ text: expect.stringContaining('다다음 턴부터 05:02 적용') }), + }) + ); + expect(harness.world.getGeneralById(1)!.turnTime).toEqual(generalTurnTime); + await harness.runOneTick(); + const updated = harness.world.getGeneralById(1)!; + expect(updated.meta.nextTurnTimeBase).toBeUndefined(); + expect(updated.turnTime.toISOString()).toBe('0200-01-01T00:15:02.514Z'); + const expectedTick = GAME_TICKS_PER_TURN + Math.round((302.5143852464758 * GAME_TICKS_PER_TURN) / 600); + expect(updated.turnTick).toBe(expectedTick); + expect(updated.meta.inherit_lived_month).toBe(1); + await harness.runOneTick(); + expect(harness.world.getGeneralById(1)!.turnTick).toBe(expectedTick + GAME_TICKS_PER_TURN); + expect(harness.world.getGeneralById(1)!.turnTime.toISOString()).toBe('0200-01-01T00:25:02.514Z'); + expect(harness.world.getGeneralById(1)!.meta.inherit_lived_month).toBe(2); + }); + + it.each([0, 30, 599.999])( + 'replaces a nonzero old offset with %s seconds at the logical boundary', + async (offset) => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([ + makeGeneral({ + turnTime: new Date('0200-01-01T00:09:43.123Z'), + turnTick: 34_987_381, + meta: { killturn: 24, nextTurnTimeBase: offset }, + }), + ]), + state: makeState(), + schedule, + map, + }); + await harness.runOneTick({ maxGenerals: 1 }); + expect(harness.world.getGeneralById(1)!.turnTick).toBe( + GAME_TICKS_PER_TURN + Math.round((offset * GAME_TICKS_PER_TURN) / 600) + ); + expect(harness.world.getGeneralById(1)!.meta.nextTurnTimeBase).toBeUndefined(); + } + ); + it('detaches an expired possessed NPC instead of deleting its body', async () => { const harness = await createTurnTestHarness({ snapshot: makeSnapshot([ diff --git a/app/game-engine/test/inheritanceActionPersistence.integration.test.ts b/app/game-engine/test/inheritanceActionPersistence.integration.test.ts index cf44f121..9de09843 100644 --- a/app/game-engine/test/inheritanceActionPersistence.integration.test.ts +++ b/app/game-engine/test/inheritanceActionPersistence.integration.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common'; +import { GAME_TICKS_PER_TURN, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common'; import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic'; @@ -55,6 +55,14 @@ const state: TurnWorldState = { currentMonth: 4, tickSeconds: 600, lastTurnTime: new Date('2026-08-24T00:00:00.000Z'), + clockBaseTime: new Date('2026-08-24T00:00:00.000Z'), + clockTick: 0, + lastTurnTick: 0, + clockMode: 'manual', + clockPhase: 'MANUAL', + clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'), + clockRevision: 1, + deadlineGeneration: 1, meta: { hiddenSeed: 'inheritance-atomic-seed', season: 77, isunited: 0, scenarioMeta }, }; @@ -183,6 +191,14 @@ integration('inheritance action PostgreSQL atomic persistence', () => { currentYear: state.currentYear, currentMonth: state.currentMonth, tickSeconds: state.tickSeconds, + clockBaseTime: state.clockBaseTime, + clockTick: 0n, + lastTurnTick: 0n, + clockMode: state.clockMode, + clockPhase: state.clockPhase, + clockWallAnchor: state.clockWallAnchor, + clockRevision: 1n, + deadlineGeneration: 1n, config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue, }, @@ -427,5 +443,51 @@ integration('inheritance action PostgreSQL atomic persistence', () => { }, inheritancePoints: { previous: 5_800 }, }); + + const oldTurnTime = new Date('2026-08-24T00:17:43.123Z'); + world.updateGeneral(actorGeneralId, { turnTime: oldTurnTime }); + const resetCommand = buildCommand('reset-turn', { action: 'resetTurnTime' }); + await createInputEvent(resetCommand); + const resetResult = await execute(resetCommand); + if (resetResult.type !== 'inheritanceAction' || !resetResult.ok || resetResult.nextTurnTimeBase === undefined) { + throw new Error('Expected a successful reset with its pending offset.'); + } + const offset = resetResult.nextTurnTimeBase; + const label = `${String(Math.trunc(offset / 60)).padStart(2, '0')}:${String(Math.trunc(offset) % 60).padStart(2, '0')}`; + expect(resetResult.nextTurnTimeLabel).toBe(label); + await expect( + db.inheritanceLog.findFirstOrThrow({ + where: { userId: actorUserId, text: { contains: '다다음 턴부터' } }, + }) + ).resolves.toMatchObject({ text: `1000 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${label} 적용` }); + const pending = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + const pendingGeneral = pending.snapshot.generals.find((general) => general.id === actorGeneralId)!; + expect(pendingGeneral.turnTime).toEqual(oldTurnTime); + expect(pendingGeneral.meta.nextTurnTimeBase).toBe(offset); + expect(pendingGeneral.inheritancePoints?.previous).toBe(4_800); + // 저장한 pending을 새 world에서 실행하고 flush/reload한다. + const restarted = new InMemoryTurnWorld(pending.state, pending.snapshot, { schedule }); + restarted.executeGeneralTurn(restarted.getGeneralById(actorGeneralId)!); + const expectedTick = 2 * GAME_TICKS_PER_TURN + Math.round((offset * GAME_TICKS_PER_TURN) / 600); + expect(restarted.getGeneralById(actorGeneralId)!.turnTick).toBe(expectedTick); + const restartedHooks = await createDatabaseTurnHooks(databaseUrl!, restarted); + try { + await restartedHooks.hooks.flushChanges!({ + lastTurnTime: pending.state.lastTurnTime.toISOString(), + processedGenerals: 1, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + const applied = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + const appliedGeneral = applied.snapshot.generals.find((general) => general.id === actorGeneralId)!; + expect(appliedGeneral.turnTick).toBe(expectedTick); + expect(appliedGeneral.meta.nextTurnTimeBase).toBeUndefined(); + const resumed = new InMemoryTurnWorld(applied.state, applied.snapshot, { schedule }); + resumed.executeGeneralTurn(resumed.getGeneralById(actorGeneralId)!); + expect(resumed.getGeneralById(actorGeneralId)!.turnTick).toBe(expectedTick + GAME_TICKS_PER_TURN); + } finally { + await restartedHooks.close(); + } }, 30_000); }); diff --git a/app/game-engine/test/inheritanceActionService.test.ts b/app/game-engine/test/inheritanceActionService.test.ts index 6d08bd00..257a6677 100644 --- a/app/game-engine/test/inheritanceActionService.test.ts +++ b/app/game-engine/test/inheritanceActionService.test.ts @@ -214,6 +214,50 @@ describe('inheritance action service', () => { expect(createLog).toHaveBeenCalled(); }); + it('draws once per successful reset, chains pending seeds, and does not draw on insufficient points', async () => { + const draw = vi.spyOn(LiteHashDRBG.prototype, 'nextFloat1'); + try { + const world = buildWorld({}); + const { db } = buildDatabase(); + const before = world.getGeneralById(1)!.turnTime; + const first = await execute(world, db, { action: 'resetTurnTime' }); + const second = await execute(world, db, { action: 'resetTurnTime' }); + if (!first.ok || !second.ok) throw new Error('Expected successful resets'); + expect(draw).toHaveBeenCalledTimes(2); + expect(second.nextTurnTimeBase).not.toBe(first.nextTurnTimeBase); + expect(world.getGeneralById(1)!.turnTime).toEqual(before); + expect(world.getGeneralById(1)!.meta.nextTurnTimeBase).toBe(second.nextTurnTimeBase); + const failedDb = buildDatabase({ point: 0 }); + await expect(execute(world, failedDb.db, { action: 'resetTurnTime' })).resolves.toMatchObject({ + ok: false, + reason: '충분한 유산 포인트를 가지고 있지 않습니다.', + }); + expect(draw).toHaveBeenCalledTimes(2); + expect(failedDb.createLog).not.toHaveBeenCalled(); + expect(world.getGeneralById(1)!.meta.nextTurnTimeBase).toBe(second.nextTurnTimeBase); + } finally { + draw.mockRestore(); + } + }); + + it('samples the whole turn interval reproducibly across fixed hidden seeds', async () => { + const offsets: number[] = []; + for (let seed = 0; seed < 64; seed++) { + const world = buildWorld({ worldMeta: { hiddenSeed: `reset-sample-${seed}` } }); + const result = await execute(world, buildDatabase().db, { action: 'resetTurnTime' }); + if (!result.ok || result.nextTurnTimeBase === undefined) throw new Error('Expected a sampled offset'); + expect(result.nextTurnTimeBase).toBeGreaterThanOrEqual(0); + expect(result.nextTurnTimeBase).toBeLessThanOrEqual(3_600); + offsets.push(result.nextTurnTimeBase); + } + expect(new Set(offsets).size).toBe(64); + expect(new Set(offsets.map((offset) => Math.floor(offset / 900))).size).toBe(4); + const replay = await execute(buildWorld({ worldMeta: { hiddenSeed: 'reset-sample-0' } }), buildDatabase().db, { + action: 'resetTurnTime', + }); + expect(replay).toMatchObject({ nextTurnTimeBase: offsets[0] }); + }); + it('keeps free ResetStat at zero spend and uses the Ref-compatible fixed-seed bonus', async () => { const world = buildWorld({}); const { db } = buildDatabase({ point: 0 }); diff --git a/tools/frontend-legacy-parity/inheritance-management.spec.ts b/tools/frontend-legacy-parity/inheritance-management.spec.ts index 32d99348..b1869dec 100644 --- a/tools/frontend-legacy-parity/inheritance-management.spec.ts +++ b/tools/frontend-legacy-parity/inheritance-management.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page, type Route } from '@playwright/test'; -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import { dirname, extname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -198,7 +198,7 @@ const installFixture = async ( } if (name === 'inherit.resetTurnTime') { resetTurnMutationCount += 1; - return response({ ok: true, nextTurnTimeBase: 302.5143852464758, nextTurnTimeLabel: '00:05' }); + return response({ ok: true, nextTurnTimeBase: 302.5143852464758, nextTurnTimeLabel: '05:02' }); } if (name === 'inherit.openUniqueAuction') { uniqueAuctionRequests.push(requestBody); @@ -252,8 +252,25 @@ test.describe('inheritance management legacy parity', () => { }); await button.click(); - await expect(item).toContainText('적용 시간: 00:05'); + await expect(item).toContainText('적용 시간: 05:02'); expect(fixture.resetTurnMutationCount()).toBe(1); + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'inherit-reset-turn.png'), fullPage: true }); + await writeFile( + resolve(artifactRoot, 'inherit-reset-turn.json'), + JSON.stringify( + await item.evaluate((element) => ({ + text: element.textContent, + html: element.outerHTML, + rect: element.getBoundingClientRect().toJSON(), + font: getComputedStyle(element).font, + buttonDisabled: element.querySelector('button')?.disabled, + })), + null, + 2 + ) + ); + } }); test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => {