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

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 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: {
@@ -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', () => {
+14 -2
View File
@@ -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);
@@ -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: {
@@ -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'
@@ -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([
@@ -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);
});
@@ -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 });