From d549cbf8874fd61d1bf4dd80e1867669f8c53fb0 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 04:48:58 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20NPC=EC=99=80=20=EA=B5=AD=EB=B0=A9=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EB=B3=80=EA=B2=BD=EC=9D=84=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EB=B2=84=EC=A0=84=EC=9C=BC=EB=A1=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/selectPool.integration.test.ts | 54 ++++++ app/game-engine/src/playAudit/policy.ts | 155 ++++++++++++++++++ .../src/playAudit/policyPersistence.ts | 42 +++++ app/game-engine/src/playAudit/retention.ts | 12 ++ app/game-engine/src/turn/databaseHooks.ts | 14 +- app/game-engine/src/turn/inMemoryWorld.ts | 23 +++ .../src/turn/nationSettingMutation.ts | 25 ++- app/game-engine/src/turn/npcPolicyDefaults.ts | 91 ++++++++++ app/game-engine/src/turn/npcPolicyMutation.ts | 122 ++++---------- app/game-engine/src/turn/turnDaemon.ts | 2 + .../test/nationSettingMutation.test.ts | 45 +++++ .../test/npcPolicyLifecycle.test.ts | 77 ++++++++- ...AuditPolicyPersistence.integration.test.ts | 112 +++++++++++++ .../playAuditRetention.integration.test.ts | 31 ++++ .../test/realtimeReadModelChanges.test.ts | 1 + docs/design/play-audit-implementation.md | 45 ++++- packages/infra/prisma/game.prisma | 27 +++ .../migration.sql | 22 +++ .../migration.sql | 1 + 19 files changed, 803 insertions(+), 98 deletions(-) create mode 100644 app/game-engine/src/playAudit/policy.ts create mode 100644 app/game-engine/src/playAudit/policyPersistence.ts create mode 100644 app/game-engine/src/turn/npcPolicyDefaults.ts create mode 100644 app/game-engine/test/playAuditPolicyPersistence.integration.test.ts create mode 100644 packages/infra/prisma/migrations/20260916030000_add_play_audit_policy/migration.sql create mode 100644 packages/infra/prisma/migrations/20260916031000_add_play_audit_policy_schema_version/migration.sql diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index 6b45f8c6..b3a1080b 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -166,6 +166,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { closeDb = () => connector.disconnect(); await db.inputEvent.deleteMany(); await db.logEntry.deleteMany(); + await db.playAuditPolicy.deleteMany({ where: { serverId: profile } }); worldStateId = (await db.worldState.findFirstOrThrow()).id; await db.playAuditMonth.deleteMany({ 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); + 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 () => { const logicalNow = runtime!.world.getGameNow(new Date()); const logicalNowMs = logicalNow.getTime(); diff --git a/app/game-engine/src/playAudit/policy.ts b/app/game-engine/src/playAudit/policy.ts new file mode 100644 index 00000000..f3b065c1 --- /dev/null +++ b/app/game-engine/src/playAudit/policy.ts @@ -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; +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, 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, 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; + actor?: TurnGeneral; + permission?: number; + requestId?: string; +}): { _playAuditPolicy?: Record } => { + 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 = {}; + 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); +}; diff --git a/app/game-engine/src/playAudit/policyPersistence.ts b/app/game-engine/src/playAudit/policyPersistence.ts new file mode 100644 index 00000000..2b6603ca --- /dev/null +++ b/app/game-engine/src/playAudit/policyPersistence.ts @@ -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 => { + 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'); + } +}; diff --git a/app/game-engine/src/playAudit/retention.ts b/app/game-engine/src/playAudit/retention.ts index 32833791..9fe81868 100644 --- a/app/game-engine/src/playAudit/retention.ts +++ b/app/game-engine/src/playAudit/retention.ts @@ -20,6 +20,18 @@ export const prunePreviousAuditBatch = async ( if (!lock?.locked) return { status: 'busy', deleted: 0 }; const world = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } }); 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 삭제의 경쟁도 차단한다. const [sample] = await tx.$queryRaw<{ id: string }[]>` SELECT id FROM play_audit_month WHERE server_id <> ${expectedServerId} diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 5cf4570c..72a4ea96 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,3 +1,4 @@ +import { persistAuditPolicies } from '../playAudit/policyPersistence.js'; import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js'; import { persistAuditMonth } from '../playAudit/persistence.js'; import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js'; @@ -1145,6 +1146,7 @@ export const createDatabaseTurnHooks = async ( pendingNationBettingFinishes, pendingYearbookSnapshots, pendingAuditMonths, + pendingAuditPolicies, pendingUnificationFinalizations, } = changes; const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); @@ -1303,15 +1305,18 @@ export const createDatabaseTurnHooks = async ( cutWallAt: unificationCutWallAt!, }) : null; + let auditCommand: { requestId: string; sequence: bigint; actorUserId: string | null } | undefined; if (commandCompletion) { const commandFence = await prisma.$queryRaw< Array<{ status: string; + sequence: bigint; + actor_user_id: string | null; processing_clock_revision: bigint | null; processing_deadline_generation: bigint | null; }> >(GamePrisma.sql` - SELECT status, + SELECT status, sequence, actor_user_id, processing_clock_revision, processing_deadline_generation FROM input_event @@ -1320,6 +1325,12 @@ export const createDatabaseTurnHooks = async ( FOR UPDATE `); const event = commandFence[0]; + if (event) + auditCommand = { + requestId: commandCompletion.requestId, + sequence: event.sequence, + actorUserId: event.actor_user_id, + }; const unificationRevisionTransition = commandCompletion.result.type === 'messageRespond' && commandCompletion.result.ok && @@ -1876,6 +1887,7 @@ export const createDatabaseTurnHooks = async ( data: pendingLogRows, }); } + await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand); for (const snapshot of pendingAuditMonths) { await persistAuditMonth(prisma, snapshot); } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 8999eb0c..ae210b6e 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1,3 +1,4 @@ +import { initializeNationAuditPolicies, type PendingAuditPolicy } from '../playAudit/policy.js'; import type { PendingAuditMonth } from '../playAudit/persistence.js'; import type { City, @@ -201,6 +202,7 @@ export interface TurnWorldChanges { pendingNationBettingFinishes: PendingNationBettingFinish[]; pendingYearbookSnapshots: PendingYearbookSnapshot[]; pendingAuditMonths: PendingAuditMonth[]; + pendingAuditPolicies: PendingAuditPolicy[]; pendingUnificationFinalizations: PendingUnificationFinalization[]; } @@ -242,6 +244,7 @@ export interface InMemoryTurnWorldStateSnapshot { pendingNationBettingFinishes: PendingNationBettingFinish[]; pendingYearbookSnapshots: PendingYearbookSnapshot[]; pendingAuditMonths: PendingAuditMonth[]; + pendingAuditPolicies: PendingAuditPolicy[]; pendingUnificationFinalizations: PendingUnificationFinalization[]; pendingRealtimeBacklogShiftTicks: number; } @@ -547,6 +550,7 @@ export class InMemoryTurnWorld { private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = []; private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = []; private readonly pendingAuditMonths: PendingAuditMonth[] = []; + private readonly pendingAuditPolicies: PendingAuditPolicy[] = []; private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = []; private pendingRealtimeBacklogShiftTicks = 0; private readonly scenarioConfig: ScenarioConfig; @@ -1096,6 +1100,7 @@ export class InMemoryTurnWorld { pendingNationBettingFinishes: this.pendingNationBettingFinishes, pendingYearbookSnapshots: this.pendingYearbookSnapshots, pendingAuditMonths: this.pendingAuditMonths, + pendingAuditPolicies: this.pendingAuditPolicies, pendingUnificationFinalizations: this.pendingUnificationFinalizations, pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks, } satisfies InMemoryTurnWorldStateSnapshot); @@ -1143,6 +1148,7 @@ export class InMemoryTurnWorld { this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes); this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots); this.replaceArray(this.pendingAuditMonths, restored.pendingAuditMonths); + this.replaceArray(this.pendingAuditPolicies, restored.pendingAuditPolicies); this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations); 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 { this.pendingAuditMonths.push(structuredClone(snapshot)); } @@ -1595,6 +1614,7 @@ export class InMemoryTurnWorld { this.dirtyNationIds.add(nation.id); this.createdNationIds.add(nation.id); this.ensureDiplomacyMatrix(); + initializeNationAuditPolicies(this, nation.id); return true; } @@ -2205,6 +2225,7 @@ export class InMemoryTurnWorld { })); const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots); const pendingAuditMonths = structuredClone(this.pendingAuditMonths); + const pendingAuditPolicies = structuredClone(this.pendingAuditPolicies); const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations); const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort( (left, right) => left - right @@ -2238,6 +2259,7 @@ export class InMemoryTurnWorld { pendingNationBettingFinishes, pendingYearbookSnapshots, pendingAuditMonths, + pendingAuditPolicies, pendingUnificationFinalizations, }; } @@ -2277,6 +2299,7 @@ export class InMemoryTurnWorld { this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length); this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length); this.pendingAuditMonths.splice(0, changes.pendingAuditMonths.length); + this.pendingAuditPolicies.splice(0, changes.pendingAuditPolicies.length); this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length); } diff --git a/app/game-engine/src/turn/nationSettingMutation.ts b/app/game-engine/src/turn/nationSettingMutation.ts index f2cb879b..9aa7484d 100644 --- a/app/game-engine/src/turn/nationSettingMutation.ts +++ b/app/game-engine/src/turn/nationSettingMutation.ts @@ -1,3 +1,4 @@ +import { recordAuditPolicyChange } from '../playAudit/policy.js'; import { createHash } from 'node:crypto'; import { asRecord, formatServerDateTime, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common'; @@ -112,7 +113,11 @@ export const applyNationSettingMutation = (options: { break; } 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); } updates = { rate: command.mutation.amount }; @@ -128,7 +133,11 @@ export const applyNationSettingMutation = (options: { updates = { bill: command.mutation.amount }; break; 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); } updates = { secretlimit: command.mutation.amount }; @@ -154,10 +163,22 @@ export const applyNationSettingMutation = (options: { } 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, { meta: { ...nation.meta, ...updates, + ...audit, _updatedAt: updatedAt, }, }); diff --git a/app/game-engine/src/turn/npcPolicyDefaults.ts b/app/game-engine/src/turn/npcPolicyDefaults.ts new file mode 100644 index 00000000..fc5910a8 --- /dev/null +++ b/app/game-engine/src/turn/npcPolicyDefaults.ts @@ -0,0 +1,91 @@ +export type NationPolicy = { + reqNationGold: number; + reqNationRice: number; + CombatForce: Record; + 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, +}; diff --git a/app/game-engine/src/turn/npcPolicyMutation.ts b/app/game-engine/src/turn/npcPolicyMutation.ts index 0185f585..d025245e 100644 --- a/app/game-engine/src/turn/npcPolicyMutation.ts +++ b/app/game-engine/src/turn/npcPolicyMutation.ts @@ -1,3 +1,4 @@ +import { recordAuditPolicyChange } from '../playAudit/policy.js'; import { createHash } from 'node:crypto'; import { @@ -11,97 +12,18 @@ import { resolveTroopSecretPermission } from '@sammo-ts/logic'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; -export type NationPolicy = { - reqNationGold: number; - reqNationRice: number; - CombatForce: Record; - 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, -}; +import { + DEFAULT_NATION_POLICY, + DEFAULT_NATION_PRIORITY, + DEFAULT_GENERAL_PRIORITY, + type NationPolicy, +} from './npcPolicyDefaults.js'; +export { + DEFAULT_NATION_POLICY, + DEFAULT_NATION_PRIORITY, + DEFAULT_GENERAL_PRIORITY, + type NationPolicy, +} from './npcPolicyDefaults.js'; const NATION_POLICY_KEYS = new Set(Object.keys(DEFAULT_NATION_POLICY) as Array); @@ -306,7 +228,8 @@ export const applyNpcPolicyMutation = (options: { if (!nation) { 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', '권한이 부족합니다. 군주, 외교권자, 조언자가 아닙니다.', { nationId: command.nationId, }); @@ -385,10 +308,25 @@ export const applyNpcPolicyMutation = (options: { // accepted commands can share the same time. Include the durable request // identity to keep the strict CAS token unique. 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, { meta: { ...nation.meta, ...updates, + ...audit, // Keep the policy CAS independent from notice/tax/scout settings. // The legacy shared _updatedAt remains a one-time migration fallback. _npcPolicyUpdatedAt: updatedAt, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 6d41e475..29b965e3 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -1,3 +1,4 @@ +import { initializeAuditPolicies } from '../playAudit/policy.js'; import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js'; import { createPlayAuditHandler } from '../playAudit/collection.js'; import { randomUUID } from 'node:crypto'; @@ -803,6 +804,7 @@ const createTurnDaemonRuntimeWithLease = async ( }; const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions); worldRef = world; + initializeAuditPolicies(world); const stateManager = new EngineStateManager(); stateManager.register('world', { diff --git a/app/game-engine/test/nationSettingMutation.test.ts b/app/game-engine/test/nationSettingMutation.test.ts index e28e12df..5362f1bd 100644 --- a/app/game-engine/test/nationSettingMutation.test.ts +++ b/app/game-engine/test/nationSettingMutation.test.ts @@ -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(); + }); }); diff --git a/app/game-engine/test/npcPolicyLifecycle.test.ts b/app/game-engine/test/npcPolicyLifecycle.test.ts index 009eb8e6..7b2c1edb 100644 --- a/app/game-engine/test/npcPolicyLifecycle.test.ts +++ b/app/game-engine/test/npcPolicyLifecycle.test.ts @@ -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 }, + }); + }); }); diff --git a/app/game-engine/test/playAuditPolicyPersistence.integration.test.ts b/app/game-engine/test/playAuditPolicyPersistence.integration.test.ts new file mode 100644 index 00000000..0719208f --- /dev/null +++ b/app/game-engine/test/playAuditPolicyPersistence.integration.test.ts @@ -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; + 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 }); + }); +}); diff --git a/app/game-engine/test/playAuditRetention.integration.test.ts b/app/game-engine/test/playAuditRetention.integration.test.ts index 16e4c630..11aa281f 100644 --- a/app/game-engine/test/playAuditRetention.integration.test.ts +++ b/app/game-engine/test/playAuditRetention.integration.test.ts @@ -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(); + }); }); diff --git a/app/game-engine/test/realtimeReadModelChanges.test.ts b/app/game-engine/test/realtimeReadModelChanges.test.ts index f62f5e1f..c12d7a92 100644 --- a/app/game-engine/test/realtimeReadModelChanges.test.ts +++ b/app/game-engine/test/realtimeReadModelChanges.test.ts @@ -117,6 +117,7 @@ describe('durable read-model change journal mapping', () => { pendingNationBettingFinishes: [], pendingYearbookSnapshots: [], pendingAuditMonths: [], + pendingAuditPolicies: [], pendingUnificationFinalizations: [], } satisfies TurnWorldChanges; const readModelChanges = createEmptyRealtimeReadModelChanges(); diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index fff0cb43..f749a142 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -2,7 +2,7 @@ [확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며, 월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API, -국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 외교·정책·NPC trace·조사 도구는 미구현이다. +국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 정책 이력 저장 기반을 추가했으며 정책 조회 화면, 외교, NPC trace와 조사 도구는 남아 있다. Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다. ## 현재 구현 @@ -88,6 +88,47 @@ R1을 완료했다고 판단하지 않는다. world metadata로 계산하며 추가 DB 조회·쓰기나 시나리오/AI 규칙 변경은 없다. 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`를 고정해 이전 월별 감사 표본 정리를 시작한다. @@ -103,7 +144,7 @@ worker가 끝나므로 평상시 idle polling은 없다. 프로세스 재기동 시작한다. 종료는 진행 중 transaction을 기다린 뒤 connector를 닫는다. 원문 DB 오류 대신 고정 경고를 운영 로그에 남기며 정리 실패로 gameplay 결과를 실패 처리하지 않는다. -현재 정리 대상은 구현된 PlayAuditMonth/General/City/Nation뿐이다. 기존 연감/계정 원장과 +현재 정리 대상은 구현된 PlayAuditMonth/General/City/Nation과 PlayAuditPolicy다. 기존 연감/계정 원장과 LogEntry 보존 정책은 바꾸지 않는다. 외교·정책·trace 테이블을 추가할 때 같은 수명주기와 key 단위 삭제를 연결해야 한다. Gateway RESET의 기존 process중지→seed commit→재기동 경로에서 시작한다. 예약 상태에서는 runtime이 없을 수 있어 seed commit 직후에도 batch diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 53eab984..ed7afa30 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -1138,3 +1138,30 @@ model PlayAuditGeneral { @@index([sampleId, cityId, generalId]) @@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") +} diff --git a/packages/infra/prisma/migrations/20260916030000_add_play_audit_policy/migration.sql b/packages/infra/prisma/migrations/20260916030000_add_play_audit_policy/migration.sql new file mode 100644 index 00000000..22af96db --- /dev/null +++ b/packages/infra/prisma/migrations/20260916030000_add_play_audit_policy/migration.sql @@ -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"); diff --git a/packages/infra/prisma/migrations/20260916031000_add_play_audit_policy_schema_version/migration.sql b/packages/infra/prisma/migrations/20260916031000_add_play_audit_policy_schema_version/migration.sql new file mode 100644 index 00000000..d4b992d4 --- /dev/null +++ b/packages/infra/prisma/migrations/20260916031000_add_play_audit_policy_schema_version/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "play_audit_policy" ADD COLUMN "schema_version" INTEGER NOT NULL DEFAULT 1 CHECK ("schema_version" > 0);