NPC 명령의 실제 검사와 준비 및 대체 실행 순서를 감사 기록에 연결
This commit is contained in:
@@ -43,3 +43,16 @@ export const buildAuditDecisionFixture = (id: string, serverId = 'decision-old')
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const buildAuditExecutionFixture = () => ({
|
||||
kind: 'EXECUTION_ATTEMPT' as const,
|
||||
attempt: 0,
|
||||
requestedAction: 'che_징병',
|
||||
resolvedAction: 'che_징병',
|
||||
executedAction: '휴식',
|
||||
checks: [{ stage: 'CONSTRAINT' as const, action: 'che_징병', result: 'deny' as const, reason: '자원 부족' }],
|
||||
completed: true,
|
||||
usedFallback: true,
|
||||
alternativeAction: null,
|
||||
preparation: null,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PendingAuditDecision } from '../src/playAudit/decision.js';
|
||||
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
|
||||
import {
|
||||
LogFormat,
|
||||
@@ -395,8 +396,39 @@ describe('legacy general-turn execution contract', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('persists pre-turn stacking and applies the inherited 60-turn cooldown', async () => {
|
||||
it('records the real disband-to-talent-search alternative in execution order', async () => {
|
||||
const decisions: PendingAuditDecision[] = [];
|
||||
const general = makeGeneral({ npcState: 2, officerLevel: 12 });
|
||||
const snapshot = makeSnapshot(general);
|
||||
snapshot.nations[0] = { ...snapshot.nations[0]!, level: 0, chiefGeneralId: 1, capitalCityId: null };
|
||||
snapshot.cities[0] = { ...snapshot.cities[0]!, nationId: 0 };
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot,
|
||||
state: { ...makeState(), meta: { ...makeState().meta, serverId: 'audit-execution', initYear: 200, initMonth: 1 } },
|
||||
schedule, map, collectLogs: true,
|
||||
commandRngFactory: () => new RandUtil(new ConstantRNG(0)),
|
||||
wrapGeneralTurnHandler: handler => ({ execute: context => {
|
||||
const result = handler.execute(context);
|
||||
decisions.push(...(result.auditDecisions ?? []));
|
||||
return result;
|
||||
} }),
|
||||
});
|
||||
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_해산', args: {} };
|
||||
await harness.runOneTick();
|
||||
const decision = decisions.find(row => row.phase === 'general');
|
||||
expect(decision?.summary).toMatchObject({ selectedAction: 'che_해산', executedAction: 'che_인재탐색', usedFallback: true });
|
||||
const attempts = decision?.steps.filter(step => step.kind === 'EXECUTION_ATTEMPT');
|
||||
expect(attempts).toMatchObject([
|
||||
{ attempt: 0, executedAction: 'che_해산', alternativeAction: 'che_인재탐색' },
|
||||
{ attempt: 1, requestedAction: 'che_인재탐색', executedAction: 'che_인재탐색', alternativeAction: null },
|
||||
]);
|
||||
expect(attempts?.[1]?.sequence).toBeGreaterThan(attempts?.[0]?.sequence ?? -1);
|
||||
});
|
||||
|
||||
it.each([0, 2])('persists pre-turn stacking and applies the inherited 60-turn cooldown (npcState=%s)', async (npcState) => {
|
||||
const decisions: PendingAuditDecision[] = [];
|
||||
const general = makeGeneral({
|
||||
npcState,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
@@ -406,10 +438,15 @@ describe('legacy general-turn execution contract', () => {
|
||||
});
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(general),
|
||||
state: makeState(),
|
||||
state: { ...makeState(), meta: { ...makeState().meta, serverId: 'audit-execution' } },
|
||||
schedule,
|
||||
map,
|
||||
collectLogs: true,
|
||||
wrapGeneralTurnHandler: handler => ({ execute: context => {
|
||||
const result = handler.execute(context);
|
||||
decisions.push(...(result.auditDecisions ?? []));
|
||||
return result;
|
||||
} }),
|
||||
});
|
||||
const turns = harness.reservedTurnStore.getGeneralTurns(1);
|
||||
turns[0] = { action: 'che_전투특기초기화', args: {} };
|
||||
@@ -427,10 +464,17 @@ describe('legacy general-turn execution contract', () => {
|
||||
expect(updated.role.specialWar).toBeNull();
|
||||
expect(updated.meta['next_execute_전투 특기 초기화']).toBe(2460);
|
||||
expect(updated.meta.prev_types_special2).toEqual(['che_격노']);
|
||||
if (npcState === 2) {
|
||||
expect(decisions[0]?.summary.executionStatus).toBe('PREPARING');
|
||||
expect(decisions[0]?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', executedAction: null, preparation: { term: 1, total: 2 }, completed: false });
|
||||
expect(decisions[1]?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', executedAction: 'che_전투특기초기화', preparation: null, completed: true });
|
||||
} else expect(decisions).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a speciality reset cooldown before accumulating its first preparation turn', async () => {
|
||||
it.each([0, 2])('rejects a speciality reset cooldown before accumulating its first preparation turn (npcState=%s)', async (npcState) => {
|
||||
const decisions: PendingAuditDecision[] = [];
|
||||
const general = makeGeneral({
|
||||
npcState,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
@@ -441,15 +485,22 @@ describe('legacy general-turn execution contract', () => {
|
||||
});
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(general),
|
||||
state: makeState(),
|
||||
state: { ...makeState(), meta: { ...makeState().meta, serverId: 'audit-execution' } },
|
||||
schedule,
|
||||
map,
|
||||
collectLogs: true,
|
||||
wrapGeneralTurnHandler: handler => ({ execute: context => {
|
||||
const result = handler.execute(context);
|
||||
decisions.push(...(result.auditDecisions ?? []));
|
||||
return result;
|
||||
} }),
|
||||
});
|
||||
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_전투특기초기화', args: {} };
|
||||
|
||||
await harness.runOneTick();
|
||||
|
||||
if (npcState === 2) expect(decisions[0]?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', executedAction: '휴식', usedFallback: true, preparation: null, checks: expect.arrayContaining([{ stage: 'COOLDOWN', action: 'che_전투특기초기화', result: 'deny', reason: '60턴 더 기다려야 합니다' }]) });
|
||||
else expect(decisions).toEqual([]);
|
||||
const updated = harness.world.getGeneralById(1)!;
|
||||
expect(updated.role.specialWar).toBe('che_격노');
|
||||
expect(updated.lastTurn).not.toEqual({ command: '전투 특기 초기화', term: 1 });
|
||||
|
||||
@@ -452,7 +452,7 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
|
||||
expect(new Set(savedDecisions.map((row) => row.id)).size).toBe(savedDecisions.length);
|
||||
expect(savedDecisions.some((row) => row.phase === 'nation' && row.summary.executedAction === 'che_선전포고')).toBe(true);
|
||||
expect(savedDecisions.some((row) => row.phase === 'general' && row.summary.executedAction === 'che_출병')).toBe(true);
|
||||
expect(savedDecisions.every((row) => row.steps[0]?.kind === 'DECISION_START' && row.steps.at(-1)?.kind === 'DECISION_END')).toBe(true);
|
||||
expect(savedDecisions.every((row) => row.steps[0]?.kind === 'DECISION_START' && row.steps.at(-1)?.kind === 'EXECUTION_ATTEMPT')).toBe(true);
|
||||
expect(decisionTrace.some((step) => step.kind === 'DECISION_START' && step.phase === 'nation')).toBe(true);
|
||||
expect(decisionTrace.some((step) => step.kind === 'DECISION_END' && step.phase === 'general')).toBe(true);
|
||||
expect(decisionTrace.some((step) => step.kind === 'RNG')).toBe(true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js';
|
||||
import { buildAuditDecisionFixture as draft } from './fixtures/playAuditDecision.js';
|
||||
import { buildAuditExecutionFixture, buildAuditDecisionFixture as draft } from './fixtures/playAuditDecision.js';
|
||||
import { prunePreviousAuditBatch } from '../src/playAudit/retention.js';
|
||||
|
||||
const databaseUrl = process.env.PLAY_AUDIT_DECISION_DATABASE_URL;
|
||||
@@ -37,6 +37,11 @@ integration('decision persistence and bounded retention', () => {
|
||||
it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => {
|
||||
const decision = draft('decision-one');
|
||||
decision.summary.codeVersion = 'a'.repeat(40);
|
||||
decision.summary.executionCoverage = 'ATTEMPTS';
|
||||
decision.steps.push({ ...decision.steps[0]!, ...buildAuditExecutionFixture(), sequence: 302 });
|
||||
await expect(
|
||||
db.$transaction((tx) => persistAuditDecisions(tx, [{ ...decision, steps: decision.steps.slice(0, -1) }]))
|
||||
).rejects.toThrow('Incomplete play audit decision');
|
||||
await db.$executeRawUnsafe(
|
||||
`CREATE OR REPLACE FUNCTION decision_fixture_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.ordinal=1 THEN RAISE EXCEPTION 'decision chunk failure'; END IF; RETURN NEW; END $$`
|
||||
);
|
||||
@@ -64,7 +69,7 @@ integration('decision persistence and bounded retention', () => {
|
||||
const header = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } });
|
||||
expect(header).toMatchObject({
|
||||
tick: 4_320_000_000n,
|
||||
stepCount: 302,
|
||||
stepCount: 303,
|
||||
summary: { codeVersion: 'a'.repeat(40) },
|
||||
});
|
||||
await expect(
|
||||
|
||||
@@ -629,7 +629,7 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: mockDate,
|
||||
meta: {},
|
||||
meta: { serverId: 'audit-execution' },
|
||||
};
|
||||
|
||||
const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_농지개간', arg: {} }];
|
||||
@@ -676,6 +676,11 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
});
|
||||
|
||||
const dirty = world.consumeDirtyState();
|
||||
if (npcState === 2) {
|
||||
const decision = dirty.pendingAuditDecisions.find(row => row.phase === 'general');
|
||||
expect(decision?.summary).toMatchObject({ requestedAction: 'che_농지개간', executedAction: '휴식', usedFallback: true, executionCoverage: 'ATTEMPTS' });
|
||||
expect(decision?.steps.at(-1)).toMatchObject({ kind: 'EXECUTION_ATTEMPT', attempt: 0, requestedAction: 'che_농지개간', executedAction: '휴식', usedFallback: true, checks: expect.arrayContaining([{ stage: 'CONSTRAINT', action: 'che_농지개간', result: 'deny', reason: '농지 개간이 충분합니다.' }]) });
|
||||
} else expect(dirty.pendingAuditDecisions).toEqual([]);
|
||||
expect(world.getCityById(1)!.agriculture).toBe(2000);
|
||||
const denyLog = dirty.logs.find((log) => log.text.includes('농지 개간이 충분합니다.'));
|
||||
expect(denyLog?.text).toContain('농지 개간 실패.');
|
||||
|
||||
Reference in New Issue
Block a user