feat: NPC와 국방 정책 변경을 감사 버전으로 저장
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "play_audit_policy" ADD COLUMN "schema_version" INTEGER NOT NULL DEFAULT 1 CHECK ("schema_version" > 0);
|
||||||
Reference in New Issue
Block a user