merge: 최신 main을 턴 실패 개인 기록 작업에 반영

# Conflicts:
#	app/game-engine/src/turn/reservedTurnHandler.ts
This commit is contained in:
2026-08-20 16:25:00 +00:00
44 changed files with 2619 additions and 276 deletions
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
@@ -15,6 +16,7 @@ integration('general access score reset persistence', () => {
let closeDb: (() => Promise<void>) | undefined;
const cleanup = async () => {
await db.logEntry.deleteMany({ where: { generalId } });
await db.generalAccessLog.deleteMany({ where: { generalId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.worldState.deleteMany({ where: { scenarioCode } });
@@ -33,8 +35,9 @@ integration('general access score reset persistence', () => {
await closeDb?.();
});
it('commits the own-turn reset marker in the same world flush', async () => {
it('commits the own-turn reset marker and per-entry log occurrence time in the same world flush', async () => {
const turnTime = new Date('2026-08-15T00:10:00.000Z');
const occurredAt = new Date('2026-08-15T00:07:43.000Z');
await db.general.create({
data: {
id: generalId,
@@ -95,6 +98,13 @@ integration('general access score reset persistence', () => {
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
);
world.markGeneralAccessScoreReset(generalId);
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
text: '<C>●</>1월:아무것도 실행하지 않았습니다.',
generalId,
occurredAt,
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
@@ -113,6 +123,12 @@ integration('general access score reset persistence', () => {
refreshScoreTotal: 999,
});
expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([]);
expect(
await db.logEntry.findFirstOrThrow({
where: { generalId, category: LogCategory.ACTION },
select: { createdAt: true },
})
).toEqual({ createdAt: occurredAt });
} finally {
await hooks.close();
}
@@ -159,6 +159,25 @@ const makeState = (meta: Record<string, unknown> = {}): TurnWorldState => ({
});
describe('legacy general turn lifecycle', () => {
it('timestamps action logs with the executing general turn instead of the shared flush cursor', async () => {
const flushCursor = new Date('0200-01-01T00:35:00.000Z');
const generalTurnTime = new Date('0200-01-01T00:37:43.000Z');
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([makeGeneral({ turnTime: generalTurnTime })]),
state: { ...makeState(), lastTurnTime: flushCursor },
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: '휴식', args: {} };
await harness.runOneTick();
const actionLog = harness.world
.peekDirtyState()
.logs.find((log) => log.text.includes('아무것도 실행하지 않았습니다.'));
expect(actionLog?.occurredAt).toEqual(generalTurnTime);
});
it('emits legacy plain logs when command gains cross experience and dedication levels', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([
@@ -224,4 +224,31 @@ describe('레거시 사령부 턴 실행 호환성', () => {
},
]);
});
it('첩보 도시는 실행 월부터 세 달 보이고 각 월 시작에 감소한 뒤 만료된다', async () => {
const nation = {
id: 1,
meta: {
rate: 20,
spy: { 2: 3 },
},
};
const handler = createNationTurnMonthlyHandler({
getWorld: () =>
({
listNations: () => [nation],
updateNation: (_id: number, patch: { meta?: typeof nation.meta }) => {
if (patch.meta) nation.meta = patch.meta;
},
}) as never,
});
expect(nation.meta.spy).toEqual({ 2: 3 });
await handler.beforeMonthChanged?.({} as never);
expect(nation.meta.spy).toEqual({ 2: 2 });
await handler.beforeMonthChanged?.({} as never);
expect(nation.meta.spy).toEqual({ 2: 1 });
await handler.beforeMonthChanged?.({} as never);
expect(nation.meta.spy).toEqual({});
});
});
@@ -128,6 +128,58 @@ describeDb('scenario database seed', () => {
}
});
test('persists the next official game index without counting cancelled or unfinished games', async () => {
const marker = `scenario-seeder-game-index-${Date.now()}`;
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
try {
const completedBefore = await connector.prisma.gameHistory.count({ where: { status: 'COMPLETED' } });
await connector.prisma.gameHistory.createMany({
data: [
{
serverId: `${marker}-completed`,
date: new Date('2026-08-01T00:00:00.000Z'),
season: 1,
scenario: 1010,
scenarioName: '정상 종료 fixture',
status: 'COMPLETED',
},
{
serverId: `${marker}-abandoned`,
date: new Date('2026-08-02T00:00:00.000Z'),
season: 1,
scenario: 1010,
scenarioName: '취소 fixture',
status: 'ABANDONED',
},
{
serverId: `${marker}-open`,
date: new Date('2026-08-03T00:00:00.000Z'),
season: 1,
scenario: 1010,
scenarioName: '미완료 fixture',
status: 'OPEN',
},
],
});
await seedScenarioToDatabase({
scenarioId: 1010,
databaseUrl,
installOptions: { serverId: marker },
});
const worldState = await connector.prisma.worldState.findFirstOrThrow();
expect(worldState.meta).toMatchObject({ gameIdx: completedBefore + 2 });
await expect(
connector.prisma.gameHistory.findUniqueOrThrow({ where: { serverId: marker } })
).resolves.toMatchObject({ status: 'OPEN' });
} finally {
await connector.prisma.gameHistory.deleteMany({ where: { serverId: { startsWith: marker } } });
await connector.disconnect();
}
});
test('writes scenario data into tables', async () => {
const { seed } = await seedScenarioToDatabase({
scenarioId,