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
@@ -166,6 +166,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
closeDb = () => connector.disconnect(); closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany(); await db.inputEvent.deleteMany();
await db.logEntry.deleteMany(); await db.logEntry.deleteMany();
await db.playAuditPolicy.deleteMany({ where: { serverId: profile } });
worldStateId = (await db.worldState.findFirstOrThrow()).id; worldStateId = (await db.worldState.findFirstOrThrow()).id;
await db.playAuditMonth.deleteMany({ await db.playAuditMonth.deleteMany({
where: { id: { in: ['select-pool-audit-old', 'select-pool-audit-active'] } }, where: { id: { in: ['select-pool-audit-old', 'select-pool-audit-active'] } },
@@ -528,6 +529,59 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
}); });
}, 30_000); }, 30_000);
it('commits a policy version with its actor, durable input sequence and nation pointer', async () => {
const actor = runtime!.world.listGenerals().find((general) => general.userId === userId)!;
const old = { nationId: actor.nationId, officerLevel: actor.officerLevel };
runtime!.world.addNation({
id: 99091,
name: '감사정책국',
color: '#ffffff',
capitalCityId: null,
chiefGeneralId: actor.id,
gold: 1000,
rice: 1000,
power: 0,
level: 1,
typeCode: 'che_중립',
meta: {},
});
runtime!.world.updateGeneral(actor.id, { nationId: 99091, officerLevel: 12 });
const requestId = 'select-pool-audit-policy';
try {
const result = await turnDaemon.requestCommand({
type: 'setNpcPolicy',
requestId,
userId,
generalId: actor.id,
nationId: 99091,
expectedUpdatedAt: null,
mutation: { kind: 'nationPolicy', values: { reqNationGold: 4321 } },
});
expect(result).toMatchObject({ type: 'setNpcPolicy', ok: true });
const input = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
expect(input.status).toBe('SUCCEEDED');
const rows = await db.playAuditPolicy.findMany({
where: { serverId: profile, nationId: 99091, area: 'NPC_VALUES' },
orderBy: { revision: 'asc' },
});
expect(rows).toHaveLength(2);
expect(rows[1]).toMatchObject({
source: 'CHANGE',
requestId,
inputSequence: input.sequence,
actor: { userId, generalId: actor.id, officerLevel: 12 },
after: { reqNationGold: 4321 },
});
expect(await db.nation.findUniqueOrThrow({ where: { id: 99091 } })).toMatchObject({
meta: {
_playAuditPolicy: { NPC_VALUES: { id: rows[1]!.id, revision: 2 } },
},
});
} finally {
runtime!.world.updateGeneral(actor.id, old);
}
});
it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => { it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => {
const logicalNow = runtime!.world.getGameNow(new Date()); const logicalNow = runtime!.world.getGameNow(new Date());
const logicalNowMs = logicalNow.getTime(); const logicalNowMs = logicalNow.getTime();
+155
View File
@@ -0,0 +1,155 @@
import { createHash } from 'node:crypto';
import { asRecord } from '@sammo-ts/common';
import type { Nation } from '@sammo-ts/logic';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
import type { TurnGeneral } from '../turn/types.js';
import { DEFAULT_NATION_POLICY } from '../turn/npcPolicyDefaults.js';
export const AUDIT_POLICY_AREAS = ['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY', 'DEFENCE'] as const;
export type AuditPolicyArea = (typeof AUDIT_POLICY_AREAS)[number];
type PolicyData = Record<string, unknown>;
type PolicyHead = { id: string; revision: number; hash: string; serverId: string };
export interface PendingAuditPolicy {
schemaVersion: 1;
id: string;
serverId: string;
nationId: number;
area: AuditPolicyArea;
revision: number;
previousId: string | null;
source: 'BASELINE' | 'CHANGE' | 'OBSERVED_GAP';
year: number;
month: number;
tick: number;
requestId: string | null;
ordinal: number;
actor: {
userId: string | null;
generalId: number;
name: string;
nationId: number;
officerLevel: number;
npcState: number;
permission: number;
} | null;
before: PolicyData | null;
after: PolicyData;
}
const canonical = (value: unknown): string =>
JSON.stringify(value, (_key, item: unknown) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
return Object.fromEntries(Object.entries(item).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
}
return item;
});
export const auditPolicyHash = (value: unknown): string => createHash('sha256').update(canonical(value)).digest('hex');
const pick = (source: Record<string, unknown>, keys: readonly string[]): PolicyData =>
JSON.parse(JSON.stringify(Object.fromEntries(keys.map((key) => [key, source[key] ?? null])))) as PolicyData;
/** 설정 누락(null)은 상속을 뜻한다. AI의 개인별 보정이나 난수를 재계산하지 않는다. */
export const projectAuditPolicy = (meta: Record<string, unknown>, area: AuditPolicyArea): PolicyData => {
const nation = asRecord(meta.npc_nation_policy);
switch (area) {
case 'NPC_VALUES':
return pick(asRecord(nation.values), Object.keys(DEFAULT_NATION_POLICY));
case 'NPC_NATION_PRIORITY':
return pick(nation, ['priority']);
case 'NPC_GENERAL_PRIORITY':
return pick(asRecord(meta.npc_general_policy), ['priority']);
case 'DEFENCE':
return pick(meta, ['war', 'scout', 'secretlimit']);
}
};
export const recordAuditPolicyChange = (options: {
world: InMemoryTurnWorld;
nation: Nation;
area: AuditPolicyArea;
nextMeta: Record<string, unknown>;
actor?: TurnGeneral;
permission?: number;
requestId?: string;
}): { _playAuditPolicy?: Record<string, PolicyHead> } => {
const { world, nation, area } = options;
const state = world.getState();
const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return {};
const rawHeads = asRecord(nation.meta._playAuditPolicy);
const heads: Record<string, PolicyHead> = {};
for (const key of AUDIT_POLICY_AREAS) {
const value = asRecord(rawHeads[key]);
if (
value.serverId === serverId &&
typeof value.id === 'string' &&
typeof value.revision === 'number' &&
Number.isSafeInteger(value.revision) &&
value.revision > 0 &&
typeof value.hash === 'string' &&
value.id === auditPolicyHash([serverId, nation.id, key, value.revision])
) {
heads[key] = { id: value.id, revision: value.revision, hash: value.hash, serverId };
}
}
let head: PolicyHead | null = heads[area] ?? null;
const before = projectAuditPolicy(nation.meta, area);
const after = projectAuditPolicy(options.nextMeta, area);
const append = (source: PendingAuditPolicy['source'], old: PolicyData | null, value: PolicyData) => {
const revision = (head?.revision ?? 0) + 1;
const id = auditPolicyHash([serverId, nation.id, area, revision]);
const actor = options.actor;
world.queueAuditPolicy({
schemaVersion: 1,
id,
serverId,
nationId: nation.id,
area,
revision,
previousId: head?.id ?? null,
source,
year: state.currentYear,
month: state.currentMonth,
tick: world.getGameClockState().tick,
requestId: source === 'CHANGE' ? (options.requestId ?? null) : null,
ordinal: world.nextAuditOrdinal(),
actor:
source === 'CHANGE' && actor
? {
userId: actor.userId ?? null,
generalId: actor.id,
name: actor.name,
nationId: actor.nationId,
officerLevel: actor.officerLevel,
npcState: actor.npcState,
permission: options.permission ?? 0,
}
: null,
before: old,
after: value,
});
head = { id, revision, hash: auditPolicyHash(value), serverId };
};
if (!head) append('BASELINE', null, before);
else if (head.hash !== auditPolicyHash(before)) append('OBSERVED_GAP', null, before);
if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after);
if (!head) throw new Error('Play audit policy baseline missing');
return { _playAuditPolicy: { ...heads, [area]: head } };
};
export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void => {
let nation = world.getNationById(nationId);
if (!nation) return;
for (const area of AUDIT_POLICY_AREAS) {
const patch = recordAuditPolicyChange({ world, nation, area, nextMeta: nation.meta });
if (
Object.keys(patch).length &&
auditPolicyHash(patch._playAuditPolicy) !== auditPolicyHash(nation.meta._playAuditPolicy ?? {})
) {
nation = world.updateNation(nation.id, { meta: { ...nation.meta, ...patch } })!;
}
}
};
export const initializeAuditPolicies = (world: InMemoryTurnWorld): void => {
for (const nation of world.listNations()) initializeNationAuditPolicies(world, nation.id);
};
@@ -0,0 +1,42 @@
import { GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
import { auditPolicyHash, type PendingAuditPolicy } from './policy.js';
export const persistAuditPolicies = async (
tx: GamePrisma.TransactionClient,
policies: readonly PendingAuditPolicy[],
command?: { requestId: string; sequence: bigint; actorUserId: string | null }
): Promise<void> => {
for (let offset = 0; offset < policies.length; offset += 200) {
const batch = policies.slice(offset, offset + 200).map((policy) => {
if (policy.requestId && !command) throw new Error('Play audit policy input event context missing');
if (
policy.requestId &&
command &&
(policy.requestId !== command.requestId || policy.actor?.userId !== command.actorUserId)
) {
throw new Error('Play audit policy actor/request mismatch');
}
return {
...policy,
inputSequence: policy.requestId && command ? command.sequence : null,
actor: policy.actor ? (JSON.parse(JSON.stringify(policy.actor)) as InputJsonValue) : GamePrisma.DbNull,
before: policy.before
? (JSON.parse(JSON.stringify(policy.before)) as InputJsonValue)
: GamePrisma.DbNull,
after: JSON.parse(JSON.stringify(policy.after)) as InputJsonValue,
hash: auditPolicyHash({
...policy,
inputSequence: policy.requestId && command ? command.sequence.toString() : null,
}),
};
});
await tx.playAuditPolicy.createMany({ data: batch, skipDuplicates: true });
const saved = await tx.playAuditPolicy.findMany({
where: { id: { in: batch.map((row) => row.id) } },
select: { id: true, hash: true },
});
const hashes = new Map(saved.map((row) => [row.id, row.hash]));
if (batch.some((row) => hashes.get(row.id) !== row.hash))
throw new Error('Play audit policy replay payload conflict');
}
};
@@ -20,6 +20,18 @@ export const prunePreviousAuditBatch = async (
if (!lock?.locked) return { status: 'busy', deleted: 0 }; if (!lock?.locked) return { status: 'busy', deleted: 0 };
const world = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } }); const world = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } });
if (asRecord(world?.meta).serverId !== expectedServerId) return { status: 'identityChanged', deleted: 0 }; if (asRecord(world?.meta).serverId !== expectedServerId) return { status: 'identityChanged', deleted: 0 };
const policies = await tx.playAuditPolicy.findMany({
where: { serverId: { not: expectedServerId } },
orderBy: { id: 'asc' },
take: AUDIT_RETENTION_BATCH_SIZE,
select: { id: true },
});
if (policies.length) {
const deleted = await tx.playAuditPolicy.deleteMany({
where: { id: { in: policies.map((row) => row.id) } },
});
return { status: 'progress', deleted: deleted.count };
}
// 부모를 잠가 늦은 child INSERT와 빈 header 삭제의 경쟁도 차단한다. // 부모를 잠가 늦은 child INSERT와 빈 header 삭제의 경쟁도 차단한다.
const [sample] = await tx.$queryRaw<{ id: string }[]>` const [sample] = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM play_audit_month WHERE server_id <> ${expectedServerId} SELECT id FROM play_audit_month WHERE server_id <> ${expectedServerId}
+13 -1
View File
@@ -1,3 +1,4 @@
import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js'; import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
import { persistAuditMonth } from '../playAudit/persistence.js'; import { persistAuditMonth } from '../playAudit/persistence.js';
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js'; import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
@@ -1145,6 +1146,7 @@ export const createDatabaseTurnHooks = async (
pendingNationBettingFinishes, pendingNationBettingFinishes,
pendingYearbookSnapshots, pendingYearbookSnapshots,
pendingAuditMonths, pendingAuditMonths,
pendingAuditPolicies,
pendingUnificationFinalizations, pendingUnificationFinalizations,
} = changes; } = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -1303,15 +1305,18 @@ export const createDatabaseTurnHooks = async (
cutWallAt: unificationCutWallAt!, cutWallAt: unificationCutWallAt!,
}) })
: null; : null;
let auditCommand: { requestId: string; sequence: bigint; actorUserId: string | null } | undefined;
if (commandCompletion) { if (commandCompletion) {
const commandFence = await prisma.$queryRaw< const commandFence = await prisma.$queryRaw<
Array<{ Array<{
status: string; status: string;
sequence: bigint;
actor_user_id: string | null;
processing_clock_revision: bigint | null; processing_clock_revision: bigint | null;
processing_deadline_generation: bigint | null; processing_deadline_generation: bigint | null;
}> }>
>(GamePrisma.sql` >(GamePrisma.sql`
SELECT status, SELECT status, sequence, actor_user_id,
processing_clock_revision, processing_clock_revision,
processing_deadline_generation processing_deadline_generation
FROM input_event FROM input_event
@@ -1320,6 +1325,12 @@ export const createDatabaseTurnHooks = async (
FOR UPDATE FOR UPDATE
`); `);
const event = commandFence[0]; const event = commandFence[0];
if (event)
auditCommand = {
requestId: commandCompletion.requestId,
sequence: event.sequence,
actorUserId: event.actor_user_id,
};
const unificationRevisionTransition = const unificationRevisionTransition =
commandCompletion.result.type === 'messageRespond' && commandCompletion.result.type === 'messageRespond' &&
commandCompletion.result.ok && commandCompletion.result.ok &&
@@ -1876,6 +1887,7 @@ export const createDatabaseTurnHooks = async (
data: pendingLogRows, data: pendingLogRows,
}); });
} }
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand);
for (const snapshot of pendingAuditMonths) { for (const snapshot of pendingAuditMonths) {
await persistAuditMonth(prisma, snapshot); await persistAuditMonth(prisma, snapshot);
} }
+23
View File
@@ -1,3 +1,4 @@
import { initializeNationAuditPolicies, type PendingAuditPolicy } from '../playAudit/policy.js';
import type { PendingAuditMonth } from '../playAudit/persistence.js'; import type { PendingAuditMonth } from '../playAudit/persistence.js';
import type { import type {
City, City,
@@ -201,6 +202,7 @@ export interface TurnWorldChanges {
pendingNationBettingFinishes: PendingNationBettingFinish[]; pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[]; pendingYearbookSnapshots: PendingYearbookSnapshot[];
pendingAuditMonths: PendingAuditMonth[]; pendingAuditMonths: PendingAuditMonth[];
pendingAuditPolicies: PendingAuditPolicy[];
pendingUnificationFinalizations: PendingUnificationFinalization[]; pendingUnificationFinalizations: PendingUnificationFinalization[];
} }
@@ -242,6 +244,7 @@ export interface InMemoryTurnWorldStateSnapshot {
pendingNationBettingFinishes: PendingNationBettingFinish[]; pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[]; pendingYearbookSnapshots: PendingYearbookSnapshot[];
pendingAuditMonths: PendingAuditMonth[]; pendingAuditMonths: PendingAuditMonth[];
pendingAuditPolicies: PendingAuditPolicy[];
pendingUnificationFinalizations: PendingUnificationFinalization[]; pendingUnificationFinalizations: PendingUnificationFinalization[];
pendingRealtimeBacklogShiftTicks: number; pendingRealtimeBacklogShiftTicks: number;
} }
@@ -547,6 +550,7 @@ export class InMemoryTurnWorld {
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = []; private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = []; private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
private readonly pendingAuditMonths: PendingAuditMonth[] = []; private readonly pendingAuditMonths: PendingAuditMonth[] = [];
private readonly pendingAuditPolicies: PendingAuditPolicy[] = [];
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = []; private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
private pendingRealtimeBacklogShiftTicks = 0; private pendingRealtimeBacklogShiftTicks = 0;
private readonly scenarioConfig: ScenarioConfig; private readonly scenarioConfig: ScenarioConfig;
@@ -1096,6 +1100,7 @@ export class InMemoryTurnWorld {
pendingNationBettingFinishes: this.pendingNationBettingFinishes, pendingNationBettingFinishes: this.pendingNationBettingFinishes,
pendingYearbookSnapshots: this.pendingYearbookSnapshots, pendingYearbookSnapshots: this.pendingYearbookSnapshots,
pendingAuditMonths: this.pendingAuditMonths, pendingAuditMonths: this.pendingAuditMonths,
pendingAuditPolicies: this.pendingAuditPolicies,
pendingUnificationFinalizations: this.pendingUnificationFinalizations, pendingUnificationFinalizations: this.pendingUnificationFinalizations,
pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks, pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks,
} satisfies InMemoryTurnWorldStateSnapshot); } satisfies InMemoryTurnWorldStateSnapshot);
@@ -1143,6 +1148,7 @@ export class InMemoryTurnWorld {
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes); this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots); this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
this.replaceArray(this.pendingAuditMonths, restored.pendingAuditMonths); this.replaceArray(this.pendingAuditMonths, restored.pendingAuditMonths);
this.replaceArray(this.pendingAuditPolicies, restored.pendingAuditPolicies);
this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations); this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations);
this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0; this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0;
} }
@@ -1358,6 +1364,19 @@ export class InMemoryTurnWorld {
}); });
} }
nextAuditOrdinal(): number {
const previous = this.state.meta._playAuditOrdinal;
const ordinal =
typeof previous === 'number' && Number.isSafeInteger(previous) && previous >= 0 ? previous + 1 : 1;
if (ordinal > 2_147_483_647) throw new Error('Play audit ordinal exhausted');
this.updateWorldMeta({ _playAuditOrdinal: ordinal });
return ordinal;
}
queueAuditPolicy(policy: PendingAuditPolicy): void {
this.pendingAuditPolicies.push(structuredClone(policy));
}
queueAuditMonth(snapshot: PendingAuditMonth): void { queueAuditMonth(snapshot: PendingAuditMonth): void {
this.pendingAuditMonths.push(structuredClone(snapshot)); this.pendingAuditMonths.push(structuredClone(snapshot));
} }
@@ -1595,6 +1614,7 @@ export class InMemoryTurnWorld {
this.dirtyNationIds.add(nation.id); this.dirtyNationIds.add(nation.id);
this.createdNationIds.add(nation.id); this.createdNationIds.add(nation.id);
this.ensureDiplomacyMatrix(); this.ensureDiplomacyMatrix();
initializeNationAuditPolicies(this, nation.id);
return true; return true;
} }
@@ -2205,6 +2225,7 @@ export class InMemoryTurnWorld {
})); }));
const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots); const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots);
const pendingAuditMonths = structuredClone(this.pendingAuditMonths); const pendingAuditMonths = structuredClone(this.pendingAuditMonths);
const pendingAuditPolicies = structuredClone(this.pendingAuditPolicies);
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations); const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort( const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort(
(left, right) => left - right (left, right) => left - right
@@ -2238,6 +2259,7 @@ export class InMemoryTurnWorld {
pendingNationBettingFinishes, pendingNationBettingFinishes,
pendingYearbookSnapshots, pendingYearbookSnapshots,
pendingAuditMonths, pendingAuditMonths,
pendingAuditPolicies,
pendingUnificationFinalizations, pendingUnificationFinalizations,
}; };
} }
@@ -2277,6 +2299,7 @@ export class InMemoryTurnWorld {
this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length); this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length);
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length); this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length);
this.pendingAuditMonths.splice(0, changes.pendingAuditMonths.length); this.pendingAuditMonths.splice(0, changes.pendingAuditMonths.length);
this.pendingAuditPolicies.splice(0, changes.pendingAuditPolicies.length);
this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length); this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length);
} }
@@ -1,3 +1,4 @@
import { recordAuditPolicyChange } from '../playAudit/policy.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { asRecord, formatServerDateTime, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common'; import { asRecord, formatServerDateTime, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
@@ -112,7 +113,11 @@ export const applyNationSettingMutation = (options: {
break; break;
} }
case 'rate': case 'rate':
if (!Number.isInteger(command.mutation.amount) || command.mutation.amount < 5 || command.mutation.amount > 30) { if (
!Number.isInteger(command.mutation.amount) ||
command.mutation.amount < 5 ||
command.mutation.amount > 30
) {
return reject('BAD_REQUEST', '올바른 세율을 입력해주세요.', command.nationId); return reject('BAD_REQUEST', '올바른 세율을 입력해주세요.', command.nationId);
} }
updates = { rate: command.mutation.amount }; updates = { rate: command.mutation.amount };
@@ -128,7 +133,11 @@ export const applyNationSettingMutation = (options: {
updates = { bill: command.mutation.amount }; updates = { bill: command.mutation.amount };
break; break;
case 'secretLimit': case 'secretLimit':
if (!Number.isInteger(command.mutation.amount) || command.mutation.amount < 1 || command.mutation.amount > 99) { if (
!Number.isInteger(command.mutation.amount) ||
command.mutation.amount < 1 ||
command.mutation.amount > 99
) {
return reject('BAD_REQUEST', '올바른 기밀 공개 기준을 입력해주세요.', command.nationId); return reject('BAD_REQUEST', '올바른 기밀 공개 기준을 입력해주세요.', command.nationId);
} }
updates = { secretlimit: command.mutation.amount }; updates = { secretlimit: command.mutation.amount };
@@ -154,10 +163,22 @@ export const applyNationSettingMutation = (options: {
} }
const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`); const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`);
const audit = ['blockWar', 'blockScout', 'secretLimit'].includes(command.mutation.kind)
? recordAuditPolicyChange({
world,
nation,
area: 'DEFENCE',
nextMeta: { ...nation.meta, ...updates },
actor,
permission,
requestId: command.requestId,
})
: {};
world.updateNation(command.nationId, { world.updateNation(command.nationId, {
meta: { meta: {
...nation.meta, ...nation.meta,
...updates, ...updates,
...audit,
_updatedAt: updatedAt, _updatedAt: updatedAt,
}, },
}); });
@@ -0,0 +1,91 @@
export type NationPolicy = {
reqNationGold: number;
reqNationRice: number;
CombatForce: Record<number, [number, number]>;
SupportForce: number[];
DevelopForce: number[];
reqHumanWarUrgentGold: number;
reqHumanWarUrgentRice: number;
reqHumanWarRecommandGold: number;
reqHumanWarRecommandRice: number;
reqHumanDevelGold: number;
reqHumanDevelRice: number;
reqNPCWarGold: number;
reqNPCWarRice: number;
reqNPCDevelGold: number;
reqNPCDevelRice: number;
minimumResourceActionAmount: number;
maximumResourceActionAmount: number;
minNPCWarLeadership: number;
minWarCrew: number;
minNPCRecruitCityPopulation: number;
safeRecruitCityPopulationRatio: number;
properWarTrainAtmos: number;
cureThreshold: number;
};
export const DEFAULT_NATION_PRIORITY = [
'불가침제의',
'선전포고',
'천도',
'유저장긴급포상',
'부대전방발령',
'유저장구출발령',
'유저장후방발령',
'부대유저장후방발령',
'유저장전방발령',
'유저장포상',
'부대구출발령',
'부대후방발령',
'NPC긴급포상',
'NPC구출발령',
'NPC후방발령',
'NPC포상',
'NPC전방발령',
'유저장내정발령',
'NPC내정발령',
'NPC몰수',
] as const;
export const DEFAULT_GENERAL_PRIORITY = [
'NPC사망대비',
'귀환',
'금쌀구매',
'출병',
'긴급내정',
'전투준비',
'전방워프',
'NPC헌납',
'징병',
'후방워프',
'전쟁내정',
'소집해제',
'일반내정',
'내정워프',
] as const;
export const DEFAULT_NATION_POLICY: NationPolicy = {
reqNationGold: 10000,
reqNationRice: 12000,
CombatForce: {},
SupportForce: [],
DevelopForce: [],
reqHumanWarUrgentGold: 0,
reqHumanWarUrgentRice: 0,
reqHumanWarRecommandGold: 0,
reqHumanWarRecommandRice: 0,
reqHumanDevelGold: 10000,
reqHumanDevelRice: 10000,
reqNPCWarGold: 0,
reqNPCWarRice: 0,
reqNPCDevelGold: 0,
reqNPCDevelRice: 500,
minimumResourceActionAmount: 1000,
maximumResourceActionAmount: 10000,
minNPCWarLeadership: 40,
minWarCrew: 1500,
minNPCRecruitCityPopulation: 50000,
safeRecruitCityPopulationRatio: 0.5,
properWarTrainAtmos: 90,
cureThreshold: 10,
};
+30 -92
View File
@@ -1,3 +1,4 @@
import { recordAuditPolicyChange } from '../playAudit/policy.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { import {
@@ -11,97 +12,18 @@ import { resolveTroopSecretPermission } from '@sammo-ts/logic';
import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js';
export type NationPolicy = { import {
reqNationGold: number; DEFAULT_NATION_POLICY,
reqNationRice: number; DEFAULT_NATION_PRIORITY,
CombatForce: Record<number, [number, number]>; DEFAULT_GENERAL_PRIORITY,
SupportForce: number[]; type NationPolicy,
DevelopForce: number[]; } from './npcPolicyDefaults.js';
reqHumanWarUrgentGold: number; export {
reqHumanWarUrgentRice: number; DEFAULT_NATION_POLICY,
reqHumanWarRecommandGold: number; DEFAULT_NATION_PRIORITY,
reqHumanWarRecommandRice: number; DEFAULT_GENERAL_PRIORITY,
reqHumanDevelGold: number; type NationPolicy,
reqHumanDevelRice: number; } from './npcPolicyDefaults.js';
reqNPCWarGold: number;
reqNPCWarRice: number;
reqNPCDevelGold: number;
reqNPCDevelRice: number;
minimumResourceActionAmount: number;
maximumResourceActionAmount: number;
minNPCWarLeadership: number;
minWarCrew: number;
minNPCRecruitCityPopulation: number;
safeRecruitCityPopulationRatio: number;
properWarTrainAtmos: number;
cureThreshold: number;
};
export const DEFAULT_NATION_PRIORITY = [
'불가침제의',
'선전포고',
'천도',
'유저장긴급포상',
'부대전방발령',
'유저장구출발령',
'유저장후방발령',
'부대유저장후방발령',
'유저장전방발령',
'유저장포상',
'부대구출발령',
'부대후방발령',
'NPC긴급포상',
'NPC구출발령',
'NPC후방발령',
'NPC포상',
'NPC전방발령',
'유저장내정발령',
'NPC내정발령',
'NPC몰수',
] as const;
export const DEFAULT_GENERAL_PRIORITY = [
'NPC사망대비',
'귀환',
'금쌀구매',
'출병',
'긴급내정',
'전투준비',
'전방워프',
'NPC헌납',
'징병',
'후방워프',
'전쟁내정',
'소집해제',
'일반내정',
'내정워프',
] as const;
export const DEFAULT_NATION_POLICY: NationPolicy = {
reqNationGold: 10000,
reqNationRice: 12000,
CombatForce: {},
SupportForce: [],
DevelopForce: [],
reqHumanWarUrgentGold: 0,
reqHumanWarUrgentRice: 0,
reqHumanWarRecommandGold: 0,
reqHumanWarRecommandRice: 0,
reqHumanDevelGold: 10000,
reqHumanDevelRice: 10000,
reqNPCWarGold: 0,
reqNPCWarRice: 0,
reqNPCDevelGold: 0,
reqNPCDevelRice: 500,
minimumResourceActionAmount: 1000,
maximumResourceActionAmount: 10000,
minNPCWarLeadership: 40,
minWarCrew: 1500,
minNPCRecruitCityPopulation: 50000,
safeRecruitCityPopulationRatio: 0.5,
properWarTrainAtmos: 90,
cureThreshold: 10,
};
const NATION_POLICY_KEYS = new Set<keyof NationPolicy>(Object.keys(DEFAULT_NATION_POLICY) as Array<keyof NationPolicy>); const NATION_POLICY_KEYS = new Set<keyof NationPolicy>(Object.keys(DEFAULT_NATION_POLICY) as Array<keyof NationPolicy>);
@@ -306,7 +228,8 @@ export const applyNpcPolicyMutation = (options: {
if (!nation) { if (!nation) {
return reject('NOT_FOUND', '국가 정보를 찾을 수 없습니다.', { nationId: command.nationId }); return reject('NOT_FOUND', '국가 정보를 찾을 수 없습니다.', { nationId: command.nationId });
} }
if (resolveTroopSecretPermission(actor, nation.meta, true) < 3) { const permission = resolveTroopSecretPermission(actor, nation.meta, true);
if (permission < 3) {
return reject('FORBIDDEN', '권한이 부족합니다. 군주, 외교권자, 조언자가 아닙니다.', { return reject('FORBIDDEN', '권한이 부족합니다. 군주, 외교권자, 조언자가 아닙니다.', {
nationId: command.nationId, nationId: command.nationId,
}); });
@@ -385,10 +308,25 @@ export const applyNpcPolicyMutation = (options: {
// accepted commands can share the same time. Include the durable request // accepted commands can share the same time. Include the durable request
// identity to keep the strict CAS token unique. // identity to keep the strict CAS token unique.
const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`); const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`);
const audit = recordAuditPolicyChange({
world,
nation,
area:
command.mutation.kind === 'nationPolicy'
? 'NPC_VALUES'
: command.mutation.kind === 'nationPriority'
? 'NPC_NATION_PRIORITY'
: 'NPC_GENERAL_PRIORITY',
nextMeta: { ...nation.meta, ...updates },
actor,
permission,
requestId: command.requestId,
});
world.updateNation(command.nationId, { world.updateNation(command.nationId, {
meta: { meta: {
...nation.meta, ...nation.meta,
...updates, ...updates,
...audit,
// Keep the policy CAS independent from notice/tax/scout settings. // Keep the policy CAS independent from notice/tax/scout settings.
// The legacy shared _updatedAt remains a one-time migration fallback. // The legacy shared _updatedAt remains a one-time migration fallback.
_npcPolicyUpdatedAt: updatedAt, _npcPolicyUpdatedAt: updatedAt,
+2
View File
@@ -1,3 +1,4 @@
import { initializeAuditPolicies } from '../playAudit/policy.js';
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js'; import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
import { createPlayAuditHandler } from '../playAudit/collection.js'; import { createPlayAuditHandler } from '../playAudit/collection.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -803,6 +804,7 @@ const createTurnDaemonRuntimeWithLease = async (
}; };
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions); const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
worldRef = world; worldRef = world;
initializeAuditPolicies(world);
const stateManager = new EngineStateManager(); const stateManager = new EngineStateManager();
stateManager.register('world', { stateManager.register('world', {
@@ -421,4 +421,49 @@ describe('nation setting mutation', () => {
}); });
expect(world.getNationById(1)?.meta).toEqual(before); 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 { describe, expect, it } from 'vitest';
import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
@@ -177,7 +178,11 @@ const unitSet: UnitSetDefinition = {
describe('NPC policy lifecycle', () => { describe('NPC policy lifecycle', () => {
it('applies CAS-protected semantic policy changes and the next AI instance consumes them without scheduler changes', () => { 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({ const first = applyNpcPolicyMutation({
world, world,
acceptedAt: new Date('2026-02-03T04:05:06.000Z'), acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
@@ -467,4 +472,74 @@ describe('NPC policy lifecycle', () => {
}) })
).toMatchObject({ ok: false, code: 'FORBIDDEN' }); ).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 () => { it('keeps the active season, bounds each transaction and retries after rollback', async () => {
await db.playAuditMonth.deleteMany(); await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.worldState.deleteMany(); await db.worldState.deleteMany();
const world = await db.worldState.create({ const world = await db.worldState.create({
data: { data: {
@@ -121,6 +122,7 @@ integration('bounded previous-season audit retention', () => {
}); });
it('starts bounded cleanup only after seed commits, including a reserved opening', async () => { it('starts bounded cleanup only after seed commits, including a reserved opening', async () => {
await db.playAuditMonth.deleteMany(); await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.worldState.deleteMany(); await db.worldState.deleteMany();
await db.worldState.create({ await db.worldState.create({
data: { data: {
@@ -184,4 +186,33 @@ integration('bounded previous-season audit retention', () => {
message: '이전 플레이 감사 자료의 나머지는 서버 시작 후 정리합니다.', 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: [], pendingNationBettingFinishes: [],
pendingYearbookSnapshots: [], pendingYearbookSnapshots: [],
pendingAuditMonths: [], pendingAuditMonths: [],
pendingAuditPolicies: [],
pendingUnificationFinalizations: [], pendingUnificationFinalizations: [],
} satisfies TurnWorldChanges; } satisfies TurnWorldChanges;
const readModelChanges = createEmptyRealtimeReadModelChanges(); const readModelChanges = createEmptyRealtimeReadModelChanges();
+43 -2
View File
@@ -2,7 +2,7 @@
[확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며, [확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며,
월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API, 월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API,
국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 외교·정책·NPC trace·조사 도구는 미구현이다. 국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 정책 이력 저장 기반을 추가했으며 정책 조회 화면, 외교, NPC trace조사 도구는 남아 있다.
Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다. Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다.
## 현재 구현 ## 현재 구현
@@ -88,6 +88,47 @@ R1을 완료했다고 판단하지 않는다.
world metadata로 계산하며 추가 DB 조회·쓰기나 시나리오/AI 규칙 변경은 없다. world metadata로 계산하며 추가 DB 조회·쓰기나 시나리오/AI 규칙 변경은 없다.
PREOPEN은 wall-clock 대기 상태이며, 검증하는 것은 공식 개방 때의 논리 게임 달력이다. PREOPEN은 wall-clock 대기 상태이며, 검증하는 것은 공식 개방 때의 논리 게임 달력이다.
## NPC·국방 정책 버전 저장 기반
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
영역은 국가 NPC 값, 국가 NPC 우선순위, 장수 NPC 우선순위, 국방(`war/scout/secretlimit`)이다.
공지·권유문·세율·지급률 등 나머지 국가 설정의 사건 기록은 자원/행위 원장 연결에서 남아 있다.
NPC 설정은 기존 allowlist의 저장 값만 복사하며 setter/time이나 임의 nation metadata를
복사하지 않는다. 누락(null)은 설정 상속이다. 개인별·server별 보정이 적용된 최종 AI 값인
것처럼 표시하지 않으며, 해당 관측값과 코드 버전은 이후 NPC trace가 담당한다.
daemon world 구성과 신규 `addNation`에서 네 기준 버전을 pending에 담는다. 기존 정상
포인터가 있으면 재시작 때 재생성하지 않는다. 포인터 ID는 기수·국가·영역·revision으로
검증하므로 다른 국가 metadata를 복사해도 기존 국가 이력을 이어받지 않는다.
기준 버전은 현재 첫 gameplay flush에서 내구화된다. PREOPEN에서 아직 flush가 없을 때의
초기 기준 버전 내구화/coverage는 후속 연결이 필요하며 현재 구현만으로 R6 완료가 아니다.
기존 NPC mutation의 검증·CAS와 국가 설정의 권한·횟수 제한을 통과한 뒤 변경 전후를 비교한다.
setter/time 변경과 동일 설정 저장에는 새 적용 버전을 만들지 않는다. 기존 CAS token 갱신,
write와 성공 응답 계약은 유지한다. 거부된 입력은 정책 적용 이력에 넣지 않으며 기존
input_event의 ok:false와 구분한다. OBSERVED_GAP은 저장된 포인터와 현재 설정 불일치를
발견했을 때의 기준 재관측이다. 실제 변경 전 값/actor/시각을 추정하지 않는다.
pending 정책·최신 포인터·전역 감사 ordinal은 world capture/restore와 acknowledgement에
포함한다. 정책 row, nation meta, world meta와 input_event 완료는 같은 transaction이다.
기존 input_event fence SELECT에 sequence/actor_user_id만 추가하여 추가 요청 조회를 피한다.
수행자 ID가 명령과 일치하지 않거나 요청이 있는데 input context가 없으면 저장을 거부한다.
actor는 당시 user/general/name/nation/officer/npc/permission만 보존하며 createdAt은 DB wall time이다.
향후 수뇌 DTO는 nation 소유권과 기존 resolver를 적용하고 userId/inputSequence/requestId 및
관리자 진단을 제외해야 한다. 수뇌 직책/가입 전 열람 정책·화면은 확정 설계대로 후속 범위다.
ID와 payload hash로 재시도를 검증한다.200행마다 createMany와 ID/hash 확인 SELECT를 사용하며
본문 전체를 재조회하지 않는다. 최초 기준은 국가당4행, 적용 변경은 해당 영역1행이다.
세계 전체 정책이나 장수별 정책을 매 턴 복사하지 않는다. 기존 NPC 기본값은 별도 pure module로
옮겨 mutation과 audit allowlist가 공유하고 기존 export 경로는 유지한다.
정책 schema version은1이며 migration은 기존 이력을 backfill하지 않는다. 이미 적용한 migration
checksum은 수정하지 않고 schema_version 필드는 별도 증분 migration으로 추가했다.
정리 worker는 이전 기수 정책 ID도 최대200개씩 삭제한다. policy version의 self-reference는
삭제 FK로 강제하지 않아 과거 비공개 기수를 key batch로 정리할 수 있다. 현재 기수 포인터는
같은 transaction으로 저장하고 조회 시 항상 현재 기수 범위를 검사해야 한다.
policyHistory API/UI, 완전한 초기 기준 내구화와 NPC 결정 연결은 아직 남았다.
## 이전 기수 월별 표본 정리 ## 이전 기수 월별 표본 정리
새 daemon runtime은 실제 `serverId`를 고정해 이전 월별 감사 표본 정리를 시작한다. 새 daemon runtime은 실제 `serverId`를 고정해 이전 월별 감사 표본 정리를 시작한다.
@@ -103,7 +144,7 @@ worker가 끝나므로 평상시 idle polling은 없다. 프로세스 재기동
시작한다. 종료는 진행 중 transaction을 기다린 뒤 connector를 닫는다. 원문 DB 오류 대신 시작한다. 종료는 진행 중 transaction을 기다린 뒤 connector를 닫는다. 원문 DB 오류 대신
고정 경고를 운영 로그에 남기며 정리 실패로 gameplay 결과를 실패 처리하지 않는다. 고정 경고를 운영 로그에 남기며 정리 실패로 gameplay 결과를 실패 처리하지 않는다.
현재 정리 대상은 구현된 PlayAuditMonth/General/City/Nation뿐이다. 기존 연감/계정 원장과 현재 정리 대상은 구현된 PlayAuditMonth/General/City/Nation과 PlayAuditPolicy다. 기존 연감/계정 원장과
LogEntry 보존 정책은 바꾸지 않는다. 외교·정책·trace 테이블을 추가할 때 같은 수명주기와 LogEntry 보존 정책은 바꾸지 않는다. 외교·정책·trace 테이블을 추가할 때 같은 수명주기와
key 단위 삭제를 연결해야 한다. Gateway RESET의 기존 process중지→seed commit→재기동 key 단위 삭제를 연결해야 한다. Gateway RESET의 기존 process중지→seed commit→재기동
경로에서 시작한다. 예약 상태에서는 runtime이 없을 수 있어 seed commit 직후에도 batch 경로에서 시작한다. 예약 상태에서는 runtime이 없을 수 있어 seed commit 직후에도 batch
+27
View File
@@ -1138,3 +1138,30 @@ model PlayAuditGeneral {
@@index([sampleId, cityId, generalId]) @@index([sampleId, cityId, generalId])
@@map("play_audit_general") @@map("play_audit_general")
} }
// 현재 기수의 불변 정책 버전. live nation 삭제와 수명을 분리한다.
model PlayAuditPolicy {
schemaVersion Int @default(1) @map("schema_version")
id String @id
serverId String @map("server_id")
nationId Int @map("nation_id")
area String
revision Int
previousId String? @map("previous_id")
source String
year Int
month Int
tick Int?
requestId String? @map("request_id")
inputSequence BigInt? @map("input_sequence")
ordinal Int
actor Json?
before Json?
after Json
hash String
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@unique([serverId, nationId, area, revision])
@@index([serverId, nationId, year, month, ordinal])
@@map("play_audit_policy")
}
@@ -0,0 +1,22 @@
CREATE TABLE "play_audit_policy" (
"id" TEXT NOT NULL PRIMARY KEY,
"server_id" TEXT NOT NULL,
"nation_id" INTEGER NOT NULL,
"area" TEXT NOT NULL CHECK ("area" IN ('NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY', 'DEFENCE')),
"revision" INTEGER NOT NULL CHECK ("revision" > 0),
"previous_id" TEXT,
"source" TEXT NOT NULL CHECK ("source" IN ('BASELINE', 'CHANGE', 'OBSERVED_GAP')),
"year" INTEGER NOT NULL,
"month" INTEGER NOT NULL CHECK ("month" BETWEEN 1 AND 12),
"tick" INTEGER,
"request_id" TEXT,
"input_sequence" BIGINT,
"ordinal" INTEGER NOT NULL,
"actor" JSONB,
"before" JSONB,
"after" JSONB NOT NULL,
"hash" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX "play_audit_policy_server_id_nation_id_area_revision_key" ON "play_audit_policy" ("server_id", "nation_id", "area", "revision");
CREATE INDEX "play_audit_policy_server_id_nation_id_year_month_ordinal_idx" ON "play_audit_policy" ("server_id", "nation_id", "year", "month", "ordinal");
@@ -0,0 +1 @@
ALTER TABLE "play_audit_policy" ADD COLUMN "schema_version" INTEGER NOT NULL DEFAULT 1 CHECK ("schema_version" > 0);