feat: NPC와 국방 정책 변경을 감사 버전으로 저장

This commit is contained in:
2026-09-16 04:48:58 +00:00
parent 10f3f0ebc6
commit d549cbf887
19 changed files with 803 additions and 98 deletions
@@ -421,4 +421,49 @@ describe('nation setting mutation', () => {
});
expect(world.getNationById(1)?.meta).toEqual(before);
});
it('records a baseline and changed defence policy, preserving no-op and rollback semantics', () => {
const world = createWorld({ worldMeta: { serverId: 'policy-fixture' }, nationMeta: { scout: 0 } });
const saved = world.captureState();
const result = applyNationSettingMutation({
world,
acceptedAt,
command: command({ kind: 'blockScout', value: true }),
});
expect(result.ok).toBe(true);
const records = world.peekDirtyState().pendingAuditPolicies;
expect(records).toHaveLength(2);
expect(records[0]).toMatchObject({
area: 'DEFENCE',
source: 'BASELINE',
before: null,
after: { scout: 0 },
actor: null,
});
expect(records[1]).toMatchObject({
source: 'CHANGE',
revision: 2,
previousId: records[0]!.id,
requestId: 'nation-setting-test',
before: { scout: 0 },
after: { scout: 1 },
actor: { userId: 'owner-1', generalId: 1, name: '테스트군주', officerLevel: 12 },
});
const next = applyNationSettingMutation({
world,
acceptedAt,
command: command({ kind: 'blockScout', value: true }, { requestId: 'same-value' }),
});
expect(next.ok).toBe(true);
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(2);
applyNationSettingMutation({
world,
acceptedAt,
command: command({ kind: 'blockScout', value: false }, { userId: 'intruder' }),
});
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(2);
world.restoreState(saved);
expect(world.peekDirtyState().pendingAuditPolicies).toEqual([]);
expect(world.getNationById(1)?.meta.scout).toBe(0);
expect(world.getState().meta._playAuditOrdinal).toBeUndefined();
});
});
@@ -1,3 +1,4 @@
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
import { describe, expect, it } from 'vitest';
import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
@@ -177,7 +178,11 @@ const unitSet: UnitSetDefinition = {
describe('NPC policy lifecycle', () => {
it('applies CAS-protected semantic policy changes and the next AI instance consumes them without scheduler changes', () => {
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const world = new InMemoryTurnWorld(
{ ...state, meta: { ...state.meta, serverId: 'npc-policy-next-consumer' } },
snapshot,
{ schedule }
);
const first = applyNpcPolicyMutation({
world,
acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
@@ -467,4 +472,74 @@ describe('NPC policy lifecycle', () => {
})
).toMatchObject({ ok: false, code: 'FORBIDDEN' });
});
it('records configured NPC policy changes but ignores setter time and unchanged values', () => {
const world = new InMemoryTurnWorld(
{ ...state, meta: { ...state.meta, serverId: 'npc-policy-audit' } },
structuredClone(snapshot),
{ schedule }
);
const apply = (requestId: string, expectedUpdatedAt: string | null) =>
applyNpcPolicyMutation({
world,
acceptedAt: new Date('2026-02-03T04:05:06Z'),
command: {
type: 'setNpcPolicy',
requestId,
userId: 'owner-1',
generalId: 1,
nationId: 1,
expectedUpdatedAt,
mutation: { kind: 'nationPolicy', values: { reqNationGold: 4_321 } },
},
});
const first = apply('audit-first', '2026-01-01T00:00:00.000Z');
expect(first.ok).toBe(true);
if (!first.ok) throw new Error(first.reason);
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(2);
expect(world.peekDirtyState().pendingAuditPolicies[1]).toMatchObject({
area: 'NPC_VALUES',
source: 'CHANGE',
after: { reqNationGold: 4321 },
actor: { name: 'NPC군주' },
});
expect(apply('audit-noop', first.updatedAt).ok).toBe(true);
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(2);
expect(apply('audit-conflict', first.updatedAt).ok).toBe(false);
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(2);
});
it('captures four baselines once, preserves their heads on reload and scopes copied metadata to a new nation', () => {
const world = new InMemoryTurnWorld(
{ ...state, meta: { ...state.meta, serverId: 'policy-baselines' } },
structuredClone(snapshot),
{ schedule }
);
initializeAuditPolicies(world);
expect(world.peekDirtyState().pendingAuditPolicies).toHaveLength(4);
world.acknowledgeDirtyState(world.peekDirtyState());
const reloaded = new InMemoryTurnWorld(
structuredClone(world.getState()),
{ ...structuredClone(snapshot), nations: world.listNations() },
{ schedule }
);
initializeAuditPolicies(reloaded);
expect(reloaded.peekDirtyState().pendingAuditPolicies).toHaveLength(0);
reloaded.addNation({ ...reloaded.getNationById(1)!, id: 2 });
const added = reloaded.peekDirtyState().pendingAuditPolicies;
expect(added).toHaveLength(4);
expect(added.every((row) => row.nationId === 2 && row.revision === 1 && row.source === 'BASELINE')).toBe(true);
expect(reloaded.getNationById(2)?.meta._playAuditPolicy).not.toEqual(
reloaded.getNationById(1)?.meta._playAuditPolicy
);
const nation = reloaded.getNationById(1)!;
reloaded.updateNation(1, { meta: { ...nation.meta, scout: 1 } });
initializeAuditPolicies(reloaded);
expect(reloaded.peekDirtyState().pendingAuditPolicies.at(-1)).toMatchObject({
nationId: 1,
area: 'DEFENCE',
source: 'OBSERVED_GAP',
actor: null,
before: null,
after: { scout: 1 },
});
});
});
@@ -0,0 +1,112 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { persistAuditPolicies } from '../src/playAudit/policyPersistence.js';
import type { PendingAuditPolicy } from '../src/playAudit/policy.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const serverId = 'policy-persistence-fixture';
const requestId = 'policy-persistence-request';
integration('immutable policy persistence', () => {
let db: GamePrismaClient;
let close: () => Promise<void>;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
await db.playAuditPolicy.deleteMany({ where: { serverId } });
await db.inputEvent.deleteMany({ where: { requestId } });
});
afterAll(async () => {
await db.playAuditPolicy.deleteMany({ where: { serverId } });
await db.inputEvent.deleteMany({ where: { requestId } });
await db.nation.deleteMany({ where: { id: 999915 } });
await close();
});
it('rolls back policy and state together, binds durable sequence, and rejects conflicting replay', async () => {
const input = await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'setNationSetting',
actorUserId: 'audit-owner',
status: 'PROCESSING',
},
});
const context = { requestId, sequence: input.sequence, actorUserId: 'audit-owner' };
const baseline: PendingAuditPolicy = {
schemaVersion: 1,
id: `${serverId}:1`,
serverId,
nationId: 999915,
area: 'DEFENCE',
revision: 1,
previousId: null,
source: 'BASELINE',
year: 190,
month: 1,
tick: 1,
requestId: null,
ordinal: 1,
actor: null,
before: null,
after: { scout: 0 },
};
const change: PendingAuditPolicy = {
...baseline,
id: `${serverId}:2`,
revision: 2,
previousId: baseline.id,
source: 'CHANGE',
requestId,
ordinal: 2,
actor: {
userId: 'audit-owner',
generalId: 1,
name: '기록 군주',
nationId: 999915,
officerLevel: 12,
npcState: 0,
permission: 4,
},
before: { scout: 0 },
after: { scout: 1 },
};
await expect(
db.$transaction(async (tx) => {
await tx.nation.create({ data: { id: 999915, name: '정책국', color: '#ffffff', meta: { scout: 1 } } });
await persistAuditPolicies(tx, [baseline, change], context);
await tx.inputEvent.update({ where: { requestId }, data: { status: 'SUCCEEDED' } });
throw new Error('policy rollback');
})
).rejects.toThrow('policy rollback');
expect(await db.playAuditPolicy.count({ where: { serverId } })).toBe(0);
expect(await db.nation.findUnique({ where: { id: 999915 } })).toBeNull();
expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).status).toBe('PROCESSING');
await db.$transaction(async (tx) => {
await tx.nation.create({ data: { id: 999915, name: '정책국', color: '#ffffff', meta: { scout: 1 } } });
await persistAuditPolicies(tx, [baseline, change], context);
await tx.inputEvent.update({ where: { requestId }, data: { status: 'SUCCEEDED' } });
});
await db.$transaction((tx) => persistAuditPolicies(tx, [baseline, change], context));
const rows = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { revision: 'asc' } });
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ inputSequence: null, actor: null });
expect(rows[1]).toMatchObject({
inputSequence: input.sequence,
requestId,
before: { scout: 0 },
after: { scout: 1 },
});
await expect(
db.$transaction((tx) => persistAuditPolicies(tx, [{ ...change, after: { scout: 0 } }], context))
).rejects.toThrow('replay payload conflict');
await expect(
db.$transaction((tx) => persistAuditPolicies(tx, [change], { ...context, actorUserId: 'other' }))
).rejects.toThrow('actor/request mismatch');
await expect(db.$transaction((tx) => persistAuditPolicies(tx, [change]))).rejects.toThrow(
'input event context missing'
);
expect((await db.playAuditPolicy.findUniqueOrThrow({ where: { id: change.id } })).after).toEqual({ scout: 1 });
});
});
@@ -23,6 +23,7 @@ integration('bounded previous-season audit retention', () => {
it('keeps the active season, bounds each transaction and retries after rollback', async () => {
await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.worldState.deleteMany();
const world = await db.worldState.create({
data: {
@@ -121,6 +122,7 @@ integration('bounded previous-season audit retention', () => {
});
it('starts bounded cleanup only after seed commits, including a reserved opening', async () => {
await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.worldState.deleteMany();
await db.worldState.create({
data: {
@@ -184,4 +186,33 @@ integration('bounded previous-season audit retention', () => {
message: '이전 플레이 감사 자료의 나머지는 서버 시작 후 정리합니다.',
});
});
it('prunes old policy versions by key and preserves the active policy history', async () => {
await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.worldState.updateMany({ data: { meta: { serverId: 'policy-active' } } });
const row = (id: string, serverId: string, revision: number) => ({
id,
serverId,
nationId: 1,
area: 'DEFENCE',
revision,
source: 'BASELINE',
year: 190,
month: 1,
ordinal: revision,
after: {},
hash: id,
});
await db.playAuditPolicy.createMany({
data: [
...Array.from({ length: 201 }, (_, index) => row(`old-policy-${index}`, 'old-policy', index + 1)),
row('active-policy', 'policy-active', 1),
],
});
expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'progress', deleted: 200 });
expect(await db.playAuditPolicy.count({ where: { serverId: 'old-policy' } })).toBe(1);
expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'progress', deleted: 1 });
expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'complete', deleted: 0 });
expect(await db.playAuditPolicy.findUnique({ where: { id: 'active-policy' } })).not.toBeNull();
});
});
@@ -117,6 +117,7 @@ describe('durable read-model change journal mapping', () => {
pendingNationBettingFinishes: [],
pendingYearbookSnapshots: [],
pendingAuditMonths: [],
pendingAuditPolicies: [],
pendingUnificationFinalizations: [],
} satisfies TurnWorldChanges;
const readModelChanges = createEmptyRealtimeReadModelChanges();