유산 랜덤 턴 초기화의 다다음 턴 적용과 분초 표시 수정

This commit is contained in:
2026-09-14 23:56:38 +00:00
parent 854a91c397
commit 380020ab2f
9 changed files with 223 additions and 25 deletions
+3 -3
View File
@@ -139,9 +139,9 @@ const buildTurnTimeZoneList = (tickMinutes: number): string[] => {
const formatTurnTimeBaseLabel = (value: number): string => { const formatTurnTimeBaseLabel = (value: number): string => {
const wholeSeconds = Math.trunc(value); const wholeSeconds = Math.trunc(value);
const hours = String(Math.trunc(wholeSeconds / 3600)).padStart(2, '0'); const minutes = String(Math.trunc(wholeSeconds / 60)).padStart(2, '0');
const minutes = String(Math.trunc((wholeSeconds % 3600) / 60)).padStart(2, '0'); const seconds = String(wholeSeconds % 60).padStart(2, '0');
return `${hours}:${minutes}`; return `${minutes}:${seconds}`;
}; };
export const resolveResetTurnTimeBase = (options: { export const resolveResetTurnTimeBase = (options: {
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import { resolveResetTurnTimeBase } from '../src/router/inherit/index.js'; import { resolveResetTurnTimeBase } from '../src/router/inherit/index.js';
describe('inherit reset turn time Ref compatibility', () => { 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({ const result = resolveResetTurnTimeBase({
hiddenSeed: 'hidden-seed', hiddenSeed: 'hidden-seed',
userId: 'user-7', userId: 'user-7',
@@ -12,7 +12,7 @@ describe('inherit reset turn time Ref compatibility', () => {
}); });
expect(result.nextTurnTimeBase).toBeCloseTo(302.5143852464758, 12); 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', () => { it('uses the prior pending base as the next deterministic seed input', () => {
+14 -2
View File
@@ -1905,13 +1905,25 @@ export class InMemoryTurnWorld {
schedule: this.schedule, 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) { if (!result.deleted?.general) {
const resolvedGeneral = result.general ?? currentGeneral; const resolvedGeneral = result.general ?? currentGeneral;
const clock = this.getGameClock(); const clock = this.getGameClock();
const currentTurnTick = currentGeneral.turnTick ?? clock.dateToTick(currentGeneral.turnTime); const currentTurnTick = currentGeneral.turnTick ?? clock.dateToTick(currentGeneral.turnTime);
const nextTurnTick = let nextTurnTick =
currentTurnTick + (clock.dateToTick(nextTurnAt) - clock.dateToTick(currentGeneral.turnTime)); 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 = const recentWarTimeChanged =
(resolvedGeneral.recentWarTime?.getTime() ?? null) !== (resolvedGeneral.recentWarTime?.getTime() ?? null) !==
(currentGeneral.recentWarTime?.getTime() ?? null); (currentGeneral.recentWarTime?.getTime() ?? null);
@@ -177,9 +177,9 @@ export const buildResetStatRandomBonus = (
const formatTurnTimeBaseLabel = (value: number): string => { const formatTurnTimeBaseLabel = (value: number): string => {
const wholeSeconds = Math.trunc(value); const wholeSeconds = Math.trunc(value);
const hours = String(Math.trunc(wholeSeconds / 3_600)).padStart(2, '0'); const minutes = String(Math.trunc(wholeSeconds / 60)).padStart(2, '0');
const minutes = String(Math.trunc((wholeSeconds % 3_600) / 60)).padStart(2, '0'); const seconds = String(wholeSeconds % 60).padStart(2, '0');
return `${hours}:${minutes}`; return `${minutes}:${seconds}`;
}; };
const resolveResetTurnTimeBase = (options: { const resolveResetTurnTimeBase = (options: {
@@ -35,7 +35,6 @@ import {
resolveUniqueConfig, resolveUniqueConfig,
readScenarioGeneralPoolClaim, readScenarioGeneralPoolClaim,
rollUniqueLotteryDetailed, rollUniqueLotteryDetailed,
getNextTurnAt,
getBillByLevel, getBillByLevel,
LEGACY_DEFAULT_MAX_LEVEL, LEGACY_DEFAULT_MAX_LEVEL,
orderLegacyActionLoggerFlush, orderLegacyActionLoggerFlush,
@@ -2196,7 +2195,7 @@ export const createReservedTurnHandler = async (options: {
aiDecisionDurationNs: generalAiDecisionDurationNs, aiDecisionDurationNs: generalAiDecisionDurationNs,
actionDurationNs: generalActionDurationNs, actionDurationNs: generalActionDurationNs,
}); });
let nextTurnAt = 'nextTurnAt' in generalResult ? generalResult.nextTurnAt : undefined; const nextTurnAt = 'nextTurnAt' in generalResult ? generalResult.nextTurnAt : undefined;
options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1); options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1);
const worldMeta = asRecord(context.world.meta); const worldMeta = asRecord(context.world.meta);
@@ -2241,13 +2240,6 @@ export const createReservedTurnHandler = async (options: {
Math.trunc(autorunLimitMinutes / turnMinutes); 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; const explicitlyRetired = generalResult.actionKey === 'che_은퇴' && generalResult.completed;
let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = explicitlyRetired let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = explicitlyRetired
? 'retired' ? 'retired'
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; import { GAME_TICKS_PER_TURN, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
import { finalizeLogEntry, type TurnSchedule } from '@sammo-ts/logic'; 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 { rankMetaKey } from '../src/turn/rankData.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.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'); 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 () => { it('detaches an expired possessed NPC instead of deleting its body', async () => {
const harness = await createTurnTestHarness({ const harness = await createTurnTestHarness({
snapshot: makeSnapshot([ snapshot: makeSnapshot([
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic'; import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
@@ -55,6 +55,14 @@ const state: TurnWorldState = {
currentMonth: 4, currentMonth: 4,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: new Date('2026-08-24T00:00:00.000Z'), 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 }, meta: { hiddenSeed: 'inheritance-atomic-seed', season: 77, isunited: 0, scenarioMeta },
}; };
@@ -183,6 +191,14 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
currentYear: state.currentYear, currentYear: state.currentYear,
currentMonth: state.currentMonth, currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds, 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, config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue,
}, },
@@ -427,5 +443,51 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
}, },
inheritancePoints: { previous: 5_800 }, 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); }, 30_000);
}); });
@@ -214,6 +214,50 @@ describe('inheritance action service', () => {
expect(createLog).toHaveBeenCalled(); 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 () => { it('keeps free ResetStat at zero spend and uses the Ref-compatible fixed-seed bonus', async () => {
const world = buildWorld({}); const world = buildWorld({});
const { db } = buildDatabase({ point: 0 }); const { db } = buildDatabase({ point: 0 });
@@ -1,5 +1,5 @@
import { expect, test, type Page, type Route } from '@playwright/test'; 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 { dirname, extname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -198,7 +198,7 @@ const installFixture = async (
} }
if (name === 'inherit.resetTurnTime') { if (name === 'inherit.resetTurnTime') {
resetTurnMutationCount += 1; 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') { if (name === 'inherit.openUniqueAuction') {
uniqueAuctionRequests.push(requestBody); uniqueAuctionRequests.push(requestBody);
@@ -252,8 +252,25 @@ test.describe('inheritance management legacy parity', () => {
}); });
await button.click(); await button.click();
await expect(item).toContainText('적용 시간: 00:05'); await expect(item).toContainText('적용 시간: 05:02');
expect(fixture.resetTurnMutationCount()).toBe(1); 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 }) => { test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => {