merge: finalize unification archives atomically

This commit is contained in:
2026-07-31 14:11:19 +00:00
25 changed files with 1844 additions and 900 deletions
+58 -36
View File
@@ -63,6 +63,27 @@ const parseYearbookNations = (value: unknown): YearbookNation[] => {
return output;
};
const resolveArchiveTarget = (
worldMeta: unknown,
profileName: string,
requestedServerId?: string
): { archiveKey: string; legacyAlias: string | null; isCurrentProfile: boolean } => {
const rawServerId = asRecord(worldMeta).serverId;
const canonicalServerId = typeof rawServerId === 'string' && rawServerId.trim() ? rawServerId.trim() : profileName;
const requested = requestedServerId ?? profileName;
const isCurrentProfile = requested === profileName || requested === canonicalServerId;
return {
archiveKey: isCurrentProfile ? canonicalServerId : requested,
legacyAlias: isCurrentProfile && canonicalServerId !== profileName ? profileName : null,
isCurrentProfile,
};
};
const normalizeArchivedLogs = (value: unknown, month: number): string[] => {
const logs = parseTextArray(value);
return logs.length ? logs : [`<C>●</>${month}월: 기록 없음`];
};
const buildNationSnapshot = async (ctx: GameApiContext) => {
const [nationRows, cityRows, generalRows] = await Promise.all([
ctx.db.nation.findMany({
@@ -220,22 +241,27 @@ export const yearbookRouter = router({
message: 'World state is not initialized.',
});
}
const targetProfileName = input?.serverID ?? ctx.profile.name;
const isCurrentProfile = targetProfileName === ctx.profile.name;
const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input?.serverID);
const firstRow = await ctx.db.yearbookHistory.findFirst({
where: { profileName: targetProfileName },
select: { year: true, month: true },
orderBy: [{ year: 'asc' }, { month: 'asc' }],
});
const findRange = async (profileName: string) =>
Promise.all([
ctx.db.yearbookHistory.findFirst({
where: { profileName },
select: { year: true, month: true },
orderBy: [{ year: 'asc' as const }, { month: 'asc' as const }],
}),
ctx.db.yearbookHistory.findFirst({
where: { profileName },
select: { year: true, month: true },
orderBy: [{ year: 'desc' as const }, { month: 'desc' as const }],
}),
]);
let [firstRow, lastRow] = await findRange(target.archiveKey);
if ((!firstRow || !lastRow) && target.legacyAlias) {
[firstRow, lastRow] = await findRange(target.legacyAlias);
}
const lastRow = await ctx.db.yearbookHistory.findFirst({
where: { profileName: targetProfileName },
select: { year: true, month: true },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
});
if (!isCurrentProfile && (!firstRow || !lastRow)) {
if (!target.isCurrentProfile && (!firstRow || !lastRow)) {
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 범위를 찾을 수 없습니다.' });
}
@@ -243,7 +269,7 @@ export const yearbookRouter = router({
const fallbackYearMonth = currentYearMonth - 1;
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
const selectedYearMonth = isCurrentProfile ? currentYearMonth : lastYearMonth;
const selectedYearMonth = target.isCurrentProfile ? currentYearMonth : lastYearMonth;
return {
firstYearMonth,
@@ -266,15 +292,16 @@ export const yearbookRouter = router({
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const targetProfileName = input.serverID ?? ctx.profile.name;
const isCurrentProfile = targetProfileName === ctx.profile.name;
const shouldRecordAfterHashCheck = isCurrentProfile && Boolean(input.hash);
if (isCurrentProfile && !shouldRecordAfterHashCheck) {
const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input.serverID);
const shouldRecordAfterHashCheck = target.isCurrentProfile && Boolean(input.hash);
if (target.isCurrentProfile && !shouldRecordAfterHashCheck) {
await recordHistoryAccess(ctx);
}
const isCurrent =
isCurrentProfile && worldState.currentYear === input.year && worldState.currentMonth === input.month;
target.isCurrentProfile &&
worldState.currentYear === input.year &&
worldState.currentMonth === input.month;
if (isCurrent) {
const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month);
@@ -301,28 +328,23 @@ export const yearbookRouter = router({
return { notModified: false, hash, data };
}
const row = await ctx.db.yearbookHistory.findFirst({
where: {
profileName: targetProfileName,
year: input.year,
month: input.month,
},
orderBy: [{ sourceId: 'desc' }, { id: 'desc' }],
});
const findArchivedRow = (profileName: string) =>
ctx.db.yearbookHistory.findFirst({
where: { profileName, year: input.year, month: input.month },
orderBy: [{ sourceId: 'desc' as const }, { id: 'desc' as const }],
});
let row = await findArchivedRow(target.archiveKey);
if (!row && target.legacyAlias) {
row = await findArchivedRow(target.legacyAlias);
}
if (!row) {
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 데이터를 찾을 수 없습니다.' });
}
const map = asRecord(row.map) as BaseMapResult;
const nations = parseYearbookNations(row.nations);
const archivedLogs =
isCurrentProfile && row.sourceId === 0
? await buildLogs(ctx, input.year, input.month)
: {
globalHistory: parseTextArray(row.globalHistory),
globalAction: parseTextArray(row.globalAction),
};
const { globalHistory, globalAction } = archivedLogs;
const globalHistory = normalizeArchivedLogs(row.globalHistory, input.month);
const globalAction = normalizeArchivedLogs(row.globalAction, input.month);
const data = {
year: input.year,
month: input.month,
@@ -17,6 +17,7 @@ const profile: GameProfile = {
};
const archiveServerId = 'hwe_260725_archive';
const currentServerId = 'che_260731_runtime';
const archiveRows = [
{
id: 1,
@@ -44,6 +45,19 @@ const archiveRows = [
hash: 'archive-2',
createdAt: new Date('2026-07-25T01:00:00.000Z'),
},
{
id: 3,
profileName: currentServerId,
sourceId: 0,
year: 219,
month: 12,
map: { year: 219, month: 12, startYear: 190, cityList: [], nationList: [] },
nations: [{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, cities: ['낙양'] }],
globalHistory: ['저장된 현재 기수 과거 기록'],
globalAction: ['저장된 현재 기수 과거 행동'],
hash: 'current-archive',
createdAt: new Date('2026-07-31T00:00:00.000Z'),
},
];
const authFor = (userId: string): GameSessionTokenPayload => ({
@@ -68,7 +82,7 @@ const buildContext = (auth: GameSessionTokenPayload | null, options: { hasGenera
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
},
worldState: {
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
findFirst: async () => ({ currentYear: 220, currentMonth: 1, meta: { serverId: currentServerId } }),
},
yearbookHistory: {
findFirst: async (args: {
@@ -165,4 +179,40 @@ describe('historical yearbook access from dynasty', () => {
message: '연감 범위를 찾을 수 없습니다.',
});
});
it('treats the canonical server ID and profile alias as the same live generation', async () => {
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
const [canonical, alias, omitted] = await Promise.all([
caller.yearbook.getRange({ serverID: currentServerId }),
caller.yearbook.getRange({ serverID: profile.name }),
caller.yearbook.getRange(),
]);
expect(canonical).toEqual(alias);
expect(alias).toEqual(omitted);
expect(canonical).toEqual({
firstYearMonth: 219 * 12 + 11,
lastYearMonth: 219 * 12 + 11,
currentYearMonth: 220 * 12,
});
});
it('uses stored logs for a past month of the current generation', async () => {
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
const result = await caller.yearbook.getHistory({
serverID: currentServerId,
year: 219,
month: 12,
});
expect(result).toMatchObject({
notModified: false,
data: {
globalHistory: ['저장된 현재 기수 과거 기록'],
globalAction: ['저장된 현재 기수 과거 행동'],
},
});
});
});
@@ -315,9 +315,7 @@ export class TurnDaemonLifecycle {
await this.commandResponder.publishCommandError(command.requestId, error);
} catch (reportError) {
const reportMessage =
reportError instanceof Error
? reportError.message
: 'Unknown command failure reporting error.';
reportError instanceof Error ? reportError.message : 'Unknown command failure reporting error.';
this.status.lastError = `${this.status.lastError} (failure report: ${reportMessage})`;
}
}
@@ -347,36 +345,31 @@ export class TurnDaemonLifecycle {
const budget = pending.budget ?? this.options.defaultBudget;
const checkpoint = this.status.checkpoint;
let result: TurnRunResult;
let fallbackError = 'Unknown turn daemon error.';
try {
const runProcessor = () => this.processor.run(targetTime, budget, checkpoint);
result = this.stateManager ? await this.stateManager.transaction(runProcessor) : await runProcessor();
const runAndFlush = async (): Promise<TurnRunResult> => {
const nextResult = await this.processor.run(targetTime, budget, checkpoint);
fallbackError = 'Unknown turn flush error.';
this.status.state = 'flushing';
await this.stateStore.saveLastTurnTime(new Date(nextResult.lastTurnTime));
await this.stateStore.saveCheckpoint(nextResult.checkpoint);
await this.hooks?.flushChanges?.(nextResult);
return nextResult;
};
result = this.stateManager ? await this.stateManager.transaction(runAndFlush) : await runAndFlush();
} catch (error) {
this.status.running = false;
this.status.state = 'paused';
this.status.paused = true;
this.errorPaused = true;
this.status.lastError = error instanceof Error ? error.message : 'Unknown turn daemon error.';
this.status.lastError = error instanceof Error ? error.message : fallbackError;
await this.hooks?.onRunError?.(error);
return;
} finally {
this.status.running = false;
}
this.status.state = 'flushing';
try {
await this.stateStore.saveLastTurnTime(new Date(result.lastTurnTime));
await this.stateStore.saveCheckpoint(result.checkpoint);
await this.hooks?.flushChanges?.(result);
} catch (error) {
this.status.state = 'paused';
this.status.paused = true;
this.errorPaused = true;
this.status.lastError = error instanceof Error ? error.message : 'Unknown turn flush error.';
await this.hooks?.onRunError?.(error);
return;
}
await this.applyRunResult(result, startMs);
this.status.state = 'idle';
try {
+17
View File
@@ -34,6 +34,8 @@ import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLea
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
import { buildPersistedRankRows } from './rankData.js';
import { persistUnificationFinalization } from './unificationPersistence.js';
import { persistYearbookSnapshot } from './yearbookPersistence.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -473,6 +475,7 @@ const buildNationUpdate = (
chiefGeneralId: nation.chiefGeneralId,
gold: nation.gold,
rice: nation.rice,
tech: typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) ? Math.trunc(nation.meta.tech) : 0,
level: nation.level,
typeCode: nation.typeCode,
meta: asJson({
@@ -546,6 +549,7 @@ export const createDatabaseTurnHooks = async (
databaseUrl: string,
world: InMemoryTurnWorld,
options?: {
profileName?: string;
reservedTurns?: InMemoryReservedTurnStore;
turnDaemonLease?: DatabaseTurnDaemonLease;
}
@@ -584,6 +588,8 @@ export const createDatabaseTurnHooks = async (
inheritancePointAdjustments,
pendingNationBettingOpens,
pendingNationBettingFinishes,
pendingYearbookSnapshots,
pendingUnificationFinalizations,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -952,6 +958,17 @@ export const createDatabaseTurnHooks = async (
});
}
}
for (const snapshot of pendingYearbookSnapshots) {
await persistYearbookSnapshot(prisma, snapshot);
}
for (const finalization of pendingUnificationFinalizations) {
if (options?.profileName && finalization.profileName !== options.profileName) {
throw new Error(
`Unification profile mismatch: pending=${finalization.profileName}, daemon=${options.profileName}.`
);
}
await persistUnificationFinalization(prisma, finalization, world);
}
for (const message of messages) {
await sendMessage(
{
@@ -1,4 +1,4 @@
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
@@ -205,13 +205,23 @@ const settleHall = async (
name: event.before.name,
nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? '#000000',
fgColor: nation?.color ?? '#000000',
fgColor: resolveLegacyTextColor(nation?.color ?? '#000000'),
startTime: typeof worldMeta.starttime === 'string' ? worldMeta.starttime : null,
unitedTime: new Date().toISOString(),
ownerName: event.before.userId ?? null,
ownerDisplayName:
typeof asRecord(event.before.meta).ownerDisplayName === 'string'
? asRecord(event.before.meta).ownerDisplayName
: typeof asRecord(event.before.meta).owner_name === 'string'
? asRecord(event.before.meta).owner_name
: typeof asRecord(event.before.meta).ownerName === 'string'
? asRecord(event.before.meta).ownerName
: null,
picture: event.before.picture ?? null,
imgsvr: event.before.imageServer ?? 0,
serverID: serverId,
serverIdx: historyCount,
scenarioName,
serverName: typeof worldMeta.serverName === 'string' ? worldMeta.serverName : '',
};
for (const type of HALL_OF_FAME_TYPES) {
@@ -2,6 +2,7 @@ import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
import { asNumber, asRecord } from '@sammo-ts/common';
export interface InMemoryTurnProcessorOptions {
tickMinutes?: number;
@@ -24,6 +25,11 @@ const resolveTickMinutes = (world: InMemoryTurnWorld, override?: number): number
return Math.max(1, Math.round(tickSeconds / 60));
};
const isWorldUnited = (world: InMemoryTurnWorld): boolean => {
const meta = asRecord(world.getState().meta);
return asNumber(meta.isunited ?? meta.isUnited, 0) !== 0;
};
export class InMemoryTurnProcessor implements TurnProcessor {
// 인메모리 월드로 턴을 실행하고 월/연 갱신까지 처리한다.
private readonly world: InMemoryTurnWorld;
@@ -91,13 +97,16 @@ export class InMemoryTurnProcessor implements TurnProcessor {
if (!partial) {
let nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes);
while (nextTickTime.getTime() <= targetTime.getTime()) {
while (!isWorldUnited(this.world) && nextTickTime.getTime() <= targetTime.getTime()) {
if (processedTurns >= budget.catchUpCap || isBudgetExpired()) {
partial = true;
break;
}
await this.world.advanceMonth(nextTickTime);
processedTurns += 1;
if (isWorldUnited(this.world)) {
break;
}
nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes);
}
}
+26
View File
@@ -15,6 +15,8 @@ import type {
PendingNeutralAuction,
PendingNationBettingFinish,
PendingNationBettingOpen,
PendingUnificationFinalization,
PendingYearbookSnapshot,
TurnDiplomacy,
TurnEvent,
TurnGeneral,
@@ -126,6 +128,8 @@ export interface TurnWorldChanges {
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
pendingUnificationFinalizations: PendingUnificationFinalization[];
}
export interface InMemoryTurnWorldStateSnapshot {
@@ -160,6 +164,8 @@ export interface InMemoryTurnWorldStateSnapshot {
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
pendingUnificationFinalizations: PendingUnificationFinalization[];
}
export interface InMemoryTurnWorldInspection {
@@ -346,6 +352,8 @@ export class InMemoryTurnWorld {
private readonly inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }> = [];
private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = [];
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
private readonly scenarioConfig: ScenarioConfig;
private readonly unitSet?: UnitSetDefinition;
private checkpoint?: TurnCheckpoint;
@@ -425,6 +433,8 @@ export class InMemoryTurnWorld {
inheritancePointAdjustments: this.inheritancePointAdjustments,
pendingNationBettingOpens: this.pendingNationBettingOpens,
pendingNationBettingFinishes: this.pendingNationBettingFinishes,
pendingYearbookSnapshots: this.pendingYearbookSnapshots,
pendingUnificationFinalizations: this.pendingUnificationFinalizations,
} satisfies InMemoryTurnWorldStateSnapshot);
}
@@ -461,6 +471,8 @@ export class InMemoryTurnWorld {
this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments);
this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens);
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations);
}
inspectState(): InMemoryTurnWorldInspection {
@@ -554,6 +566,14 @@ export class InMemoryTurnWorld {
});
}
queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void {
this.pendingYearbookSnapshots.push(structuredClone(snapshot));
}
queueUnificationFinalization(finalization: PendingUnificationFinalization): void {
this.pendingUnificationFinalizations.push(structuredClone(finalization));
}
getScenarioConfig(): ScenarioConfig {
return this.scenarioConfig;
}
@@ -1192,6 +1212,8 @@ export class InMemoryTurnWorld {
winnerNationIds: [...entry.winnerNationIds],
turnTime: new Date(entry.turnTime.getTime()),
}));
const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots);
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
return {
generals,
@@ -1216,6 +1238,8 @@ export class InMemoryTurnWorld {
inheritancePointAdjustments,
pendingNationBettingOpens,
pendingNationBettingFinishes,
pendingYearbookSnapshots,
pendingUnificationFinalizations,
};
}
@@ -1246,6 +1270,8 @@ export class InMemoryTurnWorld {
this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length);
this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length);
this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length);
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length);
this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length);
}
consumeDirtyState(): TurnWorldChanges {
+1 -6
View File
@@ -227,7 +227,6 @@ const createTurnDaemonRuntimeWithLease = async (
const unification = options.calendarHandler
? null
: createUnificationHandler({
databaseUrl: options.databaseUrl,
profileName: options.profileName ?? options.profile,
getWorld: () => worldRef,
});
@@ -459,7 +458,6 @@ const createTurnDaemonRuntimeWithLease = async (
},
});
const yearbookHandler = createYearbookHandler({
databaseUrl: options.databaseUrl,
profileName: options.profileName ?? options.profile,
getWorld: () => worldRef,
});
@@ -591,6 +589,7 @@ const createTurnDaemonRuntimeWithLease = async (
}
if (databaseFlushEnabled) {
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
profileName: options.profileName ?? options.profile,
reservedTurns: reservedTurnStoreHandle?.store,
turnDaemonLease: turnDaemonLease ?? undefined,
});
@@ -711,10 +710,6 @@ const createTurnDaemonRuntimeWithLease = async (
close = async () => {
await baseClose();
await neutralAuctionRegistrar.close();
if (unification) {
await unification.close();
}
await yearbookHandler.close();
if (redisConnector) {
await redisConnector.disconnect();
}
+19
View File
@@ -101,6 +101,25 @@ export interface PendingNationBettingFinish {
turnTime: Date;
}
export interface PendingYearbookSnapshot {
serverId: string;
sourceId: number;
year: number;
month: number;
map: unknown;
nations: unknown;
}
export interface PendingUnificationFinalization {
generationKey: string;
serverId: string;
profileName: string;
winnerNationId: number;
year: number;
month: number;
completedAt: Date;
}
export interface TurnWorldSnapshot extends Omit<
WorldSnapshot,
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
+61 -772
View File
@@ -1,794 +1,83 @@
import { createGamePostgresConnector } from '@sammo-ts/infra';
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
import type { LogEntryDraft } from '@sammo-ts/logic';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common';
import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '@sammo-ts/logic';
import type { TurnCalendarHandler } from './inMemoryWorld.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
const UNIFIER_POINT = 2000;
const readMetaNumber = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
}
return value;
};
const readMetaNumberOrNull = (meta: Record<string, unknown>, key: string): number | null => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.floor(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.floor(parsed);
}
}
return null;
};
const computeHallRate = (numerator: number, denominator: number): number => {
if (denominator <= 0) {
return 0;
}
return numerator / denominator;
};
const computeDexPoint = (meta: Record<string, unknown>): number => {
let total = 0;
for (const [key, value] of Object.entries(meta)) {
if (!key.startsWith('dex')) {
continue;
}
if (typeof value === 'number' && Number.isFinite(value)) {
total += value;
}
}
return total * 0.001;
};
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
import { queueYearbookSnapshot } from './yearbookHandler.js';
const buildUnificationLog = (nationName: string): LogEntryDraft => ({
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
text: `<C>●</><Y><b>【통일】</b></><D><b>${nationName}</b></> 전토를 통일하였습니다.`,
text: `<C>●</><Y><b>【통일】</b></><D><b>${nationName}</b></>${JosaUtil.pick(nationName, '이')} 전토를 통일하였습니다.`,
meta: {},
});
const buildNationHistoryLog = (nationId: number, nationName: string): LogEntryDraft => ({
scope: LogScope.NATION,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
nationId,
text: `<D><b>${nationName}</b></>${JosaUtil.pick(nationName, '이')} 전토를 통일`,
meta: {},
});
const buildGeneralActionLog = (generalId: number, nationId: number, nationName: string): LogEntryDraft => ({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.YEAR_MONTH,
generalId,
nationId,
text: `<D><b>${nationName}</b></>${JosaUtil.pick(nationName, '이')} 전토를 통일하였습니다.`,
meta: {},
});
const resolveServerId = (world: InMemoryTurnWorld, fallback: string): string => {
const serverId = world.getState().meta.serverId;
return typeof serverId === 'string' && serverId.trim() ? serverId.trim() : fallback;
};
export const createUnificationHandler = (options: {
databaseUrl: string;
profileName: string;
getWorld: () => InMemoryTurnWorld | null;
}): { handler: TurnCalendarHandler; close: () => Promise<void> } => {
const connector = createGamePostgresConnector({ url: options.databaseUrl });
const ready = connector.connect();
const settleInheritance = async (winnerNationId: number, year: number, month: number): Promise<void> => {
await ready;
const prisma = connector.prisma;
const generals = await prisma.general.findMany({
where: {
userId: { not: null },
npcState: { lt: 2 },
},
select: {
id: true,
userId: true,
nationId: true,
officerLevel: true,
meta: true,
},
});
const userIds = Array.from(new Set(generals.map((general) => general.userId).filter(Boolean))) as string[];
if (userIds.length === 0) {
return;
}
const pointRows = await prisma.inheritancePoint.findMany({
where: {
userId: { in: userIds },
},
select: {
userId: true,
key: true,
value: true,
},
});
const pointMap = new Map<string, Map<string, number>>();
for (const row of pointRows) {
const bucket = pointMap.get(row.userId) ?? new Map();
bucket.set(row.key, row.value);
pointMap.set(row.userId, bucket);
}
for (const general of generals) {
if (!general.userId) {
continue;
}
const meta = asRecord(general.meta);
const livedMonth = readMetaNumber(meta, 'inherit_lived_month');
const maxDomestic = readMetaNumber(meta, 'max_domestic_critical');
const activeAction = readMetaNumber(meta, 'inherit_active_action');
const combat = readMetaNumber(meta, 'rank_warnum') * 5;
const sabotage = readMetaNumber(meta, 'firenum') * 20;
const dex = computeDexPoint(meta);
const points = pointMap.get(general.userId) ?? new Map();
const previous = points.get('previous') ?? 0;
const unifier = points.get('unifier') ?? 0;
const earned =
livedMonth +
maxDomestic +
activeAction * 3 +
combat +
sabotage +
dex +
unifier +
(general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0);
const total = previous + earned;
await prisma.inheritancePoint.upsert({
where: {
userId_key: {
userId: general.userId,
key: 'previous',
},
},
update: { value: total },
create: { userId: general.userId, key: 'previous', value: total },
});
await prisma.inheritancePoint.deleteMany({
where: {
userId: general.userId,
key: { not: 'previous' },
},
});
await prisma.inheritanceResult.create({
data: {
serverId: options.profileName,
owner: general.userId,
generalId: general.id,
year,
month,
value: {
previous,
lived_month: livedMonth,
max_domestic_critical: maxDomestic,
active_action: activeAction,
combat,
sabotage,
dex,
unifier,
unifierAward:
general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
},
},
});
await prisma.inheritanceLog.create({
data: {
userId: general.userId,
year,
month,
text: `천하 통일 정산: ${Math.floor(total).toLocaleString()} 포인트`,
},
});
}
};
const settleHallOfFame = async (winnerNationId: number): Promise<void> => {
const world = options.getWorld();
if (!world) {
return;
}
await ready;
const prisma = connector.prisma;
const state = world.getState();
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
const season = readMetaNumberOrNull(meta, 'season') ?? 1;
const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0;
const scenarioName =
typeof asRecord(meta.scenarioMeta).title === 'string' ? String(asRecord(meta.scenarioMeta).title) : '';
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
const unitedTime = new Date().toISOString();
const [serverCount, nationRows, generalRows, rankRows] = await Promise.all([
prisma.gameHistory.count(),
prisma.nation.findMany({ select: { id: true, name: true, color: true } }),
prisma.general.findMany({
where: { npcState: { lt: 2 } },
select: {
id: true,
userId: true,
nationId: true,
name: true,
picture: true,
imageServer: true,
experience: true,
dedication: true,
},
}),
prisma.rankData.findMany({
where: { generalId: { gt: 0 } },
select: { generalId: true, type: true, value: true },
}),
]);
const nationMap = new Map<number, { name: string; color: string }>();
for (const nation of nationRows) {
nationMap.set(nation.id, { name: nation.name, color: nation.color });
}
const rankMap = new Map<number, Record<string, number>>();
for (const row of rankRows) {
const entry = rankMap.get(row.generalId) ?? {};
entry[row.type] = row.value;
rankMap.set(row.generalId, entry);
}
const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => {
if (type === 'experience' || type === 'dedication' || type.startsWith('dex')) {
return [type, 'natural'];
}
if (type.endsWith('rate')) {
return [type, 'calc'];
}
return [type, 'rank'];
});
for (const general of generalRows) {
const ranks = rankMap.get(general.id) ?? {};
const warnum = ranks.warnum ?? 0;
const killnum = ranks.killnum ?? 0;
const killcrew = ranks.killcrew ?? 0;
const deathcrew = ranks.deathcrew ?? 0;
const killcrewPerson = ranks.killcrew_person ?? 0;
const deathcrewPerson = ranks.deathcrew_person ?? 0;
const ttw = ranks.ttw ?? 0;
const ttd = ranks.ttd ?? 0;
const ttl = ranks.ttl ?? 0;
const tlw = ranks.tlw ?? 0;
const tld = ranks.tld ?? 0;
const tll = ranks.tll ?? 0;
const tsw = ranks.tsw ?? 0;
const tsd = ranks.tsd ?? 0;
const tsl = ranks.tsl ?? 0;
const tiw = ranks.tiw ?? 0;
const tid = ranks.tid ?? 0;
const til = ranks.til ?? 0;
const betGold = ranks.betgold ?? 0;
const betWinGold = ranks.betwingold ?? 0;
const ttTotal = ttw + ttd + ttl;
const tlTotal = tlw + tld + tll;
const tsTotal = tsw + tsd + tsl;
const tiTotal = tiw + tid + til;
const calcValues: Record<string, number> = {
winrate: computeHallRate(killnum, warnum),
killrate: computeHallRate(killcrew, Math.max(1, deathcrew)),
killrate_person: computeHallRate(killcrewPerson, Math.max(1, deathcrewPerson)),
ttrate: computeHallRate(ttw, Math.max(1, ttTotal)),
tlrate: computeHallRate(tlw, Math.max(1, tlTotal)),
tsrate: computeHallRate(tsw, Math.max(1, tsTotal)),
tirate: computeHallRate(tiw, Math.max(1, tiTotal)),
betrate: computeHallRate(betWinGold, Math.max(1, betGold)),
};
const nation = nationMap.get(general.nationId) ?? { name: '재야', color: '#000000' };
const aux = {
name: general.name,
nationName: nation.name,
bgColor: nation.color,
fgColor: nation.color,
picture: general.picture,
imgsvr: general.imageServer,
startTime,
unitedTime,
ownerName: general.userId ?? null,
serverID: serverId,
serverIdx: serverCount,
serverName: options.profileName,
scenarioName,
};
for (const [typeName, valueType] of hallTypes) {
const value =
valueType === 'natural'
? typeName === 'experience'
? general.experience
: typeName === 'dedication'
? general.dedication
: (ranks[typeName] ?? 0)
: valueType === 'rank'
? (ranks[typeName] ?? 0)
: (calcValues[typeName] ?? 0);
if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) {
continue;
}
if (typeName === 'ttrate' && ttTotal < 50) {
continue;
}
if (typeName === 'tlrate' && tlTotal < 50) {
continue;
}
if (typeName === 'tsrate' && tsTotal < 50) {
continue;
}
if (typeName === 'tirate' && tiTotal < 50) {
continue;
}
if (typeName === 'betrate' && betGold < 1000) {
continue;
}
if (value <= 0) {
continue;
}
const existing = await prisma.hallOfFame.findUnique({
where: {
serverId_type_generalNo: {
serverId,
type: typeName,
generalNo: general.id,
},
},
});
if (!existing) {
await prisma.hallOfFame.create({
data: {
serverId,
season,
scenario,
generalNo: general.id,
type: typeName,
value,
owner: general.userId ?? null,
aux,
},
});
continue;
}
if (value > existing.value) {
await prisma.hallOfFame.update({
where: { id: existing.id },
data: { value, aux },
});
}
}
}
await prisma.gameHistory.update({
where: { serverId },
data: {
winnerNation: winnerNationId,
date: new Date(),
},
});
};
const settleDynasty = async (winnerNationId: number): Promise<void> => {
const world = options.getWorld();
if (!world) {
return;
}
await ready;
const prisma = connector.prisma;
const state = world.getState();
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
const serverName =
typeof meta.serverName === 'string' && meta.serverName.trim()
? meta.serverName.trim()
: options.profileName;
const [serverCount, nationRows, cityRows, generalRows, rankRows, historyRows, oldNationRows] =
await Promise.all([
prisma.gameHistory.count(),
prisma.nation.findMany({
select: {
id: true,
name: true,
color: true,
level: true,
typeCode: true,
gold: true,
rice: true,
meta: true,
capitalCityId: true,
},
}),
prisma.city.findMany({
select: { nationId: true, population: true, populationMax: true },
}),
prisma.general.findMany({
select: {
id: true,
userId: true,
name: true,
nationId: true,
dedication: true,
officerLevel: true,
picture: true,
imageServer: true,
leadership: true,
strength: true,
intel: true,
experience: true,
gold: true,
rice: true,
crew: true,
crewTypeId: true,
train: true,
atmos: true,
age: true,
startAge: true,
npcState: true,
personalCode: true,
specialCode: true,
special2Code: true,
cityId: true,
troopId: true,
turnTime: true,
meta: true,
},
}),
prisma.rankData.findMany({
where: {
nationId: winnerNationId,
type: { in: ['killnum', 'firenum'] },
},
select: { generalId: true, type: true, value: true },
}),
prisma.logEntry.findMany({
where: {
nationId: winnerNationId,
scope: LogScope.NATION,
category: LogCategory.HISTORY,
},
orderBy: { id: 'asc' },
select: { text: true },
}),
prisma.oldNation.findMany({
where: { serverId },
select: { data: true },
}),
]);
const winnerNation = nationRows.find((nation) => nation.id === winnerNationId);
if (!winnerNation) {
return;
}
const powerMap = new Map<number, number>();
for (const nation of world.listNations()) {
powerMap.set(nation.id, nation.power);
}
const winnerGenerals = generalRows.filter((general) => general.nationId === winnerNationId);
const winnerGeneralIds = winnerGenerals.map((general) => general.id);
const noNationGenerals = generalRows.filter((general) => general.nationId === 0);
const cityCount = cityRows.filter((city) => city.nationId === winnerNationId).length;
const popSum = cityRows
.filter((city) => city.nationId === winnerNationId)
.reduce((sum, city) => sum + city.population, 0);
const popMaxSum = cityRows
.filter((city) => city.nationId === winnerNationId)
.reduce((sum, city) => sum + city.populationMax, 0);
const popText = `${popSum} / ${popMaxSum}`;
const popRate = popMaxSum > 0 ? `${Math.round((popSum / popMaxSum) * 10000) / 100} %` : '0 %';
const officerMap = new Map<number, { name: string; picture: string | null }>();
for (const general of winnerGenerals) {
if (general.officerLevel < 5) {
continue;
}
if (!officerMap.has(general.officerLevel)) {
officerMap.set(general.officerLevel, { name: general.name, picture: general.picture ?? null });
}
}
const generalNameMap = new Map<number, string>();
for (const general of generalRows) {
generalNameMap.set(general.id, general.name);
}
const buildTopList = (type: 'killnum' | 'firenum', limit: number): string => {
const rows = rankRows
.filter((row) => row.type === type && row.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, limit);
return rows
.map((row) => `${generalNameMap.get(row.generalId) ?? '무명'}${row.value.toLocaleString('ko-KR')}`)
.join(', ');
};
const tiger = buildTopList('killnum', 5);
const eagle = buildTopList('firenum', 7);
const gen = winnerGenerals
.slice()
.sort((a, b) => b.dedication - a.dedication)
.map((general) => general.name)
.join(', ');
const nationNames: string[] = [];
const nationTypeCounts = new Map<string, number>();
for (const row of oldNationRows) {
const data = asRecord(row.data);
const name = typeof data.name === 'string' ? data.name : '';
const type = typeof data.type === 'string' ? data.type : '';
if (name) {
nationNames.push(name);
}
if (type) {
nationTypeCounts.set(type, (nationTypeCounts.get(type) ?? 0) + 1);
}
}
if (!nationNames.includes(winnerNation.name)) {
nationNames.push(winnerNation.name);
}
if (winnerNation.typeCode) {
nationTypeCounts.set(winnerNation.typeCode, (nationTypeCounts.get(winnerNation.typeCode) ?? 0) + 1);
}
const nationHist = Array.from(nationTypeCounts.entries())
.map(([key, count]) => `${key}(${count})`)
.join(', ');
const phase = `${serverName}${serverCount}`;
const nationCount = `${Math.max(1, nationRows.length)} / ${Math.max(1, nationRows.length)}`;
const genCount = `${generalRows.length} / ${generalRows.length}`;
const history = historyRows.map((row) => row.text);
await prisma.oldNation.upsert({
where: {
serverId_nation_sourceId: {
serverId,
nation: winnerNationId,
sourceId: 0,
},
},
update: {
data: {
nation: winnerNationId,
name: winnerNation.name,
color: winnerNation.color,
type: winnerNation.typeCode,
level: winnerNation.level,
gold: winnerNation.gold,
rice: winnerNation.rice,
power: powerMap.get(winnerNationId) ?? 0,
capitalCityId: winnerNation.capitalCityId,
generals: winnerGeneralIds,
history,
meta: winnerNation.meta ?? {},
},
date: new Date(),
},
create: {
serverId,
nation: winnerNationId,
sourceId: 0,
data: {
nation: winnerNationId,
name: winnerNation.name,
color: winnerNation.color,
type: winnerNation.typeCode,
level: winnerNation.level,
gold: winnerNation.gold,
rice: winnerNation.rice,
power: powerMap.get(winnerNationId) ?? 0,
capitalCityId: winnerNation.capitalCityId,
generals: winnerGeneralIds,
history,
meta: winnerNation.meta ?? {},
},
},
});
await prisma.oldNation.upsert({
where: {
serverId_nation_sourceId: {
serverId,
nation: 0,
sourceId: 0,
},
},
update: {
data: {
nation: 0,
name: '재야',
color: '#000000',
type: 'neutral',
level: 0,
gold: 0,
rice: 0,
power: 0,
capitalCityId: null,
generals: noNationGenerals.map((general) => general.id),
history: [],
meta: {},
},
date: new Date(),
},
create: {
serverId,
nation: 0,
sourceId: 0,
data: {
nation: 0,
name: '재야',
color: '#000000',
type: 'neutral',
level: 0,
gold: 0,
rice: 0,
power: 0,
capitalCityId: null,
generals: noNationGenerals.map((general) => general.id),
history: [],
meta: {},
},
},
});
const oldGeneralTargets = generalRows.filter(
(general) => general.nationId === 0 || general.nationId === winnerNationId
);
const generalHistoryRows = oldGeneralTargets.length
? await prisma.logEntry.findMany({
where: {
generalId: { in: oldGeneralTargets.map((general) => general.id) },
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
},
orderBy: { id: 'desc' },
select: { generalId: true, text: true },
})
: [];
const historyByGeneral = new Map<number, string[]>();
for (const row of generalHistoryRows) {
if (row.generalId === null) {
continue;
}
const history = historyByGeneral.get(row.generalId) ?? [];
history.push(row.text);
historyByGeneral.set(row.generalId, history);
}
await Promise.all(
oldGeneralTargets.map((general) =>
((snapshot) =>
prisma.oldGeneral.upsert({
where: {
by_no: {
serverId,
generalNo: general.id,
},
},
update: {
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
create: {
serverId,
generalNo: general.id,
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
}))({
...general,
turnTime: general.turnTime.toISOString(),
history: historyByGeneral.get(general.id) ?? [],
})
)
);
await prisma.emperor.create({
data: {
serverId,
phase,
nationCount,
nationName: nationNames.join(', '),
nationHist,
genCount,
personalHist: '',
specialHist: '',
name: winnerNation.name,
type: winnerNation.typeCode,
color: winnerNation.color,
year: state.currentYear,
month: state.currentMonth,
power: powerMap.get(winnerNationId) ?? 0,
gennum: winnerGenerals.length,
citynum: cityCount,
pop: popText,
poprate: popRate,
gold: winnerNation.gold,
rice: winnerNation.rice,
l12name: officerMap.get(12)?.name ?? '',
l12pic: officerMap.get(12)?.picture ?? '',
l11name: officerMap.get(11)?.name ?? '',
l11pic: officerMap.get(11)?.picture ?? '',
l10name: officerMap.get(10)?.name ?? '',
l10pic: officerMap.get(10)?.picture ?? '',
l9name: officerMap.get(9)?.name ?? '',
l9pic: officerMap.get(9)?.picture ?? '',
l8name: officerMap.get(8)?.name ?? '',
l8pic: officerMap.get(8)?.picture ?? '',
l7name: officerMap.get(7)?.name ?? '',
l7pic: officerMap.get(7)?.picture ?? '',
l6name: officerMap.get(6)?.name ?? '',
l6pic: officerMap.get(6)?.picture ?? '',
l5name: officerMap.get(5)?.name ?? '',
l5pic: officerMap.get(5)?.picture ?? '',
tiger,
eagle,
gen,
history,
aux: {
winnerNationId,
},
},
});
};
const handler: TurnCalendarHandler = {
}): { handler: TurnCalendarHandler } => ({
handler: {
onMonthChanged: (context) => {
const world = options.getWorld();
if (!world) {
return;
}
if (!world) return;
const state = world.getState();
const meta = asRecord(state.meta);
if (typeof meta.isUnited === 'number' && meta.isUnited !== 0) {
return;
}
if (asNumber(meta.isunited ?? meta.isUnited, 0) !== 0) return;
const activeNations = world.listNations().filter((nation) => nation.level > 0);
if (activeNations.length !== 1) {
return;
}
const winner = activeNations[0];
if (activeNations.length !== 1) return;
const winner = activeNations[0]!;
const cities = world.listCities();
const ownedCount = cities.filter((city) => city.nationId === winner.id).length;
if (ownedCount !== cities.length) {
return;
if (cities.length === 0 || cities.some((city) => city.nationId !== winner.id)) return;
const serverId = resolveServerId(world, options.profileName);
world.updateWorldMeta({
isUnited: 2,
isunited: 2,
refreshLimit: asNumber(meta.refreshLimit, 0) * 100,
});
world.pushLog(buildNationHistoryLog(winner.id, winner.name));
for (const general of world.listGenerals().filter((entry) => entry.nationId === winner.id)) {
world.pushLog(buildGeneralActionLog(general.id, winner.id, winner.name));
}
world.updateWorldMeta({ isUnited: 2 });
world.pushLog(buildUnificationLog(winner.name));
void settleInheritance(winner.id, context.currentYear, context.currentMonth);
void settleHallOfFame(winner.id);
void settleDynasty(winner.id);
queueYearbookSnapshot(world, options.profileName, context.currentYear, context.currentMonth);
world.queueUnificationFinalization({
generationKey: `unification:${serverId}`,
serverId,
profileName: options.profileName,
winnerNationId: winner.id,
year: context.currentYear,
month: context.currentMonth,
completedAt: new Date(state.lastTurnTime.getTime()),
});
},
};
const close = async (): Promise<void> => {
await ready;
await connector.disconnect();
};
return { handler, close };
};
},
});
@@ -0,0 +1,500 @@
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
const UNIFIER_POINT = 2000;
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
export interface UnificationFinalizationInput {
readonly generationKey: string;
readonly serverId: string;
readonly profileName: string;
readonly winnerNationId: number;
readonly year: number;
readonly month: number;
readonly completedAt: Date;
}
export interface UnificationFinalizationResult {
status: 'APPLIED' | 'ALREADY_APPLIED';
generationKey: string;
}
const readNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
const readInteger = (value: unknown, fallback = 0): number => {
const parsed = typeof value === 'string' ? Number(value) : value;
return typeof parsed === 'number' && Number.isFinite(parsed) ? Math.floor(parsed) : fallback;
};
const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0);
const ownerDisplayName = (meta: Record<string, unknown>): string | null => {
for (const key of ['ownerDisplayName', 'owner_name', 'ownerName']) {
const value = meta[key];
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return null;
};
const formatHistogram = (value: unknown): string =>
Object.entries(asRecord(value))
.filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1]))
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, count]) => `${key}(${count})`)
.join(', ');
const claimGeneration = async (
transaction: GamePrisma.TransactionClient,
input: UnificationFinalizationInput
): Promise<'CLAIMED' | 'ALREADY_APPLIED'> => {
await transaction.$executeRaw`
SELECT pg_advisory_xact_lock(
hashtext(${'unification-finalization'}),
hashtext(${input.generationKey})
)
`;
const existing = await transaction.unificationFinalization.findUnique({
where: { generationKey: input.generationKey },
});
if (existing) {
const matches =
existing.serverId === input.serverId &&
existing.profileName === input.profileName &&
existing.winnerNation === input.winnerNationId &&
existing.year === input.year &&
existing.month === input.month &&
existing.completedAt.getTime() === input.completedAt.getTime();
if (!matches) {
throw new Error(`Unification generation payload mismatch: ${input.generationKey}.`);
}
return 'ALREADY_APPLIED';
}
await transaction.unificationFinalization.create({
data: {
generationKey: input.generationKey,
serverId: input.serverId,
profileName: input.profileName,
winnerNation: input.winnerNationId,
year: input.year,
month: input.month,
completedAt: input.completedAt,
},
});
return 'CLAIMED';
};
export const persistUnificationFinalization = async (
transaction: GamePrisma.TransactionClient,
input: UnificationFinalizationInput,
world: InMemoryTurnWorld
): Promise<UnificationFinalizationResult> => {
if (!input.generationKey.trim()) {
throw new Error('Unification finalization requires a non-empty generationKey.');
}
const claim = await claimGeneration(transaction, input);
if (claim === 'ALREADY_APPLIED') {
return { status: 'ALREADY_APPLIED', generationKey: input.generationKey };
}
const state = world.getState();
if (state.currentYear !== input.year || state.currentMonth !== input.month) {
throw new Error(
`Unification snapshot date mismatch: input=${input.year}-${input.month}, world=${state.currentYear}-${state.currentMonth}.`
);
}
const winner = world.getNationById(input.winnerNationId);
if (!winner) {
throw new Error(`Unification winner nation does not exist: ${input.winnerNationId}.`);
}
const meta = asRecord(state.meta);
const snapshotServerId =
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : input.profileName;
if (snapshotServerId !== input.serverId) {
throw new Error(`Unification snapshot server mismatch: input=${input.serverId}, world=${snapshotServerId}.`);
}
const serverId = input.serverId;
const serverName =
typeof meta.serverName === 'string' && meta.serverName.trim() ? meta.serverName.trim() : input.profileName;
const generals = world.listGenerals();
const cities = world.listCities();
const nations = world.listNations();
const eligibleGenerals = generals.filter((general) => general.userId && general.npcState < 2);
const pointRows = eligibleGenerals.length
? await transaction.inheritancePoint.findMany({
where: { userId: { in: eligibleGenerals.map((general) => general.userId!) } },
select: { userId: true, key: true, value: true },
})
: [];
const pointsByUser = new Map<string, Map<string, number>>();
for (const row of pointRows) {
const points = pointsByUser.get(row.userId) ?? new Map<string, number>();
points.set(row.key, row.value);
pointsByUser.set(row.userId, points);
}
for (const general of eligibleGenerals) {
const userId = general.userId!;
const generalMeta = asRecord(general.meta);
const currentPoints = pointsByUser.get(userId) ?? new Map<string, number>();
const previous = currentPoints.get('previous') ?? 0;
const livedMonth = readNumber(generalMeta.inherit_lived_month);
const maxDomestic = readNumber(generalMeta.max_domestic_critical);
const activeAction = readNumber(generalMeta.inherit_active_action);
const combat = readNumber(generalMeta.rank_warnum) * 5;
const sabotage = readNumber(generalMeta.firenum) * 20;
const dex =
Object.entries(generalMeta).reduce(
(sum, [key, value]) => (key.startsWith('dex') ? sum + readNumber(value) : sum),
0
) * 0.001;
const unifier = currentPoints.get('unifier') ?? 0;
const unifierAward = general.nationId === input.winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0;
const total = Math.floor(
previous + livedMonth + maxDomestic + activeAction * 3 + combat + sabotage + dex + unifier + unifierAward
);
await transaction.inheritancePoint.upsert({
where: { userId_key: { userId, key: 'previous' } },
update: { value: total },
create: { userId, key: 'previous', value: total },
});
await transaction.inheritancePoint.deleteMany({ where: { userId, key: { not: 'previous' } } });
await transaction.inheritanceResult.create({
data: {
serverId,
owner: userId,
generalId: general.id,
year: input.year,
month: input.month,
value: {
previous,
lived_month: livedMonth,
max_domestic_critical: maxDomestic,
active_action: activeAction,
combat,
sabotage,
dex,
unifier,
unifierAward,
total,
generationKey: input.generationKey,
},
},
});
await transaction.inheritanceLog.create({
data: {
userId,
serverId,
year: input.year,
month: input.month,
text: `천하 통일 정산: ${total.toLocaleString('ko-KR')} 포인트`,
},
});
}
const rankRows = generals.length
? await transaction.rankData.findMany({
where: { generalId: { in: generals.map((general) => general.id) } },
select: { generalId: true, type: true, value: true },
})
: [];
const ranksByGeneral = new Map<number, Record<string, number>>();
for (const row of rankRows) {
const ranks = ranksByGeneral.get(row.generalId) ?? {};
ranks[row.type] = row.value;
ranksByGeneral.set(row.generalId, ranks);
}
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
const season = readInteger(meta.season, 1);
const scenario = readInteger(meta.scenarioId);
const scenarioName = String(asRecord(meta.scenarioMeta).title ?? '');
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
const unitedTime = input.completedAt.toISOString();
const serverCount = await transaction.gameHistory.count();
const minHallAge = readInteger(asRecord(world.getScenarioConfig().const).minPushHallAge, 30);
const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => {
if (type === 'experience' || type === 'dedication' || type.startsWith('dex')) {
return [type, 'natural'];
}
return [type, type.endsWith('rate') ? 'calc' : 'rank'];
});
for (const general of eligibleGenerals.filter((entry) => entry.age >= minHallAge)) {
const ranks = ranksByGeneral.get(general.id) ?? {};
const totals = {
tt: (ranks.ttw ?? 0) + (ranks.ttd ?? 0) + (ranks.ttl ?? 0),
tl: (ranks.tlw ?? 0) + (ranks.tld ?? 0) + (ranks.tll ?? 0),
ts: (ranks.tsw ?? 0) + (ranks.tsd ?? 0) + (ranks.tsl ?? 0),
ti: (ranks.tiw ?? 0) + (ranks.tid ?? 0) + (ranks.til ?? 0),
};
const calc: Record<string, number> = {
winrate: computeRate(ranks.killnum ?? 0, ranks.warnum ?? 0),
killrate: computeRate(ranks.killcrew ?? 0, Math.max(1, ranks.deathcrew ?? 0)),
killrate_person: computeRate(ranks.killcrew_person ?? 0, Math.max(1, ranks.deathcrew_person ?? 0)),
ttrate: computeRate(ranks.ttw ?? 0, totals.tt),
tlrate: computeRate(ranks.tlw ?? 0, totals.tl),
tsrate: computeRate(ranks.tsw ?? 0, totals.ts),
tirate: computeRate(ranks.tiw ?? 0, totals.ti),
betrate: computeRate(ranks.betwingold ?? 0, Math.max(1, ranks.betgold ?? 0)),
};
const generalMeta = asRecord(general.meta);
const nation = nationMap.get(general.nationId);
const background = nation?.color ?? '#000000';
const aux = {
name: general.name,
nationName: nation?.name ?? '재야',
bgColor: background,
fgColor: resolveLegacyTextColor(background),
picture: general.picture ?? null,
imgsvr: general.imageServer ?? 0,
startTime,
unitedTime,
ownerDisplayName: ownerDisplayName(generalMeta),
serverID: serverId,
serverIdx: serverCount,
serverName,
scenarioName,
generationKey: input.generationKey,
};
for (const [type, valueType] of hallTypes) {
const value =
valueType === 'calc'
? (calc[type] ?? 0)
: valueType === 'rank'
? (ranks[type] ?? 0)
: type === 'experience'
? general.experience
: type === 'dedication'
? general.dedication
: readNumber(generalMeta[type]);
if ((type === 'winrate' || type === 'killrate') && (ranks.warnum ?? 0) < 10) continue;
if (type === 'ttrate' && totals.tt < 50) continue;
if (type === 'tlrate' && totals.tl < 50) continue;
if (type === 'tsrate' && totals.ts < 50) continue;
if (type === 'tirate' && totals.ti < 50) continue;
if (type === 'betrate' && (ranks.betgold ?? 0) < 1000) continue;
if (value <= 0) continue;
const existing = await transaction.hallOfFame.findFirst({
where: {
OR: [
{ serverId, type, generalNo: general.id },
{ serverId, type, owner: general.userId },
],
},
});
if (!existing) {
await transaction.hallOfFame.create({
data: {
serverId,
season,
scenario,
generalNo: general.id,
type,
value,
owner: general.userId ?? null,
aux,
},
});
} else if (value > existing.value) {
await transaction.hallOfFame.update({ where: { id: existing.id }, data: { value, aux } });
}
}
}
await transaction.gameHistory.update({
where: { serverId },
data: { winnerNation: input.winnerNationId, date: input.completedAt },
});
const nationHistoryRows = await transaction.logEntry.findMany({
where: { nationId: input.winnerNationId, scope: LogScope.NATION, category: LogCategory.HISTORY },
orderBy: { id: 'asc' },
select: { text: true },
});
const nationHistory = nationHistoryRows.map((row) => row.text);
const winnerGenerals = generals.filter((general) => general.nationId === input.winnerNationId);
const neutralGenerals = generals.filter((general) => general.nationId === 0);
const cityCount = cities.filter((city) => city.nationId === input.winnerNationId).length;
const totalPop = cities.reduce((sum, city) => sum + city.population, 0);
const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0);
const winnerMeta = asRecord(winner.meta);
const winnerData = {
...winner,
tech: readInteger(winnerMeta.tech),
aux: {
...asRecord(winnerMeta.aux),
...asRecord(winnerMeta.max_power),
},
msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''),
scout_msg: String(winnerMeta.scout_msg ?? ''),
generals: winnerGenerals.map((general) => general.id),
history: nationHistory,
generationKey: input.generationKey,
};
await transaction.oldNation.upsert({
where: { serverId_nation_sourceId: { serverId, nation: input.winnerNationId, sourceId: 0 } },
update: { data: asJson(winnerData), date: input.completedAt },
create: {
serverId,
nation: input.winnerNationId,
sourceId: 0,
data: asJson(winnerData),
date: input.completedAt,
},
});
const neutralData = {
nation: 0,
name: '재야',
generals: neutralGenerals.map((general) => general.id),
generationKey: input.generationKey,
};
await transaction.oldNation.upsert({
where: { serverId_nation_sourceId: { serverId, nation: 0, sourceId: 0 } },
update: { data: neutralData, date: input.completedAt },
create: { serverId, nation: 0, sourceId: 0, data: neutralData, date: input.completedAt },
});
const archiveGenerals = [...neutralGenerals, ...winnerGenerals];
const generalHistoryRows = archiveGenerals.length
? await transaction.logEntry.findMany({
where: {
generalId: { in: archiveGenerals.map((general) => general.id) },
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
},
orderBy: { id: 'asc' },
select: { generalId: true, text: true },
})
: [];
const historyByGeneral = new Map<number, string[]>();
for (const row of generalHistoryRows) {
if (row.generalId === null) continue;
const history = historyByGeneral.get(row.generalId) ?? [];
history.push(row.text);
historyByGeneral.set(row.generalId, history);
}
for (const general of archiveGenerals) {
const data = {
...general,
turnTime: general.turnTime.toISOString(),
history: historyByGeneral.get(general.id) ?? [],
generationKey: input.generationKey,
};
await transaction.oldGeneral.upsert({
where: { by_no: { serverId, generalNo: general.id } },
update: {
owner: general.userId ?? null,
name: general.name,
lastYearMonth: input.year * 100 + input.month,
turnTime: general.turnTime,
data: asJson(data),
},
create: {
serverId,
generalNo: general.id,
owner: general.userId ?? null,
name: general.name,
lastYearMonth: input.year * 100 + input.month,
turnTime: general.turnTime,
data: asJson(data),
},
});
}
const officerMap = new Map(
winnerGenerals
.filter((general) => general.officerLevel >= 5)
.map((general) => [general.officerLevel, general] as const)
);
const topList = (type: 'killnum' | 'firenum', limit: number): string =>
rankRows
.filter((row) => row.type === type && row.value > 0 && winnerGenerals.some((g) => g.id === row.generalId))
.sort((left, right) => right.value - left.value)
.slice(0, limit)
.map(
(row) =>
`${generals.find((general) => general.id === row.generalId)?.name ?? '무명'}${row.value.toLocaleString('ko-KR')}`
)
.join(', ');
const previousNationArchives = await transaction.oldNation.findMany({
where: { serverId },
select: { data: true },
});
const archivedNationNames = previousNationArchives
.map((row) => asRecord(row.data).name)
.filter((name): name is string => typeof name === 'string' && Boolean(name));
if (!archivedNationNames.includes(winner.name)) archivedNationNames.push(winner.name);
const statistics = asRecord(meta.dynastyStatistics);
const nationCount = `1 / ${Math.max(1, readInteger(statistics.maxNationCount, 1))}`;
const genCount = `${generals.length} / ${Math.max(generals.length, readInteger(statistics.maxGeneralCount))}`;
const statisticNationNames = String(statistics.maxNationName ?? '').trim();
const personalHist = formatHistogram(statistics.personalHist);
const specialHist = [formatHistogram(statistics.specialHist), formatHistogram(statistics.special2Hist)]
.filter(Boolean)
.join(' // ');
const population = `${totalPop} / ${totalMaxPop}`;
const popRate = totalMaxPop > 0 ? `${Math.round((totalPop / totalMaxPop) * 10000) / 100} %` : '0 %';
const officer = (level: number) => officerMap.get(level);
await transaction.emperor.create({
data: {
serverId,
phase: `${serverName}${serverCount}`,
nationCount,
nationName: statisticNationNames || archivedNationNames.join(', '),
nationHist: formatHistogram(statistics.maxNationHist),
genCount,
personalHist,
specialHist,
name: winner.name,
type: winner.typeCode,
color: winner.color,
year: input.year,
month: input.month,
power: winner.power,
gennum: winnerGenerals.length,
citynum: cityCount,
pop: population,
poprate: popRate,
gold: winner.gold,
rice: winner.rice,
l12name: officer(12)?.name ?? '',
l12pic: officer(12)?.picture ?? '',
l11name: officer(11)?.name ?? '',
l11pic: officer(11)?.picture ?? '',
l10name: officer(10)?.name ?? '',
l10pic: officer(10)?.picture ?? '',
l9name: officer(9)?.name ?? '',
l9pic: officer(9)?.picture ?? '',
l8name: officer(8)?.name ?? '',
l8pic: officer(8)?.picture ?? '',
l7name: officer(7)?.name ?? '',
l7pic: officer(7)?.picture ?? '',
l6name: officer(6)?.name ?? '',
l6pic: officer(6)?.picture ?? '',
l5name: officer(5)?.name ?? '',
l5pic: officer(5)?.picture ?? '',
tiger: topList('killnum', 5),
eagle: topList('firenum', 7),
gen: winnerGenerals
.slice()
.sort((left, right) => right.dedication - left.dedication)
.map((general) => general.name)
.join(', '),
history: nationHistory,
aux: { winnerNationId: input.winnerNationId, generationKey: input.generationKey },
},
});
return { status: 'APPLIED', generationKey: input.generationKey };
};
+96 -51
View File
@@ -1,6 +1,4 @@
import { createHash } from 'crypto';
import { asNumber, asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import type { TurnCalendarHandler } from './inMemoryWorld.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -29,6 +27,19 @@ type YearbookNation = {
cities: string[];
};
type DynastyStatistics = {
maxNationCount: number;
maxNationName: string;
maxNationHist: Record<string, number>;
maxGeneralCount: number;
currentGeneralCount: number;
userGeneralCount: number;
npcGeneralCount: number;
personalHist: Record<string, number>;
specialHist: Record<string, number>;
special2Hist: Record<string, number>;
};
const readState = (meta: Record<string, unknown>): number => {
const raw = meta.state;
if (typeof raw === 'number' && Number.isFinite(raw)) {
@@ -136,58 +147,92 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => {
});
};
const buildHash = (map: YearbookMap, nations: YearbookNation[]): string =>
createHash('sha256').update(JSON.stringify({ map, nations })).digest('hex');
const increment = (target: Record<string, number>, key: string): void => {
target[key] = (target[key] ?? 0) + 1;
};
const updateDynastyStatistics = (world: InMemoryTurnWorld): void => {
const state = world.getState();
const previous = asRecord(state.meta.dynastyStatistics);
const activeNations = world
.listNations()
.filter((nation) => nation.level > 0)
.sort((left, right) => right.power - left.power || left.id - right.id);
const generals = world.listGenerals();
const maxNationCount = Math.max(asNumber(previous.maxNationCount, 0), activeNations.length);
const replaceNationMaximum = activeNations.length > asNumber(previous.maxNationCount, 0);
const nationHist: Record<string, number> = {};
for (const nation of activeNations) increment(nationHist, nation.typeCode || 'neutral');
const personalHist: Record<string, number> = {};
const specialHist: Record<string, number> = {};
const special2Hist: Record<string, number> = {};
let userGeneralCount = 0;
let npcGeneralCount = 0;
for (const general of generals) {
increment(personalHist, general.role.personality || 'None');
increment(specialHist, general.role.specialDomestic || 'None');
increment(special2Hist, general.role.specialWar || 'None');
if (general.npcState < 2) userGeneralCount += 1;
else npcGeneralCount += 1;
}
const statistics: DynastyStatistics = {
maxNationCount,
maxNationName: replaceNationMaximum
? activeNations.map((nation) => `${nation.name}(${nation.typeCode})`).join(', ')
: typeof previous.maxNationName === 'string'
? previous.maxNationName
: '',
maxNationHist: replaceNationMaximum
? nationHist
: Object.fromEntries(
Object.entries(asRecord(previous.maxNationHist)).flatMap(([key, value]) =>
typeof value === 'number' && Number.isFinite(value) ? [[key, value]] : []
)
),
maxGeneralCount: Math.max(asNumber(previous.maxGeneralCount, 0), generals.length),
currentGeneralCount: generals.length,
userGeneralCount,
npcGeneralCount,
personalHist,
specialHist,
special2Hist,
};
world.updateWorldMeta({ dynastyStatistics: statistics });
};
const resolveServerId = (world: InMemoryTurnWorld, fallback: string): string => {
const serverId = world.getState().meta.serverId;
return typeof serverId === 'string' && serverId.trim() ? serverId.trim() : fallback;
};
export const queueYearbookSnapshot = (
world: InMemoryTurnWorld,
profileName: string,
year: number,
month: number
): void => {
world.queueYearbookSnapshot({
serverId: resolveServerId(world, profileName),
sourceId: 0,
year,
month,
map: buildMapSnapshot(world, year, month),
nations: buildNationSnapshot(world),
});
};
export const createYearbookHandler = (options: {
databaseUrl: string;
profileName: string;
getWorld: () => InMemoryTurnWorld | null;
}): { handler: TurnCalendarHandler; close: () => Promise<void> } => {
const connector = createGamePostgresConnector({ url: options.databaseUrl });
const ready = connector.connect();
const handler: TurnCalendarHandler = {
beforeMonthChanged: async (context) => {
}): { handler: TurnCalendarHandler } => ({
handler: {
beforeMonthChanged: (context) => {
const world = options.getWorld();
if (!world) {
return;
}
await ready;
const map = buildMapSnapshot(world, context.previousYear, context.previousMonth);
const nations = buildNationSnapshot(world);
const hash = buildHash(map, nations);
await connector.prisma.yearbookHistory.upsert({
where: {
profileName_year_month_sourceId: {
profileName: options.profileName,
year: context.previousYear,
month: context.previousMonth,
sourceId: 0,
},
},
update: {
map,
nations,
hash,
},
create: {
profileName: options.profileName,
sourceId: 0,
year: context.previousYear,
month: context.previousMonth,
map,
nations,
hash,
},
});
if (!world) return;
updateDynastyStatistics(world);
queueYearbookSnapshot(world, options.profileName, context.previousYear, context.previousMonth);
},
};
const close = async () => {
await connector.disconnect();
};
return { handler, close };
};
},
});
@@ -0,0 +1,75 @@
import { createHash } from 'node:crypto';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import type { PendingYearbookSnapshot } from './types.js';
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
const computeHash = (payload: unknown): string => createHash('sha256').update(JSON.stringify(payload)).digest('hex');
export const persistYearbookSnapshot = async (
transaction: GamePrisma.TransactionClient,
snapshot: PendingYearbookSnapshot
): Promise<void> => {
const [historyRows, actionRows] = await Promise.all([
transaction.logEntry.findMany({
where: {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
year: snapshot.year,
month: snapshot.month,
},
orderBy: { id: 'desc' },
select: { text: true },
}),
transaction.logEntry.findMany({
where: {
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
year: snapshot.year,
month: snapshot.month,
},
orderBy: { id: 'desc' },
select: { text: true },
}),
]);
const globalHistory = historyRows.map((row) => row.text);
const globalAction = actionRows.map((row) => row.text);
const hash = computeHash({
map: snapshot.map,
nations: snapshot.nations,
globalHistory,
globalAction,
});
await transaction.yearbookHistory.upsert({
where: {
profileName_year_month_sourceId: {
profileName: snapshot.serverId,
year: snapshot.year,
month: snapshot.month,
sourceId: snapshot.sourceId,
},
},
update: {
map: asJson(snapshot.map),
nations: asJson(snapshot.nations),
globalHistory: asJson(globalHistory),
globalAction: asJson(globalAction),
hash,
},
create: {
profileName: snapshot.serverId,
sourceId: snapshot.sourceId,
year: snapshot.year,
month: snapshot.month,
map: asJson(snapshot.map),
nations: asJson(snapshot.nations),
globalHistory: asJson(globalHistory),
globalAction: asJson(globalAction),
hash,
},
});
};
@@ -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 } });
@@ -75,6 +75,211 @@ describe('TurnDaemonLifecycle', () => {
await loop;
});
it('restores processor and state-store mutations when scheduled flush fails', async () => {
const now = new Date('2026-01-01T00:10:00.000Z');
const previousCheckpoint = {
turnTime: '2026-01-01T00:00:00.000Z',
generalId: 7,
year: 203,
month: 1,
};
const nextCheckpoint = {
turnTime: now.toISOString(),
generalId: 8,
year: 203,
month: 2,
};
let engineState: {
value: string;
lastTurnTime: string;
checkpoint: TurnRunResult['checkpoint'];
} = {
value: 'before',
lastTurnTime: previousCheckpoint.turnTime,
checkpoint: previousCheckpoint,
};
const stateManager = new EngineStateManager();
stateManager.register('test', {
capture: () => structuredClone(engineState),
restore: (snapshot) => {
engineState = snapshot;
},
});
const published = vi.fn();
let resolveError: (() => void) | undefined;
const errorObserved = new Promise<void>((resolve) => {
resolveError = resolve;
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue: new InMemoryControlQueue(),
getNextTickTime: () => new Date('2026-01-01T00:05:00.000Z'),
stateStore: {
loadLastTurnTime: async () => new Date(engineState.lastTurnTime),
loadNextGeneralTurnTime: async () => null,
saveLastTurnTime: async (turnTime) => {
engineState.lastTurnTime = turnTime.toISOString();
},
loadCheckpoint: async () => engineState.checkpoint,
saveCheckpoint: async (checkpoint) => {
engineState.checkpoint = checkpoint;
},
},
processor: {
run: async (): Promise<TurnRunResult> => {
engineState.value = 'calculated';
return {
lastTurnTime: now.toISOString(),
processedGenerals: 1,
processedTurns: 1,
durationMs: 0,
partial: false,
checkpoint: nextCheckpoint,
};
},
},
stateManager,
hooks: {
flushChanges: async () => {
expect(engineState).toMatchObject({
value: 'calculated',
lastTurnTime: now.toISOString(),
checkpoint: nextCheckpoint,
});
throw new Error('scheduled flush failed');
},
publishEvents: published,
onRunError: async () => {
resolveError?.();
},
},
},
{
profile: 'test',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
const loop = lifecycle.start();
await errorObserved;
expect(engineState).toEqual({
value: 'before',
lastTurnTime: previousCheckpoint.turnTime,
checkpoint: previousCheckpoint,
});
expect(stateManager.getRevision()).toBe(0);
expect(published).not.toHaveBeenCalled();
expect(lifecycle.getStatus()).toMatchObject({
state: 'paused',
paused: true,
lastError: 'scheduled flush failed',
});
await lifecycle.stop('done');
await loop;
});
it('keeps processor and state-store mutations after flush succeeds and publishes afterward', async () => {
const now = new Date('2026-01-01T00:10:00.000Z');
const previousCheckpoint = {
turnTime: '2026-01-01T00:00:00.000Z',
generalId: 7,
year: 203,
month: 1,
};
const nextCheckpoint = {
turnTime: now.toISOString(),
generalId: 8,
year: 203,
month: 2,
};
let engineState: {
value: string;
lastTurnTime: string;
checkpoint: TurnRunResult['checkpoint'];
} = {
value: 'before',
lastTurnTime: previousCheckpoint.turnTime,
checkpoint: previousCheckpoint,
};
const stateManager = new EngineStateManager();
stateManager.register('test', {
capture: () => structuredClone(engineState),
restore: (snapshot) => {
engineState = snapshot;
},
});
const callOrder: string[] = [];
let resolvePublished: (() => void) | undefined;
const published = new Promise<void>((resolve) => {
resolvePublished = resolve;
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue: new InMemoryControlQueue(),
getNextTickTime: (lastTurnTime) => addMinutes(lastTurnTime, 10),
stateStore: {
loadLastTurnTime: async () => new Date(engineState.lastTurnTime),
loadNextGeneralTurnTime: async () => null,
saveLastTurnTime: async (turnTime) => {
callOrder.push('save-last-turn');
engineState.lastTurnTime = turnTime.toISOString();
},
loadCheckpoint: async () => engineState.checkpoint,
saveCheckpoint: async (checkpoint) => {
callOrder.push('save-checkpoint');
engineState.checkpoint = checkpoint;
},
},
processor: {
run: async (): Promise<TurnRunResult> => {
callOrder.push('processor');
engineState.value = 'calculated';
return {
lastTurnTime: now.toISOString(),
processedGenerals: 1,
processedTurns: 1,
durationMs: 0,
partial: false,
checkpoint: nextCheckpoint,
};
},
},
stateManager,
hooks: {
flushChanges: async () => {
callOrder.push('flush');
},
publishEvents: async () => {
callOrder.push('publish');
resolvePublished?.();
},
},
},
{
profile: 'test',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
const loop = lifecycle.start();
await published;
expect(engineState).toEqual({
value: 'calculated',
lastTurnTime: now.toISOString(),
checkpoint: nextCheckpoint,
});
expect(stateManager.getRevision()).toBe(1);
expect(callOrder).toEqual(['processor', 'save-last-turn', 'save-checkpoint', 'flush', 'publish']);
await lifecycle.stop('done');
await loop;
});
it('runs scheduled turn based on queue front and checkpoint context', async () => {
const turnTermMinutes = 10;
const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);
+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 } }),
})
);
});
});
+1
View File
@@ -16,4 +16,5 @@ export * from './turnDaemon/types.js';
export * from './realtime/keys.js';
export * from './realtime/types.js';
export * from './ranking/types.js';
export * from './ranking/legacyColor.js';
export * from './auth/accountIconProjection.js';
@@ -0,0 +1,23 @@
const LEGACY_WHITE_TEXT_COLORS = new Set([
'',
'#330000',
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#6495ED',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#800080',
'#A9A9A9',
'#000000',
]);
export const resolveLegacyTextColor = (backgroundColor: string): '#ffffff' | '#000000' =>
LEGACY_WHITE_TEXT_COLORS.has(backgroundColor.toUpperCase()) ? '#ffffff' : '#000000';
+13
View File
@@ -416,6 +416,19 @@ model Emperor {
@@map("emperior")
}
model UnificationFinalization {
generationKey String @id @map("generation_key")
serverId String @unique @map("server_id")
profileName String @map("profile_name")
winnerNation Int @map("winner_nation")
year Int
month Int
completedAt DateTime @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("unification_finalization")
}
model Troop {
troopLeaderId Int @id @map("troop_leader")
nationId Int @map("nation")
@@ -0,0 +1,24 @@
ALTER TABLE "ng_old_nations"
ALTER COLUMN "server_id" TYPE TEXT;
ALTER TABLE "ng_old_generals"
ALTER COLUMN "server_id" TYPE TEXT;
ALTER TABLE "emperior"
ALTER COLUMN "server_id" TYPE TEXT;
CREATE TABLE "unification_finalization" (
"generation_key" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"profile_name" TEXT NOT NULL,
"winner_nation" INTEGER NOT NULL,
"year" INTEGER NOT NULL,
"month" INTEGER NOT NULL,
"completed_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "unification_finalization_pkey" PRIMARY KEY ("generation_key")
);
CREATE UNIQUE INDEX "unification_finalization_server_id_key"
ON "unification_finalization" ("server_id");
+1
View File
@@ -310,6 +310,7 @@ export interface TurnEngineNationUpdateInput {
chiefGeneralId: number | null;
gold: number;
rice: number;
tech: number;
level: number;
typeCode: string;
meta: InputJsonValue;
@@ -259,6 +259,7 @@ const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
);
await page.route('**/che/api/trpc/**', async (route) => {
await fulfillOperations(route, (operation) => {
if (operation === 'auth.status') return { userId: 'frontend-legacy-fixture-user' };
if (operation === 'lobby.info') {
return { ...fixture.game.lobby, myGeneral: fixture.game.session.general };
}