플레이 감사 국가 생멸과 장기 tick 저장을 보완하고 중간 전달 준비

This commit is contained in:
2026-09-16 07:26:05 +00:00
parent ae0dfd503f
commit 52cea882ab
24 changed files with 428 additions and 46 deletions
+2 -1
View File
@@ -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,
});
}
};
+3 -2
View File
@@ -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
+45 -13
View File
@@ -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 () => {
+36 -5
View File
@@ -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: '문서 교체',
+1 -1
View File
@@ -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',
});
});