유산 랜덤 턴 초기화의 다다음 턴 적용과 분초 표시 수정
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user