feat(engine): finalize unification archives atomically

This commit is contained in:
2026-07-31 14:10:22 +00:00
parent 8ea8e6e26f
commit 02515303fd
20 changed files with 1516 additions and 843 deletions
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { TurnCommandEnv } from '@sammo-ts/logic';
import { LogCategory, LogScope, type TurnCommandEnv } from '@sammo-ts/logic';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js';
@@ -16,6 +16,8 @@ const generalIds = [991_201, 991_202];
const cityIds = [991_201, 991_202, 991_203, 991_204, 991_205, 991_206, 991_207];
const nationId = 991_201;
const yearbookProfile = 'monthly-boundary-pre-persistence';
const yearbookServerId = 'monthly-boundary-generation-20260731';
const archivedLogTexts = ['월경계 과거 정세', '월경계 과거 행동'];
integration('monthly pre-update persistence', () => {
let db: GamePrismaClient;
@@ -32,7 +34,8 @@ integration('monthly pre-update persistence', () => {
await db.city.deleteMany({ where: { id: { in: cityIds } } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-boundary-pre-persistence' } });
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
await db.yearbookHistory.deleteMany({ where: { profileName: { in: [yearbookProfile, yearbookServerId] } } });
await db.logEntry.deleteMany({ where: { text: { in: archivedLogTexts } } });
});
afterAll(async () => {
@@ -42,7 +45,8 @@ integration('monthly pre-update persistence', () => {
await db.city.deleteMany({ where: { id: { in: cityIds } } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-boundary-pre-persistence' } });
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
await db.yearbookHistory.deleteMany({ where: { profileName: { in: [yearbookProfile, yearbookServerId] } } });
await db.logEntry.deleteMany({ where: { text: { in: archivedLogTexts } } });
await closeDb?.();
});
@@ -63,6 +67,24 @@ integration('monthly pre-update persistence', () => {
},
},
});
await db.logEntry.createMany({
data: [
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
year: 200,
month: 12,
text: archivedLogTexts[0]!,
},
{
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
year: 200,
month: 12,
text: archivedLogTexts[1]!,
},
],
});
await db.city.createMany({
data: cityIds.map((id, index) => ({
id,
@@ -120,6 +142,7 @@ integration('monthly pre-update persistence', () => {
environment: { mapName: 'che', unitSet: 'che' },
},
meta: {
serverId: yearbookServerId,
develcost: 18,
scenarioMeta: {
title: 'pre persistence',
@@ -149,7 +172,6 @@ integration('monthly pre-update persistence', () => {
});
const nations = createNationTurnMonthlyHandler({ getWorld: () => world });
const yearbook = createYearbookHandler({
databaseUrl: databaseUrl!,
profileName: yearbookProfile,
getWorld: () => world,
});
@@ -157,7 +179,7 @@ integration('monthly pre-update persistence', () => {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler: composeCalendarHandlers(yearbook.handler, boundary, nations),
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName: yearbookProfile });
try {
await world.advanceMonth(new Date('0201-01-01T00:00:00.000Z'));
await hooks.hooks.flushChanges?.({
@@ -219,7 +241,7 @@ integration('monthly pre-update persistence', () => {
const yearbookRow = await db.yearbookHistory.findUniqueOrThrow({
where: {
profileName_year_month_sourceId: {
profileName: yearbookProfile,
profileName: yearbookServerId,
year: 200,
month: 12,
sourceId: 0,
@@ -233,9 +255,11 @@ integration('monthly pre-update persistence', () => {
])
);
expect(cityIds.map((id) => yearbookStates.get(id))).toEqual([31, 32, 33, 34, 41, 42, 43]);
expect(yearbookRow.globalHistory).toEqual([archivedLogTexts[0]]);
expect(yearbookRow.globalAction).toEqual([archivedLogTexts[1]]);
expect(await db.yearbookHistory.count({ where: { profileName: yearbookProfile } })).toBe(0);
} finally {
await hooks.close();
await yearbook.close();
}
});
});
+37 -3
View File
@@ -739,13 +739,27 @@ describeDb('scenario database seed', () => {
},
});
await prisma.emperor.create({ data: { serverId, name: marker, history: { marker }, aux: { marker } } });
await prisma.unificationFinalization.create({
data: {
generationKey: `unification:${serverId}`,
serverId,
profileName: marker,
winnerNation: 1,
year: 999,
month: 12,
completedAt: new Date('2033-01-01T00:00:00.000Z'),
},
});
await prisma.yearbookHistory.create({
data: {
profileName: marker,
year: 999,
month: 12,
map: {},
nations: {},
map: { marker },
nations: [{ marker }],
globalHistory: [`${marker}-history`],
globalAction: [`${marker}-action`],
hash: `${marker}-hash`,
},
});
await prisma.legacyGameStorage.create({
@@ -791,11 +805,30 @@ describeDb('scenario database seed', () => {
prisma.oldNation.count({ where: { serverId } }),
prisma.oldGeneral.count({ where: { serverId } }),
prisma.emperor.count({ where: { serverId } }),
prisma.unificationFinalization.count({ where: { serverId } }),
prisma.yearbookHistory.count({ where: { profileName: marker } }),
prisma.legacyGameStorage.count({ where: { namespace: marker } }),
prisma.hallOfFame.count({ where: { serverId } }),
])
).resolves.toEqual(Array.from({ length: 12 }, () => 1));
).resolves.toEqual(Array.from({ length: 13 }, () => 1));
await expect(
prisma.yearbookHistory.findUniqueOrThrow({
where: {
profileName_year_month_sourceId: {
profileName: marker,
year: 999,
month: 12,
sourceId: 0,
},
},
})
).resolves.toMatchObject({
map: { marker },
nations: [{ marker }],
globalHistory: [`${marker}-history`],
globalAction: [`${marker}-action`],
hash: `${marker}-hash`,
});
} finally {
await prisma.errorLog.deleteMany({ where: { category: marker } });
await prisma.inheritancePoint.deleteMany({ where: { userId: marker } });
@@ -805,6 +838,7 @@ describeDb('scenario database seed', () => {
await prisma.oldNation.deleteMany({ where: { serverId } });
await prisma.oldGeneral.deleteMany({ where: { serverId } });
await prisma.emperor.deleteMany({ where: { serverId } });
await prisma.unificationFinalization.deleteMany({ where: { serverId } });
await prisma.gameHistory.deleteMany({ where: { serverId } });
await prisma.yearbookHistory.deleteMany({ where: { profileName: marker } });
await prisma.legacyGameStorage.deleteMany({ where: { namespace: marker } });
+92
View File
@@ -161,4 +161,96 @@ describe('InMemoryTurnProcessor ordering', () => {
expect(world.getNextGeneralId()).toBe(5);
expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 });
});
it('stops catch-up immediately after a calendar handler finalizes unification', async () => {
const baseTime = new Date('0189-01-01T00:00:00Z');
const snapshot = {
generals: [],
cities: [
{
id: 1,
name: 'City_1',
nationId: 1,
level: 1,
population: 1,
populationMax: 1,
agriculture: 1,
agricultureMax: 1,
commerce: 1,
commerceMax: 1,
security: 1,
securityMax: 1,
defence: 1,
defenceMax: 1,
wall: 1,
wallMax: 1,
supplyState: 1,
frontState: 0,
state: 0,
meta: {},
},
],
nations: [
{
id: 1,
name: 'TestNation',
color: '#FF0000',
capitalCityId: 1,
chiefGeneralId: null,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: {
id: 'test_map',
name: 'TestMap',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test_map', unitSet: 'default' },
},
} as TurnWorldSnapshot;
const worldHolder: { current?: InMemoryTurnWorld } = {};
const world = new InMemoryTurnWorld(
{
id: 1,
currentYear: 189,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: baseTime,
meta: {},
},
snapshot,
{
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler: {
onMonthChanged: (): void => worldHolder.current?.updateWorldMeta({ isUnited: 2 }),
},
}
);
worldHolder.current = world;
const processor = new InMemoryTurnProcessor(world, { tickMinutes: 10 });
const result = await processor.run(addMinutes(baseTime, 50), {
budgetMs: 1_000,
maxGenerals: 10,
catchUpCap: 10,
});
expect(result.processedTurns).toBe(1);
expect(world.getState()).toMatchObject({ currentYear: 189, currentMonth: 2, meta: { isUnited: 2 } });
});
});
@@ -0,0 +1,240 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createUnificationHandler } from '../src/turn/unificationHandler.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const fixtureId = 992_001;
const serverId = 'che_unification_atomicity_fixture';
const profileName = 'che';
const userId = 'unification-atomicity-user';
integration('unification finalization transaction', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
const cleanup = async (): Promise<void> => {
await db.unificationFinalization.deleteMany({ where: { serverId } });
await db.yearbookHistory.deleteMany({ where: { profileName: serverId } });
await db.emperor.deleteMany({ where: { serverId } });
await db.oldGeneral.deleteMany({ where: { serverId } });
await db.oldNation.deleteMany({ where: { serverId } });
await db.hallOfFame.deleteMany({ where: { serverId } });
await db.inheritanceResult.deleteMany({ where: { serverId } });
await db.inheritanceLog.deleteMany({ where: { userId } });
await db.inheritancePoint.deleteMany({ where: { userId } });
await db.gameHistory.deleteMany({ where: { serverId } });
await db.logEntry.deleteMany({ where: { year: 190, month: 7 } });
await db.rankData.deleteMany({ where: { generalId: fixtureId } });
await db.general.deleteMany({ where: { id: fixtureId } });
await db.city.deleteMany({ where: { id: fixtureId } });
await db.nation.deleteMany({ where: { id: fixtureId } });
await db.worldState.deleteMany({ where: { scenarioCode: 'unification-atomicity-fixture' } });
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await cleanup();
});
afterAll(async () => {
await cleanup();
await closeDb?.();
});
it('rolls every archive back on a late failure and applies it exactly once on retry', async () => {
await db.nation.create({
data: {
id: fixtureId,
name: '원자통일국',
color: '#ffffff',
capitalCityId: fixtureId,
chiefGeneralId: fixtureId,
gold: 1_000,
rice: 2_000,
tech: 123,
level: 1,
typeCode: 'che_중립',
meta: {
power: 3_000,
max_power: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] },
},
},
});
await db.city.create({
data: {
id: fixtureId,
name: '원자도시',
nationId: fixtureId,
level: 1,
population: 1_000,
populationMax: 2_000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
supplyState: 1,
frontState: 0,
region: 1,
meta: { state: 0 },
},
});
await db.general.create({
data: {
id: fixtureId,
userId,
name: '원자장수',
nationId: fixtureId,
cityId: fixtureId,
npcState: 0,
officerLevel: 12,
leadership: 80,
strength: 70,
intel: 60,
experience: 10,
dedication: 5,
age: 40,
crew: 400,
picture: '1.png',
turnTime: new Date('0190-07-01T00:00:00.000Z'),
meta: {
ownerName: '원자 사용자',
killturn: 24,
inherit_lived_month: 10,
max_domestic_critical: 20,
inherit_active_action: 3,
rank_warnum: 4,
firenum: 2,
dex1: 100,
},
},
});
await db.inheritancePoint.createMany({
data: [
{ userId, key: 'previous', value: 100 },
{ userId, key: 'unifier', value: 7 },
],
});
const worldRow = await db.worldState.create({
data: {
scenarioCode: 'unification-atomicity-fixture',
currentYear: 190,
currentMonth: 6,
tickSeconds: 600,
config: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: { minPushHallAge: 30 },
environment: { mapName: 'che', unitSet: 'che' },
},
meta: {
serverId,
serverName: '원자 서버',
season: 1,
scenarioId: 2,
refreshLimit: 2,
scenarioMeta: {
title: '원자성 시나리오',
startYear: 190,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
},
},
});
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
let world: InMemoryTurnWorld | null = null;
const unification = createUnificationHandler({ profileName, getWorld: () => world });
world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler: unification.handler,
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName });
const runResult = {
lastTurnTime: '0190-07-01T00:00:00.000Z',
processedGenerals: 0,
processedTurns: 1,
durationMs: 0,
partial: false,
};
try {
await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z'));
expect(world.getState().meta).toMatchObject({ isUnited: 2, isunited: 2, refreshLimit: 200 });
expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(1);
expect(world.peekDirtyState().pendingYearbookSnapshots).toHaveLength(1);
await expect(hooks.hooks.flushChanges?.(runResult)).rejects.toThrow();
expect(await db.unificationFinalization.count({ where: { serverId } })).toBe(0);
expect(await db.yearbookHistory.count({ where: { profileName: serverId } })).toBe(0);
expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(0);
expect(await db.oldGeneral.count({ where: { serverId } })).toBe(0);
expect(await db.oldNation.count({ where: { serverId } })).toBe(0);
expect(await db.emperor.count({ where: { serverId } })).toBe(0);
expect(
(await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } }))
.value
).toBe(100);
expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(1);
await db.gameHistory.create({
data: {
serverId,
date: new Date('0190-01-01T00:00:00.000Z'),
season: 1,
scenario: 2,
scenarioName: '원자성 시나리오',
},
});
await hooks.hooks.flushChanges?.(runResult);
expect(await db.unificationFinalization.count({ where: { serverId } })).toBe(1);
expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(1);
expect(await db.oldGeneral.count({ where: { serverId } })).toBe(1);
expect(await db.oldNation.count({ where: { serverId } })).toBe(2);
expect(await db.emperor.count({ where: { serverId } })).toBe(1);
expect((await db.gameHistory.findUniqueOrThrow({ where: { serverId } })).winnerNation).toBe(fixtureId);
const yearbook = await db.yearbookHistory.findUniqueOrThrow({
where: {
profileName_year_month_sourceId: {
profileName: serverId,
year: 190,
month: 7,
sourceId: 0,
},
},
});
expect(yearbook.globalHistory).toEqual(expect.arrayContaining([expect.stringContaining('【통일】')]));
expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(0);
await hooks.hooks.flushChanges?.(runResult);
expect(await db.unificationFinalization.count({ where: { serverId } })).toBe(1);
expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(1);
expect(await db.emperor.count({ where: { serverId } })).toBe(1);
expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
currentYear: 190,
currentMonth: 7,
});
} finally {
await hooks.close();
}
});
});
@@ -0,0 +1,236 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrisma } from '@sammo-ts/infra';
import type { City, Nation } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { persistUnificationFinalization } from '../src/turn/unificationPersistence.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const buildWorld = (): InMemoryTurnWorld => {
const turnTime = new Date('0190-07-01T00:00:00.000Z');
const general: TurnGeneral = {
id: 1,
userId: 'user-1',
name: '통일장수',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 80, strength: 70, intelligence: 60 },
turnTime,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {
killturn: 24,
owner_name: '표시 이름',
inherit_lived_month: 10,
max_domestic_critical: 20,
inherit_active_action: 3,
rank_warnum: 4,
firenum: 2,
dex1: 100,
},
officerLevel: 12,
experience: 10,
dedication: 5,
injury: 0,
gold: 100,
rice: 100,
crew: 100,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 40,
npcState: 0,
picture: '1.png',
imageServer: 0,
};
const nation: Nation = {
id: 1,
name: '통일국',
color: '#ffffff',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 1000,
rice: 2000,
power: 3000,
level: 1,
typeCode: 'test',
meta: {},
};
const city: City = {
id: 1,
name: '통일도시',
nationId: 1,
level: 1,
state: 0,
population: 1000,
populationMax: 2000,
agriculture: 0,
agricultureMax: 0,
commerce: 0,
commerceMax: 0,
security: 0,
securityMax: 0,
supplyState: 1,
frontState: 0,
defence: 0,
defenceMax: 0,
wall: 0,
wallMax: 0,
meta: {},
};
const state: TurnWorldState = {
id: 1,
currentYear: 190,
currentMonth: 7,
tickSeconds: 600,
lastTurnTime: turnTime,
meta: {
killturn: 24,
serverId: 'server-1',
serverName: '테스트',
season: 1,
scenarioId: 2,
scenarioMeta: { title: '테스트 시나리오' },
},
};
const snapshot: TurnWorldSnapshot = {
generals: [general],
cities: [city],
nations: [nation],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: { minPushHallAge: 30 },
environment: { mapName: 'test', unitSet: 'test' },
},
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
};
return new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
};
const input = {
generationKey: 'unification:server-1',
serverId: 'server-1',
profileName: 'che',
winnerNationId: 1,
year: 190,
month: 7,
completedAt: new Date('0190-07-01T00:00:00.000Z'),
} as const;
describe('persistUnificationFinalization', () => {
it('does not write when the transaction-scoped generation was already applied', async () => {
const transaction = Object.assign({} as GamePrisma.TransactionClient, {
$executeRaw: vi.fn().mockResolvedValue(1),
unificationFinalization: {
findUnique: vi.fn().mockResolvedValue({
generationKey: input.generationKey,
serverId: input.serverId,
profileName: input.profileName,
winnerNation: input.winnerNationId,
year: input.year,
month: input.month,
completedAt: input.completedAt,
}),
create: vi.fn(),
},
});
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
status: 'ALREADY_APPLIED',
generationKey: input.generationKey,
});
expect(transaction.unificationFinalization.create).not.toHaveBeenCalled();
});
it('uses one supplied transaction for absolute inheritance and archive writes', async () => {
const inheritanceUpsert = vi.fn().mockResolvedValue({});
const inheritanceResultCreate = vi.fn().mockResolvedValue({});
const inheritanceLogCreate = vi.fn().mockResolvedValue({});
const hallCreate = vi.fn().mockResolvedValue({});
const gameHistoryUpdate = vi.fn().mockResolvedValue({});
const emperorCreate = vi.fn().mockResolvedValue({});
const transaction = Object.assign({} as GamePrisma.TransactionClient, {
$executeRaw: vi.fn().mockResolvedValue(1),
unificationFinalization: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
},
inheritancePoint: {
findMany: vi.fn().mockResolvedValue([
{ userId: 'user-1', key: 'previous', value: 100 },
{ userId: 'user-1', key: 'unifier', value: 7 },
]),
upsert: inheritanceUpsert,
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
},
inheritanceResult: { create: inheritanceResultCreate },
inheritanceLog: { create: inheritanceLogCreate },
rankData: { findMany: vi.fn().mockResolvedValue([]) },
gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate },
hallOfFame: {
findFirst: vi.fn().mockResolvedValue(null),
create: hallCreate,
update: vi.fn().mockResolvedValue({}),
},
logEntry: { findMany: vi.fn().mockResolvedValue([]) },
oldNation: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
},
oldGeneral: { upsert: vi.fn().mockResolvedValue({}) },
emperor: { create: emperorCreate },
});
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
status: 'APPLIED',
generationKey: input.generationKey,
});
expect(inheritanceUpsert).toHaveBeenCalledWith(
expect.objectContaining({
update: { value: 2206 },
create: { userId: 'user-1', key: 'previous', value: 2206 },
})
);
expect(inheritanceResultCreate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ serverId: 'server-1' }) })
);
expect(inheritanceLogCreate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ serverId: 'server-1' }) })
);
expect(hallCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
aux: expect.objectContaining({ ownerDisplayName: '표시 이름', fgColor: '#000000' }),
}),
})
);
expect(gameHistoryUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) })
);
expect(emperorCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ aux: { winnerNationId: 1, generationKey: input.generationKey } }),
})
);
});
});