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

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
@@ -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 });