플레이 감사 국가 생멸과 장기 tick 저장을 보완하고 중간 전달 준비
This commit is contained in:
@@ -133,7 +133,7 @@ export const findAuditMonth = async (
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수의 게임 연월을 선택해 주세요.' });
|
||||
}
|
||||
if (!world.serverId) return null;
|
||||
return tx.playAuditMonth.findUnique({
|
||||
const sample = await tx.playAuditMonth.findUnique({
|
||||
where: {
|
||||
serverId_year_month_kind: {
|
||||
serverId: world.serverId,
|
||||
@@ -152,6 +152,7 @@ export const findAuditMonth = async (
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return sample ? { ...sample, tick: sample.tick?.toString() ?? null } : null;
|
||||
};
|
||||
|
||||
export const pageResult = <T>(
|
||||
|
||||
@@ -89,6 +89,7 @@ const classifications = {
|
||||
'tournament.placeBet',
|
||||
],
|
||||
redisProjection: [
|
||||
'tournament.start',
|
||||
'tournament.patchState',
|
||||
'tournament.seedParticipants',
|
||||
'tournament.setBettingEntries',
|
||||
@@ -160,7 +161,7 @@ describe('game-api direct mutation journal inventory', () => {
|
||||
// count independently catches mutations that were added to a router but never mounted.
|
||||
expect(declaredCount).toBe(actual.length);
|
||||
expect(new Set(classified).size).toBe(classified.length);
|
||||
expect(classified).toHaveLength(87);
|
||||
expect(classified).toHaveLength(88);
|
||||
expect(actual).toEqual(classified);
|
||||
});
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ const expectedOwnerCounts: Record<string, number> = {
|
||||
'mixed-saga': 9,
|
||||
operational: 3,
|
||||
'read-only-mutation-transport': 2,
|
||||
'redis-projection': 6,
|
||||
'redis-projection': 7,
|
||||
'separate-access-journal': 1,
|
||||
'session-only': 1,
|
||||
};
|
||||
@@ -105,7 +105,7 @@ describe('game-api mutation evidence manifest', () => {
|
||||
const rows = parseManifest();
|
||||
const manifestRoutes = rows.map(({ route }) => route);
|
||||
|
||||
expect(rows).toHaveLength(87);
|
||||
expect(rows).toHaveLength(88);
|
||||
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
|
||||
expect(manifestRoutes).toEqual([...manifestRoutes].sort());
|
||||
expect(manifestRoutes).toEqual(mountedMutationNames());
|
||||
|
||||
@@ -2200,6 +2200,7 @@ integration('game API security over HTTP transport', () => {
|
||||
month: 1,
|
||||
kind: 'MONTH_END',
|
||||
settlementsComplete: true,
|
||||
tick: 4_320_000_000n,
|
||||
hash: 'http-fixture',
|
||||
cities: {
|
||||
create: {
|
||||
@@ -2392,7 +2393,7 @@ integration('game API security over HTTP transport', () => {
|
||||
year: 190,
|
||||
month: revision === 3 ? 2 : 1,
|
||||
ordinal: revision,
|
||||
tick: 12,
|
||||
tick: 4_320_000_000n,
|
||||
requestId: revision > 1 ? 'audit-policy-request' : null,
|
||||
inputSequence: revision > 1 ? 9007199254740993n : null,
|
||||
actor:
|
||||
@@ -2438,6 +2439,7 @@ integration('game API security over HTTP transport', () => {
|
||||
data: {
|
||||
version: {
|
||||
previousId: policyId(2),
|
||||
tick: '4320000000',
|
||||
inputSequence: '9007199254740993',
|
||||
fields: [{ key: 'scout', beforeJson: '2', afterJson: '3', changed: true }],
|
||||
},
|
||||
@@ -2556,7 +2558,12 @@ integration('game API security over HTTP transport', () => {
|
||||
(await get('generalDetail', admin, { id: generalId, at: { year: 190, month: 1 } })).body
|
||||
).toMatchObject({
|
||||
result: {
|
||||
data: { collected: true, general: { name: '과거이름' }, city: { id: 99123, name: '과거도시' } },
|
||||
data: {
|
||||
collected: true,
|
||||
sample: { tick: '4320000000' },
|
||||
general: { name: '과거이름' },
|
||||
city: { id: 99123, name: '과거도시' },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
|
||||
@@ -178,3 +178,55 @@ export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt =
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 국가 생멸로 생성/제거된 관계를 기록한다. 행위자나 명령은 관측하지 못했다면 추정하지 않는다. */
|
||||
export const recordNationAuditDiplomacy = (
|
||||
world: InMemoryTurnWorld,
|
||||
nationIds: readonly number[],
|
||||
relations: readonly TurnDiplomacy[],
|
||||
operation: 'CREATED' | 'REMOVED',
|
||||
observedTick?: number
|
||||
): void => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||
const targets = new Set(nationIds.filter((id) => id > 0));
|
||||
const observed = relations
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.fromNationId > 0 &&
|
||||
entry.toNationId > 0 &&
|
||||
(targets.has(entry.fromNationId) || targets.has(entry.toNationId))
|
||||
)
|
||||
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId);
|
||||
if (!observed.length) return;
|
||||
// 전역 순번은 실행 identity에만 사용한다. 한 사건 묶음 안의 순서는 방향별 local ordinal이다.
|
||||
const executionId = `nation-relations:${world.nextAuditOrdinal()}`;
|
||||
const clock = world.getGameClockState();
|
||||
for (const [index, entry] of observed.entries()) {
|
||||
const value = { state: entry.state, term: entry.term, dead: entry.dead };
|
||||
world.queueAuditDiplomacy({
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
srcNationId: entry.fromNationId,
|
||||
destNationId: entry.toNationId,
|
||||
category: 'RELATION',
|
||||
source: 'ENGINE',
|
||||
eventType: `NATION_RELATION_${operation}`,
|
||||
documentId: null,
|
||||
documentHash: null,
|
||||
previousDocumentId: null,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: BigInt(observedTick ?? clock.tick),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
executionId,
|
||||
ordinal: index + 1,
|
||||
requestId: null,
|
||||
inputSequence: null,
|
||||
actor: null,
|
||||
before: operation === 'REMOVED' ? value : null,
|
||||
after: operation === 'CREATED' ? value : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,7 +28,8 @@ export const persistAuditMonth = async (
|
||||
!Number.isInteger(snapshot.year) ||
|
||||
!Number.isInteger(snapshot.month) ||
|
||||
snapshot.month < 1 ||
|
||||
snapshot.month > 12
|
||||
snapshot.month > 12 ||
|
||||
(snapshot.tick !== null && (!Number.isSafeInteger(snapshot.tick) || snapshot.tick < 0))
|
||||
) {
|
||||
throw new Error('Invalid play audit month identity');
|
||||
}
|
||||
@@ -42,7 +43,7 @@ export const persistAuditMonth = async (
|
||||
year: snapshot.year,
|
||||
month: snapshot.month,
|
||||
kind: snapshot.kind,
|
||||
tick: snapshot.tick,
|
||||
tick: snapshot.tick === null ? null : BigInt(snapshot.tick),
|
||||
settlementsComplete: snapshot.settlementsComplete,
|
||||
hash,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ export const persistAuditPolicies = async (
|
||||
): Promise<void> => {
|
||||
for (let offset = 0; offset < policies.length; offset += 200) {
|
||||
const batch = policies.slice(offset, offset + 200).map((policy) => {
|
||||
if (!Number.isSafeInteger(policy.tick) || policy.tick < 0)
|
||||
throw new Error('Invalid play audit policy tick');
|
||||
if (policy.requestId && !command) throw new Error('Play audit policy input event context missing');
|
||||
if (
|
||||
policy.requestId &&
|
||||
@@ -18,6 +20,7 @@ export const persistAuditPolicies = async (
|
||||
}
|
||||
return {
|
||||
...policy,
|
||||
tick: BigInt(policy.tick),
|
||||
inputSequence: policy.requestId && command ? command.sequence : null,
|
||||
actor: policy.actor ? (JSON.parse(JSON.stringify(policy.actor)) as InputJsonValue) : GamePrisma.DbNull,
|
||||
before: policy.before
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { recordTurnAuditDiplomacy, type AuditDiplomacyAction } from '../playAudit/diplomacy.js';
|
||||
import {
|
||||
recordTurnAuditDiplomacy,
|
||||
recordNationAuditDiplomacy,
|
||||
type AuditDiplomacyAction,
|
||||
} from '../playAudit/diplomacy.js';
|
||||
import type { AuditDiplomacyEventDraft } from '@sammo-ts/infra';
|
||||
import { initializeNationAuditPolicies, type PendingAuditPolicy } from '../playAudit/policy.js';
|
||||
import type { PendingAuditMonth } from '../playAudit/persistence.js';
|
||||
@@ -1635,6 +1639,7 @@ export class InMemoryTurnWorld {
|
||||
this.createdNationIds.add(nation.id);
|
||||
this.ensureDiplomacyMatrix();
|
||||
initializeNationAuditPolicies(this, nation.id);
|
||||
recordNationAuditDiplomacy(this, [nation.id], this.collectNationDiplomacy([nation.id]), 'CREATED');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1723,7 +1728,7 @@ export class InMemoryTurnWorld {
|
||||
return true;
|
||||
}
|
||||
|
||||
removeNation(id: number): boolean {
|
||||
removeNation(id: number, auditTick?: number): boolean {
|
||||
if (!this.nations.has(id)) {
|
||||
return false;
|
||||
}
|
||||
@@ -1731,13 +1736,16 @@ export class InMemoryTurnWorld {
|
||||
this.dirtyNationIds.delete(id);
|
||||
this.createdNationIds.delete(id);
|
||||
this.deletedNationIds.add(id);
|
||||
const removedRelations: TurnDiplomacy[] = [];
|
||||
for (const [key, entry] of this.diplomacy) {
|
||||
if (entry.fromNationId === id || entry.toNationId === id) {
|
||||
removedRelations.push(entry);
|
||||
this.diplomacy.delete(key);
|
||||
this.dirtyDiplomacyKeys.delete(key);
|
||||
this.createdDiplomacyKeys.delete(key);
|
||||
}
|
||||
}
|
||||
recordNationAuditDiplomacy(this, [id], removedRelations, 'REMOVED', auditTick);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2100,7 +2108,7 @@ export class InMemoryTurnWorld {
|
||||
this.createdGeneralIds.add(createdGeneral.id);
|
||||
}
|
||||
if (result.created.nations) {
|
||||
let addedNation = false;
|
||||
const addedNationIds: number[] = [];
|
||||
for (const createdNation of result.created.nations) {
|
||||
if (this.nations.has(createdNation.id)) {
|
||||
continue;
|
||||
@@ -2108,10 +2116,18 @@ export class InMemoryTurnWorld {
|
||||
this.nations.set(createdNation.id, { ...createdNation });
|
||||
this.dirtyNationIds.add(createdNation.id);
|
||||
this.createdNationIds.add(createdNation.id);
|
||||
addedNation = true;
|
||||
addedNationIds.push(createdNation.id);
|
||||
}
|
||||
if (addedNation) {
|
||||
if (addedNationIds.length) {
|
||||
this.ensureDiplomacyMatrix();
|
||||
for (const id of addedNationIds) initializeNationAuditPolicies(this, id);
|
||||
recordNationAuditDiplomacy(
|
||||
this,
|
||||
addedNationIds,
|
||||
this.collectNationDiplomacy(addedNationIds),
|
||||
'CREATED',
|
||||
executionTick
|
||||
);
|
||||
}
|
||||
}
|
||||
if (result.created.troops) {
|
||||
@@ -2133,7 +2149,7 @@ export class InMemoryTurnWorld {
|
||||
if (result.successorlessNationId !== undefined) {
|
||||
// 사망 군주도 삭제 전 archive의 장수 목록과 멸망 로그에 포함한다.
|
||||
this.generals.set(currentGeneral.id, result.general ?? currentGeneral);
|
||||
this.dissolveNationWithoutSuccessor(result.successorlessNationId, currentGeneral.id);
|
||||
this.dissolveNationWithoutSuccessor(result.successorlessNationId, currentGeneral.id, executionTick);
|
||||
}
|
||||
if (result.deleted?.general) {
|
||||
this.removeGeneral(currentGeneral.id);
|
||||
@@ -2142,7 +2158,7 @@ export class InMemoryTurnWorld {
|
||||
this.lifecycleEvents.push(result.lifecycleEvent);
|
||||
}
|
||||
|
||||
this.removeCollapsedNations();
|
||||
this.removeCollapsedNations(executionTick);
|
||||
|
||||
return {
|
||||
nextTurnAt,
|
||||
@@ -2343,7 +2359,7 @@ export class InMemoryTurnWorld {
|
||||
return changes;
|
||||
}
|
||||
|
||||
dissolveNationWithoutSuccessor(nationId: number, dyingLordId?: number): boolean {
|
||||
dissolveNationWithoutSuccessor(nationId: number, dyingLordId?: number, auditTick?: number): boolean {
|
||||
const nation = this.nations.get(nationId);
|
||||
if (!nation) {
|
||||
return false;
|
||||
@@ -2398,10 +2414,10 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
// 과거 누락으로 군주가 이미 삭제된 국가의 운영 복구도 같은 정산을 쓴다.
|
||||
if (dyingLordId === undefined) pushHistory();
|
||||
return this.collapseNation(nationId);
|
||||
return this.collapseNation(nationId, auditTick);
|
||||
}
|
||||
|
||||
collapseNation(nationId: number): boolean {
|
||||
collapseNation(nationId: number, auditTick?: number): boolean {
|
||||
const nation = this.nations.get(nationId);
|
||||
if (!nation) {
|
||||
return false;
|
||||
@@ -2480,11 +2496,11 @@ export class InMemoryTurnWorld {
|
||||
this.removeTroop(troop.id);
|
||||
}
|
||||
}
|
||||
this.removeNation(nationId);
|
||||
this.removeNation(nationId, auditTick);
|
||||
return true;
|
||||
}
|
||||
|
||||
private removeCollapsedNations(): void {
|
||||
private removeCollapsedNations(auditTick?: number): void {
|
||||
const collapsedNationIds: number[] = [];
|
||||
for (const nation of this.nations.values()) {
|
||||
if (nation.id <= 0) {
|
||||
@@ -2502,10 +2518,26 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
|
||||
for (const nationId of collapsedNationIds) {
|
||||
this.collapseNation(nationId);
|
||||
this.collapseNation(nationId, auditTick);
|
||||
}
|
||||
}
|
||||
|
||||
/** 감사 때문에 전체 관계 matrix를 복제하지 않고 대상 국가에 연결된 행만 가져온다. */
|
||||
private collectNationDiplomacy(nationIds: readonly number[]): TurnDiplomacy[] {
|
||||
const relations = new Map<string, TurnDiplomacy>();
|
||||
for (const nationId of nationIds) {
|
||||
if (nationId <= 0) continue;
|
||||
for (const otherId of this.nations.keys()) {
|
||||
if (otherId <= 0 || otherId === nationId) continue;
|
||||
for (const key of [buildDiplomacyKey(nationId, otherId), buildDiplomacyKey(otherId, nationId)]) {
|
||||
const relation = this.diplomacy.get(key);
|
||||
if (relation) relations.set(key, relation);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(relations.values());
|
||||
}
|
||||
|
||||
private ensureDiplomacyMatrix(): void {
|
||||
const nationIds = Array.from(this.nations.keys());
|
||||
for (const srcNationId of nationIds) {
|
||||
|
||||
@@ -49,6 +49,7 @@ integration('monthly diplomacy persistence', () => {
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.playAuditPolicy.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.diplomacy.deleteMany({
|
||||
where: {
|
||||
OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }],
|
||||
@@ -61,6 +62,7 @@ integration('monthly diplomacy persistence', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.playAuditPolicy.deleteMany({ where: { serverId: scenarioCode } });
|
||||
await db.diplomacy.deleteMany({
|
||||
where: {
|
||||
OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }],
|
||||
@@ -278,6 +280,54 @@ integration('monthly diplomacy persistence', () => {
|
||||
currentYear: 193,
|
||||
currentMonth: 4,
|
||||
});
|
||||
const endingNation = nationIds[3]!;
|
||||
expect(world.removeNation(endingNation)).toBe(true);
|
||||
const removals = world.peekDirtyState().pendingAuditDiplomacy;
|
||||
expect(removals).toHaveLength(6);
|
||||
expect(
|
||||
removals.every((event) => event.eventType === 'NATION_RELATION_REMOVED' && event.after === null)
|
||||
).toBe(true);
|
||||
await db.$executeRawUnsafe(`CREATE FUNCTION reject_lifecycle_audit_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'lifecycle audit fixture failure'; END; $$`);
|
||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_lifecycle_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_lifecycle_audit_fixture()`);
|
||||
try {
|
||||
await expect(hooks.flushChanges()).rejects.toThrow('lifecycle audit fixture failure');
|
||||
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1);
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(removals);
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_lifecycle_audit_fixture ON play_audit_diplomacy_event');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_lifecycle_audit_fixture()');
|
||||
}
|
||||
await hooks.flushChanges();
|
||||
expect(await db.nation.count({ where: { id: endingNation } })).toBe(0);
|
||||
expect(
|
||||
await db.diplomacy.count({
|
||||
where: { OR: [{ srcNationId: endingNation }, { destNationId: endingNation }] },
|
||||
})
|
||||
).toBe(0);
|
||||
expect(
|
||||
await db.playAuditDiplomacyEvent.count({
|
||||
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_REMOVED' },
|
||||
})
|
||||
).toBe(6);
|
||||
expect(world.addNation(buildNation(endingNation, '재건국', 1))).toBe(true);
|
||||
await hooks.flushChanges();
|
||||
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1);
|
||||
expect(
|
||||
await db.diplomacy.count({
|
||||
where: { OR: [{ srcNationId: endingNation }, { destNationId: endingNation }] },
|
||||
})
|
||||
).toBe(6);
|
||||
expect(await db.playAuditPolicy.count({ where: { serverId: scenarioCode, nationId: endingNation } })).toBe(
|
||||
4
|
||||
);
|
||||
await hooks.flushChanges();
|
||||
expect(
|
||||
await db.playAuditDiplomacyEvent.count({
|
||||
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_CREATED' },
|
||||
})
|
||||
).toBe(6);
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
@@ -129,10 +129,67 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => {
|
||||
return world;
|
||||
};
|
||||
describe('play audit collection durability state', () => {
|
||||
it('captures direct reserved-turn nation batches once and initializes their policies', () => {
|
||||
const world = buildWorld({
|
||||
execute: () => ({ created: { generals: [], nations: [buildNation(3, 0, {}), buildNation(4, 0, {})] } }),
|
||||
});
|
||||
world.updateGeneral(3, { turnTick: 123 });
|
||||
world.executeGeneralTurn(world.getGeneralById(3)!);
|
||||
const changes = world.peekDirtyState();
|
||||
expect(changes.pendingAuditDiplomacy).toHaveLength(10);
|
||||
expect(changes.pendingAuditDiplomacy.every((event) => event.tick === 123n)).toBe(true);
|
||||
expect(
|
||||
new Set(changes.pendingAuditDiplomacy.map((event) => `${event.srcNationId}:${event.destNationId}`)).size
|
||||
).toBe(10);
|
||||
expect(changes.pendingAuditPolicies).toHaveLength(8);
|
||||
expect(world.getNationById(3)?.meta._playAuditPolicy).toBeDefined();
|
||||
expect(world.getNationById(4)?.meta._playAuditPolicy).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps nation creation and removal relations ordered across repeated IDs and rollback', () => {
|
||||
const world = buildWorld();
|
||||
const checkpoint = world.captureState();
|
||||
const nation = buildNation(3, 0, {});
|
||||
expect(world.addNation(nation)).toBe(true);
|
||||
const created = world.peekDirtyState().pendingAuditDiplomacy;
|
||||
expect(created).toHaveLength(4);
|
||||
expect(created.map(({ srcNationId, destNationId }) => [srcNationId, destNationId])).toEqual([
|
||||
[1, 3],
|
||||
[2, 3],
|
||||
[3, 1],
|
||||
[3, 2],
|
||||
]);
|
||||
expect(created.every((event) => event.before === null && event.eventType === 'NATION_RELATION_CREATED')).toBe(
|
||||
true
|
||||
);
|
||||
expect(world.addNation(nation)).toBe(false);
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(created);
|
||||
world.applyDiplomacyPatch({ srcNationId: 1, destNationId: 3, patch: { state: 7, term: 12 } });
|
||||
expect(world.removeNation(3)).toBe(true);
|
||||
const removed = world.peekDirtyState().pendingAuditDiplomacy.slice(4);
|
||||
expect(removed).toHaveLength(4);
|
||||
expect(removed[0]).toMatchObject({
|
||||
eventType: 'NATION_RELATION_REMOVED',
|
||||
after: null,
|
||||
before: { state: 7, term: 12, dead: 0 },
|
||||
});
|
||||
expect(world.removeNation(3)).toBe(false);
|
||||
expect(world.addNation(nation)).toBe(true);
|
||||
const replayedId = world.peekDirtyState().pendingAuditDiplomacy.slice(8);
|
||||
expect(replayedId).toHaveLength(4);
|
||||
expect(replayedId[0]!.executionId).not.toBe(created[0]!.executionId);
|
||||
world.restoreState(checkpoint);
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||
expect(world.addNation(nation)).toBe(true);
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(created);
|
||||
});
|
||||
|
||||
it('marks an empty diplomacy baseline without inventing relations and validates before queuing', () => {
|
||||
const world = buildWorld();
|
||||
world.updateWorldMeta({ serverId: null });
|
||||
world.removeNation(1);
|
||||
world.removeNation(2);
|
||||
world.updateWorldMeta({ serverId: 'yearbook-projection-test' });
|
||||
expect(() => initializeAuditDiplomacy(world, new Date('invalid'))).toThrow(RangeError);
|
||||
expect(world.getState().meta.playAuditDiplomacy).toBeUndefined();
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||
|
||||
@@ -14,7 +14,7 @@ integration('play audit transactional month persistence', () => {
|
||||
serverId,
|
||||
year: 200,
|
||||
month: 1,
|
||||
tick: 10,
|
||||
tick: 4_320_000_000,
|
||||
kind: 'MONTH_END',
|
||||
settlementsComplete: true,
|
||||
...buildAuditSnapshot({
|
||||
@@ -67,7 +67,7 @@ integration('play audit transactional month persistence', () => {
|
||||
await expect(db.$transaction((tx) => persistAuditMonth(tx, { ...snapshot, tick: 11 }))).rejects.toThrow(
|
||||
'replay payload conflict'
|
||||
);
|
||||
expect((await db.playAuditMonth.findUniqueOrThrow({ where: { id: saved.id } })).tick).toBe(10);
|
||||
expect((await db.playAuditMonth.findUniqueOrThrow({ where: { id: saved.id } })).tick).toBe(4_320_000_000n);
|
||||
await db.playAuditMonth.delete({ where: { id: saved.id } });
|
||||
expect(await db.playAuditNation.count({ where: { sampleId: saved.id } })).toBe(0);
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ integration('immutable policy persistence', () => {
|
||||
source: 'BASELINE',
|
||||
year: 190,
|
||||
month: 1,
|
||||
tick: 1,
|
||||
tick: 4_320_000_000,
|
||||
requestId: null,
|
||||
ordinal: 1,
|
||||
actor: null,
|
||||
@@ -91,7 +91,7 @@ integration('immutable policy persistence', () => {
|
||||
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[0]).toMatchObject({ inputSequence: null, actor: null, tick: 4_320_000_000n });
|
||||
expect(rows[1]).toMatchObject({
|
||||
inputSequence: input.sequence,
|
||||
requestId,
|
||||
|
||||
@@ -193,7 +193,7 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
policies.every(
|
||||
(policy) =>
|
||||
policy.source === 'BASELINE' &&
|
||||
policy.tick === Number(beforeClock.clockTick) &&
|
||||
policy.tick === beforeClock.clockTick &&
|
||||
policy.inputSequence === null
|
||||
)
|
||||
).toBe(true);
|
||||
@@ -256,7 +256,7 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
expect(recovered.clockRevision).toBeGreaterThan(original.clockRevision);
|
||||
const policies = await db.playAuditPolicy.findMany({ where: { serverId, nationId: 91992 } });
|
||||
expect(policies).toHaveLength(4);
|
||||
expect(policies.every((policy) => policy.tick === runtime!.world.getGameClockState().tick)).toBe(true);
|
||||
expect(policies.every((policy) => policy.tick === BigInt(runtime!.world.getGameClockState().tick))).toBe(true);
|
||||
expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]);
|
||||
}, 30_000);
|
||||
it('adopts an empty document collection without duplicating an existing initial sample', async () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ const general = {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
};
|
||||
const install = async (page: Page, denied = false, baseline: boolean | 'document' = false) => {
|
||||
const install = async (page: Page, denied = false, baseline: boolean | 'document' | 'created' | 'removed' = false) => {
|
||||
const requests: { operation: string; input: Record<string, unknown> }[] = [];
|
||||
await page.addInitScript((profile) => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_audit');
|
||||
@@ -103,7 +103,14 @@ const install = async (page: Page, denied = false, baseline: boolean | 'document
|
||||
destNationId: 3,
|
||||
category: baseline === 'document' ? 'DOCUMENT' : 'RELATION',
|
||||
source: 'BASELINE',
|
||||
eventType: baseline === 'document' ? 'LETTER_BASELINE' : 'RELATION_BASELINE',
|
||||
eventType:
|
||||
baseline === 'document'
|
||||
? 'LETTER_BASELINE'
|
||||
: baseline === 'created'
|
||||
? 'NATION_RELATION_CREATED'
|
||||
: baseline === 'removed'
|
||||
? 'NATION_RELATION_REMOVED'
|
||||
: 'RELATION_BASELINE',
|
||||
documentId: baseline === 'document' ? 8 : null,
|
||||
previousDocumentId: null,
|
||||
year: 190,
|
||||
@@ -152,18 +159,27 @@ const install = async (page: Page, denied = false, baseline: boolean | 'document
|
||||
destNationId: 3,
|
||||
category: baseline === 'document' ? 'DOCUMENT' : 'RELATION',
|
||||
source: 'BASELINE',
|
||||
eventType: baseline === 'document' ? 'LETTER_BASELINE' : 'RELATION_BASELINE',
|
||||
eventType:
|
||||
baseline === 'document'
|
||||
? 'LETTER_BASELINE'
|
||||
: baseline === 'created'
|
||||
? 'NATION_RELATION_CREATED'
|
||||
: baseline === 'removed'
|
||||
? 'NATION_RELATION_REMOVED'
|
||||
: 'RELATION_BASELINE',
|
||||
documentId: baseline === 'document' ? 8 : null,
|
||||
previousDocumentId: null,
|
||||
year: 190,
|
||||
month: 1,
|
||||
actor: null,
|
||||
createdAt: world.asOf,
|
||||
before: null,
|
||||
before: baseline === 'removed' ? { state: 7, term: 12, dead: 0 } : null,
|
||||
after:
|
||||
baseline === 'document'
|
||||
? { state: 'ACTIVATED' }
|
||||
: { state: 2, term: 0, dead: 0 },
|
||||
: baseline === 'removed'
|
||||
? null
|
||||
: { state: 2, term: 0, dead: 0 },
|
||||
tick: '0',
|
||||
clockRevision: '1',
|
||||
ordinal: 1,
|
||||
@@ -951,3 +967,18 @@ test('existing diplomacy document is an initial observation with its preserved s
|
||||
await expect(page.getByRole('region', { name: '당시 외교 문서' })).toContainText('도입 전 본문');
|
||||
await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible();
|
||||
});
|
||||
|
||||
for (const [kind, label, value] of [
|
||||
['created', '신생국 관계 생성', '교역'],
|
||||
['removed', '멸망국 관계 종료', '불가침'],
|
||||
] as const) {
|
||||
test(`nation relation lifecycle displays ${kind} with a missing side`, async ({ page }) => {
|
||||
await install(page, false, kind);
|
||||
await page.goto(
|
||||
gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6')
|
||||
);
|
||||
await page.getByRole('button', { name: label, exact: true }).click();
|
||||
await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText(value);
|
||||
await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText('미관측 / 없음');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ let detailGeneration = 0;
|
||||
const selected = computed(() => (typeof route.query.event === 'string' ? route.query.event : null));
|
||||
const labels: Record<string, string> = {
|
||||
LETTER_BASELINE: '문서 최초 관측',
|
||||
NATION_RELATION_CREATED: '신생국 관계 생성',
|
||||
NATION_RELATION_REMOVED: '멸망국 관계 종료',
|
||||
RELATION_BASELINE: '관계 최초 관측',
|
||||
LETTER_PROPOSED: '문서 제안',
|
||||
LETTER_REPLACED: '문서 교체',
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
|
||||
gameSchemaHead: '20260907150000_wait_then_turn_recovery',
|
||||
gameSchemaHead: '20260916060000_widen_play_audit_ticks',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
|
||||
플레이 행위·국가 통계·NPC 결정의 감사는
|
||||
[프로필별 플레이 감사 설계](./design/play-audit.md)에서 별도로 정의합니다.
|
||||
현재는 설계 단계이며 각 profile의 `/play-audit`와 game-api가 화면·조회를
|
||||
소유할 예정입니다. 아래 `/gateway/admin/audit`는 기존 관리자 조치 원장입니다.
|
||||
각 profile의 `/play-audit`와 game-api가 화면·조회를 소유합니다. 현재 사용 가능한
|
||||
기능과 DB 적용·권한·미완성 범위는 [플레이 감사 운영 안내](./play-audit-operations.md)를 따릅니다. 아래 `/gateway/admin/audit`는 기존 관리자 조치 원장입니다.
|
||||
|
||||
## 화면 구성
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ tournament.setBettingEntries redis-projection core-only session-admin-role endpo
|
||||
tournament.setMatches redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
|
||||
tournament.setParticipants redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
|
||||
tournament.setState redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
|
||||
tournament.start redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
|
||||
troop.create engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
|
||||
troop.exit engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
|
||||
troop.join engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
|
||||
|
||||
|
@@ -1,12 +1,23 @@
|
||||
# 플레이 감사 구현 기록과 수집 inventory
|
||||
|
||||
[확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며,
|
||||
월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API,
|
||||
국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 정책 이력 저장과 목록/상세 조회, 초기 기준 내구화를 추가했으며 전체 종료 경계, 외교, NPC trace와 조사 도구는 남아 있다.
|
||||
Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다.
|
||||
[확정 설계](play-audit.md)의 P1~P6 구현 기록이다. 현재 월별 상태·국가 시계열,
|
||||
장수/도시 상세와 로그, 외교 이력, 정책 버전과 프로필별 관리자 진입을 사용할 수 있다.
|
||||
NPC 결정 trace와 조사 A~F의 완성, 전체 종료 경계 및 COST gate는 남아 있다.
|
||||
2026-09-16 사용자는 주간 한도 약20%를 남기고 사용 가능한 기능과 DB migration을
|
||||
정리·통합·push하는 중간 전달을 요청했다. 전체 설계 완료와 이 전달을 구분한다.
|
||||
실제 적용 방법과 제한은 [운영 안내](../play-audit-operations.md)를 따른다.
|
||||
|
||||
## 현재 구현
|
||||
|
||||
### 전달 전 DB tick 정밀도 보완
|
||||
|
||||
월 표본과 정책의 기존 INTEGER tick은 1개월36,000,000 기준 약60개월에 넘친다.
|
||||
`20260916060000_widen_play_audit_ticks`로 두 열을 BIGINT로 확장하고 writer의 안전한
|
||||
정수 검증/변환, 월 표본 상세의 문자열 응답을 연결했다. pending payload와 hash 생성은
|
||||
기존 number 의미를 유지해 이미 저장한 hash를 바꾸지 않는다. 릴리스 manifest도 이
|
||||
migration head를 가리킨다. 실제 PG의55→56/빈56/no-op·기존 값/null/hash 보존과 큰 tick
|
||||
저장 및 실제 HTTP의 월 표본/정책 상세 tick 문자열을 검증했다.
|
||||
|
||||
### 기본 조회 화면
|
||||
|
||||
프로필 game frontend의 `/play-audit`는 장수가 없는 감사 계정도 직접 접근한다.
|
||||
@@ -244,6 +255,27 @@ writer의 batch INSERT/hash 확인 SELECT를 사용한다. 원문은 해시 계
|
||||
실제 PG fixture는 201건/2 batch와 INITIAL 저장 실패의 전체 rollback, 원문 hash와 상태,
|
||||
재시작의 동일 event/표식을 확인한다. 화면에서는 '문서 최초 관측'으로 구분한다.
|
||||
|
||||
### 국가 생성·멸망 관계
|
||||
|
||||
국가 생성은 `addNation`(월간 NPC/이민족/즉시 명령)과 예약 턴의 직접 created.nations
|
||||
반영 경로를 모두 수집한다. 기본 matrix를 만든 뒤 신생국과 연결된 방향별 관계만 가져오고,
|
||||
한 번에 여러 국가를 생성해도 같은 관계를 중복 기록하지 않는다. 후자의 누락된 정책
|
||||
기준 4영역 초기화도 같은 지점에 연결했다. 생성 시 조회 범위는 대상 국가 수×기존 국가 수며
|
||||
전체 관계 matrix를 감사용으로 복제하지 않는다.
|
||||
|
||||
멸망은 후계자 없음/전투/월간 방랑 처리의 공통 removeNation에서 기존 삭제 순회가
|
||||
제거하는 관계를 재사용한다. 생성은 before null, 제거는 after null이며 default 교역
|
||||
생성을 월간 외교 상태 전이로 바꾸지 않는다. 기존 world 감사 순번은 `nation-relations:N`
|
||||
실행 identity에만 쓰고 사건 안의 ordinal은 방향별 정렬 순서다. checkpoint 복원과 기존
|
||||
fenced transaction/pending acknowledgement를 그대로 따른다. 원인 actor/request 연결은
|
||||
관측되지 않으면 null이며 향후 조사 C/E context 연결 대상으로 남긴다. 예약 턴에서 생성·멸망한 관계는 전역 clock 대신 해당 장수의 실행 tick을 전달한다.
|
||||
|
||||
단위 검증은 같은 ID 생성·삭제·재생성, 중복 add/remove, rollback, 예약 턴 복수 신생국의
|
||||
정책 기준/관계 중복 방지를 확인한다. 실제 PG는 제거 저장 실패의 nation/관계 rollback,
|
||||
재시도, 삭제 뒤 재생성, 정책 4개 및 이력 중복 방지를 검증한다. CHE/HWE에서 생성·종료의
|
||||
한쪽 상태 부재를 표시한다. 상대국이 없는 단독 신생국은 관계 사건이 없으며 독립 국가
|
||||
생멸 원장은 향후 조사 C에 속한다.
|
||||
|
||||
## NPC·국방 정책 버전 저장 기반
|
||||
|
||||
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
|
||||
@@ -449,9 +481,11 @@ no-general 허용, 무인증·일반 admin·다른 profile·제재 거부, 200
|
||||
반환하고 수입/급여만 기간 합산한다. 누락·국가 없음·불완전 정산의 흐름은 null,
|
||||
관측한 정산 없음은 0이다. 기간 일부 요청은 from/to와 complete=false로 표시한다.
|
||||
|
||||
장수·도시 상세와 독립 로그, FINAL 별도 표시와 정책 조회는 기본 화면에 연결했다. 외교·NPC 결정과 조사 기능은 남았다.
|
||||
장수·도시 상세와 독립 로그, FINAL 별도 표시와 정책 조회는 기본 화면에 연결했다. 외교 이력도 연결했으며 NPC 결정과 조사 기능의 완성은 남았다.
|
||||
|
||||
## 수집 지점과 쓰기 재검토
|
||||
## 초기 수집 지점과 쓰기 재검토
|
||||
|
||||
아래 표와 검증 진입점은 초기 구현 당시 inventory다. 이후 연결·검증 결과는 위의 현재 구현과 운영 안내를 기준으로 읽는다.
|
||||
|
||||
기준 Core commit은 `5ac961dfd17738dc4c39e6401f975296f39403a5`이다.
|
||||
SQL/bytes는 아직 실측하지 않았으며 아래는 현재 소스에서 확인한 연결 지점과 구현 경계다.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 문서 상태와 사용법
|
||||
|
||||
**설계 기준: 2026-09-16. 제품 기능은 미구현이다.** 이 문서는 관리자 플레이
|
||||
**설계 기준: 2026-09-16. 제품 기능은 부분 구현 상태다.** 이 문서는 관리자 플레이
|
||||
감사의 구현 goal과 완료 판정 기준이다. 문서 작성 완료는 기능 구현 완료가 아니다.
|
||||
후속 작업은 아래 요구사항 ID, 단계와 증거 표를 유지하며 진행 상태를 갱신한다.
|
||||
현재 구현 진행과 수집 지점은 [구현 inventory](play-audit-implementation.md)에 기록한다.
|
||||
@@ -401,7 +401,8 @@ NPC trace의 정책 참조와 사건 연결 외에 매 턴 전체 world를 seria
|
||||
|
||||
### 8.2 요구사항별 증거
|
||||
|
||||
모든 상태는 현재 **미구현**이다. 후속 보고서에 명령·artifact·commit을 연결해야만 체크한다.
|
||||
각 요구사항의 일부 구현과 검증은 구현 inventory/report에 기록한다. 아래 전체 합격 기준을
|
||||
충족하기 전에는 요구사항 전체를 완료로 체크하지 않는다.
|
||||
|
||||
| ID | 합격 기준 | 필요한 증거 |
|
||||
| ------- | ------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
@@ -438,3 +439,13 @@ desktop/mobile geometry를 확인한다. screenshot·DOM·computed style·측정
|
||||
문서 수정은 결정 이유와 영향을 받는 요구사항·비용·검증을 함께 변경한다.
|
||||
실행 일자·결과·미검증·commit은 상위 `report/`에 기록한다. 이 문서의 체크박스는
|
||||
단순 계획·시도·의도로 완료 처리하지 않는다.
|
||||
|
||||
### 8.4 사용량을 고려한 중간 전달
|
||||
|
||||
2026-09-16 사용자는 주간 잔여 약42%에서 약20%를 남기고 현재 작업물을 마무리하여
|
||||
사용 가능한 기능부터 이용할 수 있게 하며 DB 스키마도 미리 준비하도록 지시했다.
|
||||
이에 따라 큰 미완성 기능을 추가로 벌이기보다 현재 수집·조회·권한의 회귀 검증,
|
||||
정식 migration·운영 안내, 현재 main 통합과 push를 우선한다. 운영 적용은 대상 프로필을
|
||||
확인한 범위에서 수행한다. 구현되지 않은 NPC trace/조사 도구나 미검증 COST gate를
|
||||
완료로 표시하지 않는다. 후속 설계 범위는 삭제하지 않고 [운영 안내](../play-audit-operations.md)의
|
||||
사용 가능/미완성 경계와 구현 inventory에 남긴다.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# 플레이 감사 사용과 DB 적용
|
||||
|
||||
플레이 감사는 각 프로필의 `/play-audit`에서 읽는다. Gateway 관리자 조치 원장과
|
||||
다른 기능이다. 현재 전달은 [전체 설계](design/play-audit.md)의 부분 구현이며
|
||||
구현·검증 source는 [inventory](design/play-audit-implementation.md)에 기록한다.
|
||||
이번 전달 대상은 sam.hided.net의 전체 프로필이다. 코드와 migration을 main에 push하고,
|
||||
운영자는 각 프로필에 직접 DB 보존 업데이트를 적용한다. 이 문서의 검증 결과는 실제
|
||||
운영 DB에 이미 적용되었다는 뜻이 아니다.
|
||||
|
||||
## 지금 사용할 수 있는 기능
|
||||
|
||||
| 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 |
|
||||
| --- | --- | --- |
|
||||
| 국가 | 월말 금·쌀·기술력, 세율·실제 정산, 유저/NPC/부대장 NPC별 자원·숙련 집계, 월/6개월 그래프 | 보유량은 마지막 월말, 수입/지급액은 기간 합. 부분 기간/미수집은 0과 다름 |
|
||||
| 장수 | 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
|
||||
| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 |
|
||||
| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 |
|
||||
| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면과 NPC 결정의 정책 참조 연결은 아직 없음 |
|
||||
|
||||
목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로
|
||||
누른다. 현재 상태는 수동으로 조회하며 백그라운드 polling은 하지 않는다. 필터·월·선택
|
||||
대상은 URL에 남으므로 같은 권한으로 직접 열기/새로고침할 수 있다.
|
||||
|
||||
## 진입과 권한
|
||||
|
||||
1. Gateway의 사용자 관리에서 대상 관리자에게 정확한 프로필 scope를 부여한다.
|
||||
예: `admin.playAudit.read:che:default`, `admin.playAudit.read:hwe:default`.
|
||||
일반 `admin` 역할이나 게임 수뇌 직책만으로 이 권한을 대신하지 않는다.
|
||||
2. Gateway 관리자 서버 목록에서 해당 프로필의 **플레이 감사** 진입을 사용한다.
|
||||
기존 게임 session 발급을 재사용하며 장수가 없어도 접근할 수 있다.
|
||||
3. 같은 origin의 `/che/play-audit`, `/hwe/play-audit`로 이동한다. 직접 URL은 해당
|
||||
프로필의 유효한 session이 필요하다. token을 URL에 넣지 않는다.
|
||||
4. 외교/정책은 대상 국가·기간을 적용하고 사건을 눌러 상세를 읽는다. 멸망국은
|
||||
해당 월의 국가 목록에서 선택한다. 상세 실패는 목록을 유지한 채 다시 시도한다.
|
||||
|
||||
권한 취소, 다른 프로필 token, 게임 입장 제재는 backend에서 다시 검사한다.
|
||||
공통 계정 조사용 `admin.playAudit.accounts` scope는 준비되어 있으나 계정 조사 기능은
|
||||
아직 제공하지 않는다. 이 scope만으로 프로필 조회가 허용되지 않는다.
|
||||
|
||||
## DB migration과 적용 순서
|
||||
|
||||
정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push`나
|
||||
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은56개이며 다음 감사
|
||||
migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다.
|
||||
|
||||
| migration | 준비되는 저장소/제약 |
|
||||
| --- | --- |
|
||||
| `20260916010000_add_play_audit_month` | 월 표본과 국가·도시·장수 projection 4개 테이블 |
|
||||
| `20260916020000_add_log_entry_server_id` | 새 장수 로그의 불변 기수 identity |
|
||||
| `20260916030000_add_play_audit_policy` | 국가별 불변 정책 revision |
|
||||
| `20260916031000_add_play_audit_policy_schema_version` | 정책 payload 버전 |
|
||||
| `20260916040000_add_play_audit_initial` | 기수당 INITIAL 표본 1개 제약 |
|
||||
| `20260916050000_add_play_audit_diplomacy` | 방향·국가쌍·실행 순서 기반 외교 사건과 불변 원문 보호 |
|
||||
| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 |
|
||||
|
||||
운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정
|
||||
commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의
|
||||
동등한 migration 명령은 infra의 `pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game`이며,
|
||||
올바른 profile DB/schema를 환경 또는 기존 secret 경로로 전달해야 한다. credential을
|
||||
CLI/보고서에 출력하지 않는다. Gateway schema에는 이번 구현 때문에 새 테이블을 요구하지 않는다.
|
||||
`release-manifest.json`의 gameSchemaHead도 위 마지막 migration을 가리킨다. 여러 프로필은
|
||||
Gateway의 DB 보존 일괄 업데이트로 같은 고정 commit을 순차 적용할 수 있다. 각 프로필은
|
||||
자기 schema에 migration을 적용해야 하며 한 프로필의 성공을 전체 적용 성공으로 보지 않는다.
|
||||
Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway 구성요소를 업데이트한다.
|
||||
|
||||
적용 전 기존 backup/운영 보호 절차를 따르고, migration 후 API/engine/frontend를 같은
|
||||
버전으로 전환한다. 엔진은 clock 복구 후 조회 준비를 공개하기 전에 INITIAL·정책·관계·
|
||||
보유 문서의 기준을 같은 fenced transaction에 저장한다. 문서 원문은200건씩 읽어 hash만
|
||||
사건에 남긴다. 이 단계가 실패하면 정상 준비 상태로 숨기지 않는다. 원인을 해결하고
|
||||
재시작하면 전체 transaction 재시도가 가능하다. 기존 migration을 되돌리거나 수정하지 않는다. tick 확장 migration은 기존 감사 행의
|
||||
값과 hash를 보존하지만 열 형식 변경의 테이블 잠금/재작성 비용이 있다. 첫 감사 도입에서는
|
||||
앞 migration이 만든 빈 테이블에 적용되며, 시험판 감사 기록이 이미 많다면 기존 업데이트
|
||||
유지보수 구간에서 적용 시간을 확인한다. API의 tick은 정밀도 손실을 막기 위해 문자열로 반환한다.
|
||||
|
||||
확인은 프로필 감사 진입 → 현재 장수/도시 → 초기 표본 → 정책/외교 기준 → 다음 정상
|
||||
월 경계 후 월말 표본 순서로 한다. 조기 수집 구간의 정산 coverage가 부분일 수 있으므로
|
||||
화면의 0/null/미수집·부분 표시에 따라 해석한다. 감사 migration을 적용했다고 과거 NPC
|
||||
결정이나 자원 이동이 자동으로 채워지는 것은 아니다.
|
||||
|
||||
## 보존과 미완성 범위
|
||||
|
||||
- 현재 기수 자료만 제공한다. RESET이 새 serverId를 활성화하면 이전 기수 조회를
|
||||
즉시 차단하고 이전 감사 자료를200행 단위로 정리한다. 기존 연감/계정 원장은 별도다.
|
||||
- 통일 시 FINAL 표본은 정규 월말과 구분한다. **CANCELLED가 runtime을 중단한 프로필은
|
||||
현재 감사 API도 사용할 수 없다.** 취소 후 다음 초기화까지 읽는 수명주기는 후속 작업이다.
|
||||
- NPC의 개인/수뇌/유저 자동턴 상세 판정 trace는 아직 수집·조회하지 않는다.
|
||||
- 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진
|
||||
버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다.
|
||||
- 일부 국가 생성/소멸 사건은 actor/request가 null이다. 원인을 현재 주체로 추정하지 않는다.
|
||||
- 장수 이름 검색·자유 정렬·지도 탐색·모든 전투 지표/연결과 전체 COST gate는 남아 있다.
|
||||
- 격리 PostgreSQL/Redis 및 mock API를 쓰는 실제 Chromium 검증은 운영 HTTPS 검증과 다르다.
|
||||
|
||||
후속 NPC/행위/계정 저장소의 필드·순서·보존·인덱스 요구는 설계의 R5/조사 A~F와 비용
|
||||
표를 유지한다. 미구현 저장소를 현재 수집 중이라고 표시하지 않는다. 다음 작업은 해당
|
||||
writer와 rollback·정리 경계를 함께 추가하는 정식 migration으로 이어가야 한다.
|
||||
@@ -1090,7 +1090,7 @@ model PlayAuditMonth {
|
||||
year Int
|
||||
month Int
|
||||
kind String
|
||||
tick Int?
|
||||
tick BigInt?
|
||||
schemaVersion Int @default(1) @map("schema_version")
|
||||
settlementsComplete Boolean @map("settlements_complete")
|
||||
hash String
|
||||
@@ -1152,7 +1152,7 @@ model PlayAuditPolicy {
|
||||
source String
|
||||
year Int
|
||||
month Int
|
||||
tick Int?
|
||||
tick BigInt?
|
||||
requestId String? @map("request_id")
|
||||
inputSequence BigInt? @map("input_sequence")
|
||||
ordinal Int
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 1개월은 36,000,000 tick이다. 기존 INTEGER는 약60개월에 넘치므로
|
||||
-- 현재 world/input_event와 같은 BIGINT로 넓힌다. 기존 값과 hash는 유지한다.
|
||||
ALTER TABLE "play_audit_month" ALTER COLUMN "tick" TYPE BIGINT USING "tick"::BIGINT;
|
||||
ALTER TABLE "play_audit_policy" ALTER COLUMN "tick" TYPE BIGINT USING "tick"::BIGINT;
|
||||
@@ -2,6 +2,6 @@
|
||||
"formatVersion": 1,
|
||||
"controllerProtocol": 2,
|
||||
"gatewaySchemaHead": "20260825000000_add_bulk_release_batches",
|
||||
"gameSchemaHead": "20260907150000_wait_then_turn_recovery",
|
||||
"gameSchemaHead": "20260916060000_widen_play_audit_ticks",
|
||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user