diff --git a/app/game-api/src/router/playAudit/shared.ts b/app/game-api/src/router/playAudit/shared.ts index 95291d90..c9bf287b 100644 --- a/app/game-api/src/router/playAudit/shared.ts +++ b/app/game-api/src/router/playAudit/shared.ts @@ -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 = ( diff --git a/app/game-api/test/directMutationJournalInventory.test.ts b/app/game-api/test/directMutationJournalInventory.test.ts index c295a8a5..6142a40d 100644 --- a/app/game-api/test/directMutationJournalInventory.test.ts +++ b/app/game-api/test/directMutationJournalInventory.test.ts @@ -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); }); diff --git a/app/game-api/test/mutationEvidenceManifest.test.ts b/app/game-api/test/mutationEvidenceManifest.test.ts index b678614e..76ae4b16 100644 --- a/app/game-api/test/mutationEvidenceManifest.test.ts +++ b/app/game-api/test/mutationEvidenceManifest.test.ts @@ -55,7 +55,7 @@ const expectedOwnerCounts: Record = { '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()); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index f861274f..82fc95bf 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -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( diff --git a/app/game-engine/src/playAudit/diplomacy.ts b/app/game-engine/src/playAudit/diplomacy.ts index 33bc3449..77945627 100644 --- a/app/game-engine/src/playAudit/diplomacy.ts +++ b/app/game-engine/src/playAudit/diplomacy.ts @@ -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, + }); + } +}; diff --git a/app/game-engine/src/playAudit/persistence.ts b/app/game-engine/src/playAudit/persistence.ts index dfddd4a3..8a8948b5 100644 --- a/app/game-engine/src/playAudit/persistence.ts +++ b/app/game-engine/src/playAudit/persistence.ts @@ -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, }, diff --git a/app/game-engine/src/playAudit/policyPersistence.ts b/app/game-engine/src/playAudit/policyPersistence.ts index 2b6603ca..74e58c9f 100644 --- a/app/game-engine/src/playAudit/policyPersistence.ts +++ b/app/game-engine/src/playAudit/policyPersistence.ts @@ -8,6 +8,8 @@ export const persistAuditPolicies = async ( ): Promise => { 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 diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index c167f96a..8cde5334 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -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(); + 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) { diff --git a/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts b/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts index 5b0759ad..c201fa8d 100644 --- a/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts @@ -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(); } diff --git a/app/game-engine/test/playAuditCollection.test.ts b/app/game-engine/test/playAuditCollection.test.ts index 906492e9..9e469385 100644 --- a/app/game-engine/test/playAuditCollection.test.ts +++ b/app/game-engine/test/playAuditCollection.test.ts @@ -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([]); diff --git a/app/game-engine/test/playAuditPersistence.integration.test.ts b/app/game-engine/test/playAuditPersistence.integration.test.ts index f0e0eedd..e3231cec 100644 --- a/app/game-engine/test/playAuditPersistence.integration.test.ts +++ b/app/game-engine/test/playAuditPersistence.integration.test.ts @@ -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); }); diff --git a/app/game-engine/test/playAuditPolicyPersistence.integration.test.ts b/app/game-engine/test/playAuditPolicyPersistence.integration.test.ts index 0719208f..b167be39 100644 --- a/app/game-engine/test/playAuditPolicyPersistence.integration.test.ts +++ b/app/game-engine/test/playAuditPolicyPersistence.integration.test.ts @@ -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, diff --git a/app/game-engine/test/playAuditStartup.integration.test.ts b/app/game-engine/test/playAuditStartup.integration.test.ts index abaf32a2..ab3848c7 100644 --- a/app/game-engine/test/playAuditStartup.integration.test.ts +++ b/app/game-engine/test/playAuditStartup.integration.test.ts @@ -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 () => { diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index 289809e8..e5d7d40a 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -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 }[] = []; 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('미관측 / 없음'); + }); +} diff --git a/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue b/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue index ccb662e9..70f4cd28 100644 --- a/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue +++ b/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue @@ -23,6 +23,8 @@ let detailGeneration = 0; const selected = computed(() => (typeof route.query.event === 'string' ? route.query.event : null)); const labels: Record = { LETTER_BASELINE: '문서 최초 관측', + NATION_RELATION_CREATED: '신생국 관계 생성', + NATION_RELATION_REMOVED: '멸망국 관계 종료', RELATION_BASELINE: '관계 최초 관측', LETTER_PROPOSED: '문서 제안', LETTER_REPLACED: '문서 교체', diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 87d678aa..4507c078 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -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', }); }); diff --git a/docs/admin-console.md b/docs/admin-console.md index 11c986fc..bd451d4f 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -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`는 기존 관리자 조치 원장입니다. ## 화면 구성 diff --git a/docs/architecture/game-api-mutation-evidence.tsv b/docs/architecture/game-api-mutation-evidence.tsv index f25e2261..c0af205a 100644 --- a/docs/architecture/game-api-mutation-evidence.tsv +++ b/docs/architecture/game-api-mutation-evidence.tsv @@ -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 diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index d3e98407..1017b47b 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -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는 아직 실측하지 않았으며 아래는 현재 소스에서 확인한 연결 지점과 구현 경계다. diff --git a/docs/design/play-audit.md b/docs/design/play-audit.md index c1098942..78186c04 100644 --- a/docs/design/play-audit.md +++ b/docs/design/play-audit.md @@ -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에 남긴다. diff --git a/docs/play-audit-operations.md b/docs/play-audit-operations.md new file mode 100644 index 00000000..81ff3d9e --- /dev/null +++ b/docs/play-audit-operations.md @@ -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으로 이어가야 한다. diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index d6fe82af..1ef73253 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -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 diff --git a/packages/infra/prisma/migrations/20260916060000_widen_play_audit_ticks/migration.sql b/packages/infra/prisma/migrations/20260916060000_widen_play_audit_ticks/migration.sql new file mode 100644 index 00000000..d81425c8 --- /dev/null +++ b/packages/infra/prisma/migrations/20260916060000_widen_play_audit_ticks/migration.sql @@ -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; diff --git a/release-manifest.json b/release-manifest.json index 7ea76de1..6b46f712 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -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"] }