fix: 은퇴 명예 기록과 유산 수명주기 정합성을 복원한다

은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다.

유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
This commit is contained in:
2026-08-24 03:38:12 +00:00
parent 95cf68dffd
commit 4fc20de8d4
33 changed files with 4160 additions and 1166 deletions
@@ -265,6 +265,42 @@ const zPatchGeneral = z.object({
}),
});
const zInheritanceAction = z
.object({
type: z.literal('inheritanceAction'),
requestId: z.string().min(1).optional(),
userId: z.string().min(1),
input: z.discriminatedUnion('action', [
z.object({
action: z.literal('buyHiddenBuff'),
buffType: z.enum([
'warAvoidRatio',
'warCriticalRatio',
'warMagicTrialProb',
'domesticSuccessProb',
'domesticFailProb',
'warAvoidRatioOppose',
'warCriticalRatioOppose',
'warMagicTrialProbOppose',
]),
level: z.number().int().min(1).max(5),
}),
z.object({ action: z.literal('setNextSpecialWar'), specialKey: z.string().min(1) }),
z.object({ action: z.literal('resetSpecialWar') }),
z.object({ action: z.literal('resetTurnTime') }),
z.object({
action: z.literal('resetStat'),
leadership: z.number().int(),
strength: z.number().int(),
intel: z.number().int(),
inheritBonusStat: z.tuple([z.number().int(), z.number().int(), z.number().int()]).optional(),
}),
z.object({ action: z.literal('buyRandomUnique') }),
z.object({ action: z.literal('checkOwner'), targetGeneralId: z.number().int().positive() }),
]),
})
.strict();
const zAdjustGeneralIcon = z
.object({
type: z.literal('adjustGeneralIcon'),
@@ -643,6 +679,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
return { ...command, requestId: envelope.requestId };
};
const normalizeInheritanceAction: CommandNormalizer<'inheritanceAction'> = (envelope) => {
const command = parseWith(zInheritanceAction, envelope.command);
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (envelope) => {
const command = parseWith(zAdjustGeneralIcon, envelope.command);
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
@@ -764,6 +808,7 @@ const normalizers: CommandNormalizerMap = {
adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult,
patchGeneral: normalizePatchGeneral,
inheritanceAction: normalizeInheritanceAction,
adjustGeneralIcon: normalizeAdjustGeneralIcon,
joinCreateGeneral: normalizeJoinCreateGeneral,
npcPossessGeneral: normalizeNpcPossessGeneral,
+61 -20
View File
@@ -44,7 +44,7 @@ import type { InMemoryTurnWorld, TurnWorldChanges } from './inMemoryWorld.js';
import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js';
import { buildDiplomacyMeta } from '@sammo-ts/logic';
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js';
import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js';
import { persistGeneralLifecycleEvents, type GeneralLifecycleArchiveLog } from './generalTurnLifecyclePersistence.js';
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
@@ -1094,6 +1094,7 @@ export const createDatabaseTurnHooks = async (
lifecycleEvents,
pendingNeutralAuctions,
inheritancePointAdjustments,
pendingInheritanceLogs,
pendingNationBettingOpens,
pendingNationBettingFinishes,
pendingYearbookSnapshots,
@@ -1137,6 +1138,20 @@ export const createDatabaseTurnHooks = async (
select: { id: true },
})
)?.id ?? 0;
const logContext = {
year: state.currentYear,
month: state.currentMonth,
at: state.lastTurnTime,
};
const pendingLogRows = logs
.map((entry) => buildLogCreateData(entry, logContext))
.filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry));
const pendingLifecycleArchiveLogs: GeneralLifecycleArchiveLog[] = pendingLogRows.flatMap((entry) =>
entry.generalId !== null &&
(entry.category === LogCategory.HISTORY || entry.category === LogCategory.BATTLE_BRIEF)
? [{ generalId: entry.generalId, category: entry.category, text: entry.text }]
: []
);
// Lock and validate the fencing row in the same transaction as every
// world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch.
@@ -1257,15 +1272,24 @@ export const createDatabaseTurnHooks = async (
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default';
if (inheritancePointAdjustments.length > 0) {
const persistInheritancePointAdjustments = async (
entries: typeof inheritancePointAdjustments
): Promise<void> => {
if (entries.length === 0) {
return;
}
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
for (const entry of inheritancePointAdjustments) {
for (const entry of entries) {
const groupKey = `${entry.userId}\u0000${entry.key}`;
const current = grouped.get(groupKey);
if (current) {
current.amount += entry.amount;
} else {
grouped.set(groupKey, { ...entry });
grouped.set(groupKey, {
userId: entry.userId,
key: entry.key,
amount: entry.amount,
});
}
}
for (const entry of grouped.values()) {
@@ -1275,14 +1299,41 @@ export const createDatabaseTurnHooks = async (
create: { userId: entry.userId, key: entry.key, value: entry.amount },
});
}
}
};
const persistInheritanceLogs = async (entries: typeof pendingInheritanceLogs): Promise<void> => {
if (entries.length === 0) {
return;
}
await prisma.inheritanceLog.createMany({
data: entries.map((entry) => ({
userId: entry.userId,
year: entry.year,
month: entry.month,
text: entry.text,
})),
});
};
const beforeLifecycleAdjustments = inheritancePointAdjustments.filter(
(entry) => entry.phase !== 'after_lifecycle'
);
const afterLifecycleAdjustments = inheritancePointAdjustments.filter(
(entry) => entry.phase === 'after_lifecycle'
);
const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle');
const afterLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase === 'after_lifecycle');
await persistInheritancePointAdjustments(beforeLifecycleAdjustments);
await persistInheritanceLogs(beforeLifecycleLogs);
await persistGeneralLifecycleEvents(
prisma,
lifecycleEvents,
meta,
asRecord(world.getScenarioConfig().const),
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0)
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0),
pendingLifecycleArchiveLogs
);
await persistInheritancePointAdjustments(afterLifecycleAdjustments);
await persistInheritanceLogs(afterLifecycleLogs);
if (accessScoreResetGeneralIds.length > 0) {
await prisma.generalAccessLog.updateMany({
@@ -1611,20 +1662,10 @@ export const createDatabaseTurnHooks = async (
await upsertRankRows(prisma, rankRows);
}
if (logs.length > 0) {
const logContext = {
year: state.currentYear,
month: state.currentMonth,
at: state.lastTurnTime,
};
const payload = logs
.map((entry) => buildLogCreateData(entry, logContext))
.filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry));
if (payload.length > 0) {
await prisma.logEntry.createMany({
data: payload,
});
}
if (pendingLogRows.length > 0) {
await prisma.logEntry.createMany({
data: pendingLogRows,
});
}
for (const snapshot of pendingYearbookSnapshots) {
await persistYearbookSnapshot(prisma, snapshot);
@@ -1,9 +1,23 @@
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import {
asRecord,
HALL_OF_FAME_TYPES,
RANK_DATA_TYPES,
rankDataMetaKey,
resolveLegacyTextColor,
type HallOfFameType,
} from '@sammo-ts/common';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { computeInheritanceSettlementBreakdown } from '@sammo-ts/logic/inheritance/pointCalculation.js';
import {
readCentennialRecordableDexterity,
type CentennialDexKey,
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
import type { GeneralLifecycleEvent } from './inMemoryWorld.js';
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js';
import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js';
import { buildPersistedRankRows } from './rankData.js';
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
@@ -24,12 +38,59 @@ const readWorldNumber = (record: Record<string, unknown>, key: string, fallback:
return value === 0 && record[key] === undefined ? fallback : Math.floor(value);
};
type LifecycleRankValues = Map<string, number>;
export interface GeneralLifecycleArchiveLog {
generalId: number;
category: string;
text: string;
}
const loadLifecycleRankValues = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent
): Promise<LifecycleRankValues> => {
const persisted = await prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
});
const values = new Map(persisted.map((row) => [row.type, row.value]));
const snapshotMeta = asRecord(event.before.meta);
for (const row of buildPersistedRankRows(event.before)) {
if (
row.type === 'experience' ||
row.type === 'dedication' ||
Object.prototype.hasOwnProperty.call(snapshotMeta, rankDataMetaKey(row.type))
) {
values.set(row.type, row.value);
}
}
return values;
};
const persistPostRetirementRankValues = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent
): Promise<void> => {
if (!event.after) {
return;
}
for (const row of buildPersistedRankRows(event.after)) {
await prisma.rankData.upsert({
where: { generalId_type: { generalId: row.generalId, type: row.type } },
update: { nationId: row.nationId, value: row.value },
create: row,
});
}
};
const settleInheritance = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>,
isRebirth: boolean,
configConst: Record<string, unknown>
configConst: Record<string, unknown>,
rankValues: ReadonlyMap<string, number>
): Promise<void> => {
const userId = event.before.userId;
if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) {
@@ -53,29 +114,23 @@ const settleInheritance = async (
}
}
const [rows, rankRows] = await Promise.all([
prisma.inheritancePoint.findMany({
where: { userId },
select: { key: true, value: true },
}),
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
]);
const rows = await prisma.inheritancePoint.findMany({
where: { userId },
select: { key: true, value: true },
});
const points = new Map(rows.map((row) => [row.key, row.value]));
const previous = points.get('previous') ?? 0;
const randomUniqueRefund = meta.inheritRandomUnique
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000)
: 0;
const specificSpecialRefund = meta.inheritSpecificSpecialWar
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
: 0;
const randomUniqueRefund =
!isRebirth && meta.inheritRandomUnique ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) : 0;
const specificSpecialRefund =
!isRebirth && meta.inheritSpecificSpecialWar
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
: 0;
const refund = randomUniqueRefund + specificSpecialRefund;
const calculationMeta = {
...Object.fromEntries(rankValues),
...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])),
...meta,
...Object.fromEntries(rankRows.map((row) => [row.type, row.value])),
...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])),
};
const settlement = computeInheritanceSettlementBreakdown(
{
@@ -143,14 +198,22 @@ const settleInheritance = async (
},
});
}
await prisma.inheritanceLog.create({
data: {
userId,
year: event.year,
month: event.month,
text: `${isRebirth ? '은퇴' : '사망'} 정산: ${total.toLocaleString()} 포인트`,
},
});
for (const text of buildInheritanceSettlementLogTexts({
previous: previous + refund,
points: settlement.earned,
storedKeys: new Set([...points.keys(), ...(refund > 0 ? (['previous'] as const) : [])]),
total,
isRebirth,
})) {
await prisma.inheritanceLog.create({
data: {
userId,
year: event.year,
month: event.month,
text,
},
});
}
};
const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0);
@@ -159,26 +222,23 @@ const settleHall = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>,
gameNow: Date
gameNow: Date,
rank: ReadonlyMap<string, number>
): Promise<void> => {
const isUnited = readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0));
const isUnited =
event.isUnitedAtEvent ?? readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0));
if (isUnited !== 0) {
return;
}
const [ranks, nation, historyCount] = await Promise.all([
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
const [nation, serverIdx] = await Promise.all([
event.before.nationId > 0
? prisma.nation.findUnique({
where: { id: event.before.nationId },
select: { name: true, color: true },
})
: null,
prisma.gameHistory.count(),
resolveOfficialGameIndex(prisma, worldMeta),
]);
const rank = new Map(ranks.map((row) => [row.type, row.value]));
const value = (key: string): number => rank.get(key) ?? readNumber(asRecord(event.before.meta), key);
const warnum = value('warnum');
const tt = value('ttw') + value('ttd') + value('ttl');
@@ -221,12 +281,13 @@ const settleHall = async (
picture: event.before.picture ?? null,
imgsvr: event.before.imageServer ?? 0,
serverID: serverId,
serverIdx: historyCount,
serverIdx,
scenarioName,
serverName: typeof worldMeta.serverName === 'string' ? worldMeta.serverName : '',
};
for (const type of HALL_OF_FAME_TYPES) {
const eventMeta = asRecord(event.before.meta);
let hallValue =
type === 'experience'
? event.before.experience
@@ -234,7 +295,9 @@ const settleHall = async (
? event.before.dedication
: type.endsWith('rate')
? (calc[type] ?? 0)
: value(type);
: type.startsWith('dex')
? readCentennialRecordableDexterity(eventMeta, type as CentennialDexKey)
: value(type);
if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue;
if (type === 'ttrate' && tt < 50) continue;
if (type === 'tlrate' && tl < 50) continue;
@@ -244,72 +307,56 @@ const settleHall = async (
if (!Number.isFinite(hallValue) || hallValue <= 0) continue;
hallValue = Number(hallValue);
const existing = await prisma.hallOfFame.findUnique({
where: {
serverId_type_generalNo: {
serverId,
type: type as HallOfFameType,
generalNo: event.generalId,
},
},
});
if (existing) {
if (hallValue > existing.value) {
await prisma.hallOfFame.update({
where: { id: existing.id },
data: { value: hallValue, aux: asJson(aux) },
});
}
continue;
}
await prisma.hallOfFame.createMany({
data: [
{
serverId,
season,
scenario,
generalNo: event.generalId,
type,
value: hallValue,
owner: event.before.userId ?? null,
aux: asJson(aux),
},
],
skipDuplicates: true,
await persistHallOfFameCandidate(prisma, {
serverId,
season,
scenario,
generalNo: event.generalId,
type: type as HallOfFameType,
value: hallValue,
owner: event.before.userId ?? null,
aux,
});
}
};
const archiveDeletedGeneral = async (
const archiveGeneral = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>
worldMeta: Record<string, unknown>,
rankValues: ReadonlyMap<string, number>,
pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[]
): Promise<void> => {
const serverId =
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
const [recordRows, rankRows] = await Promise.all([
prisma.logEntry.findMany({
where: {
generalId: event.generalId,
scope: LogScope.GENERAL,
category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
},
orderBy: { id: 'desc' },
select: { category: true, text: true },
}),
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
]);
const recordRows = await prisma.logEntry.findMany({
where: {
generalId: event.generalId,
scope: LogScope.GENERAL,
category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
},
orderBy: { id: 'desc' },
select: { category: true, text: true },
});
const archivedMeta = {
...asRecord(event.before.meta),
...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])),
...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])),
};
delete archivedMeta.inheritRandomUnique;
delete archivedMeta.inheritSpecificSpecialWar;
const history = recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text);
const battleResults = recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text);
const pendingGeneralLogs = pendingArchiveLogs.filter((row) => row.generalId === event.generalId);
const history = [
...pendingGeneralLogs
.filter((row) => row.category === LogCategory.HISTORY)
.map((row) => row.text)
.reverse(),
...recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text),
];
const battleResults = [
...pendingGeneralLogs
.filter((row) => row.category === LogCategory.BATTLE_BRIEF)
.map((row) => row.text)
.reverse(),
...recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text),
];
const data = {
...event.before,
meta: archivedMeta,
@@ -345,7 +392,8 @@ export const persistGeneralLifecycleEvents = async (
events: GeneralLifecycleEvent[],
worldMeta: Record<string, unknown>,
configConst: Record<string, unknown>,
gameNow = new Date()
gameNow = new Date(),
pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[] = []
): Promise<void> => {
if (events.length === 0) {
return;
@@ -359,17 +407,18 @@ export const persistGeneralLifecycleEvents = async (
if (event.outcome === 'detached' || event.outcome === 'deleted') {
await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } });
}
if (event.outcome !== 'deleted' && event.outcome !== 'retired') {
continue;
}
const rankValues = await loadLifecycleRankValues(prisma, event);
if (event.outcome === 'deleted') {
await archiveDeletedGeneral(prisma, event, worldMeta);
await settleInheritance(prisma, event, worldMeta, false, configConst);
await settleInheritance(prisma, event, worldMeta, false, configConst, rankValues);
await archiveGeneral(prisma, event, worldMeta, rankValues, pendingArchiveLogs);
}
if (event.outcome === 'retired') {
await settleHall(prisma, event, worldMeta, gameNow);
await settleInheritance(prisma, event, worldMeta, true, configConst);
await prisma.rankData.updateMany({
where: { generalId: event.generalId },
data: { value: 0 },
});
await settleHall(prisma, event, worldMeta, gameNow, rankValues);
await settleInheritance(prisma, event, worldMeta, true, configConst, rankValues);
await persistPostRetirementRankValues(prisma, event);
}
}
};
@@ -0,0 +1,83 @@
import type { HallOfFameType } from '@sammo-ts/common';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
const readInteger = (value: unknown, fallback: number): number => {
const parsed = typeof value === 'string' ? Number(value) : value;
return typeof parsed === 'number' && Number.isFinite(parsed) ? Math.floor(parsed) : fallback;
};
/**
* `gameIdx` is fixed when RESET opens a game and deliberately excludes
* retained ABANDONED rows. Older fixtures may not carry it, so reconstruct the
* same sequence from completed games plus the configured first index.
*/
export const resolveOfficialGameIndex = async (
prisma: GamePrisma.TransactionClient,
worldMeta: Record<string, unknown>
): Promise<number> => {
if (worldMeta.gameIdx !== undefined) {
return readInteger(worldMeta.gameIdx, 0);
}
const completedGames = await prisma.gameHistory.count({ where: { status: 'COMPLETED' } });
return completedGames + readInteger(worldMeta.firstGameIdx, 1);
};
export interface HallOfFameCandidate {
serverId: string;
season: number;
scenario: number;
generalNo: number;
type: HallOfFameType;
value: number;
owner: string | null;
aux: unknown;
}
/**
* Ref insertIgnore treats an owner record belonging to another general as a
* complete winner: it does not reassign that row even when the new value is
* higher. Only an existing row for the same general and scenario may replace
* value+aux, keeping every identity column unchanged. This avoids the former
* Core state where an old general number was combined with a new general's aux.
*/
export const persistHallOfFameCandidate = async (
prisma: GamePrisma.TransactionClient,
candidate: HallOfFameCandidate
): Promise<'CREATED' | 'UPDATED' | 'PRESERVED'> => {
const matches = await prisma.hallOfFame.findMany({
where: {
OR: [
{ serverId: candidate.serverId, type: candidate.type, generalNo: candidate.generalNo },
...(candidate.owner
? [{ serverId: candidate.serverId, type: candidate.type, owner: candidate.owner }]
: []),
],
},
});
const sameGeneral = matches.find((entry) => entry.generalNo === candidate.generalNo);
if (!sameGeneral && matches.length > 0) {
return 'PRESERVED';
}
if (!sameGeneral) {
await prisma.hallOfFame.create({
data: {
...candidate,
aux: asJson(candidate.aux),
},
});
return 'CREATED';
}
if (sameGeneral.scenario !== candidate.scenario || candidate.value <= sameGeneral.value) {
return 'PRESERVED';
}
await prisma.hallOfFame.update({
where: { id: sameGeneral.id },
data: {
value: candidate.value,
aux: asJson(candidate.aux),
},
});
return 'UPDATED';
};
+47 -5
View File
@@ -83,6 +83,8 @@ export interface GeneralLifecycleEvent {
outcome: 'active' | 'detached' | 'deleted' | 'retired';
before: TurnGeneral;
after?: TurnGeneral;
/** World unification state observed when this lifecycle transition occurred. */
isUnitedAtEvent?: number;
year: number;
month: number;
}
@@ -123,6 +125,23 @@ export interface InMemoryGameClockState {
lastTurnTick: number;
}
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
export interface PendingInheritancePointAdjustment {
userId: string;
key: string;
amount: number;
phase?: InheritancePersistencePhase;
}
export interface PendingInheritanceLog {
userId: string;
year: number;
month: number;
text: string;
phase?: InheritancePersistencePhase;
}
export interface TurnWorldChanges {
realtimeBacklogShiftTicks: number;
accessScoreResetGeneralIds: number[];
@@ -145,7 +164,8 @@ export interface TurnWorldChanges {
deletedEvents: number[];
lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[];
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
inheritancePointAdjustments: PendingInheritancePointAdjustment[];
pendingInheritanceLogs: PendingInheritanceLog[];
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
@@ -184,7 +204,8 @@ export interface InMemoryTurnWorldStateSnapshot {
messages: MessageDraft[];
lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[];
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
inheritancePointAdjustments: PendingInheritancePointAdjustment[];
pendingInheritanceLogs: PendingInheritanceLog[];
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
@@ -487,7 +508,8 @@ export class InMemoryTurnWorld {
private readonly messages: MessageDraft[] = [];
private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
private readonly inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }> = [];
private readonly inheritancePointAdjustments: PendingInheritancePointAdjustment[] = [];
private readonly pendingInheritanceLogs: PendingInheritanceLog[] = [];
private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = [];
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
@@ -786,6 +808,7 @@ export class InMemoryTurnWorld {
lifecycleEvents: this.lifecycleEvents,
pendingNeutralAuctions: this.pendingNeutralAuctions,
inheritancePointAdjustments: this.inheritancePointAdjustments,
pendingInheritanceLogs: this.pendingInheritanceLogs,
pendingNationBettingOpens: this.pendingNationBettingOpens,
pendingNationBettingFinishes: this.pendingNationBettingFinishes,
pendingYearbookSnapshots: this.pendingYearbookSnapshots,
@@ -831,6 +854,7 @@ export class InMemoryTurnWorld {
this.replaceArray(this.lifecycleEvents, restored.lifecycleEvents);
this.replaceArray(this.pendingNeutralAuctions, restored.pendingNeutralAuctions);
this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments);
this.replaceArray(this.pendingInheritanceLogs, restored.pendingInheritanceLogs ?? []);
this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens);
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
@@ -990,11 +1014,23 @@ export class InMemoryTurnWorld {
});
}
queueInheritancePointAdjustment(userId: string, key: string, amount: number): void {
queueInheritancePointAdjustment(
userId: string,
key: string,
amount: number,
phase?: InheritancePersistencePhase
): void {
if (!userId || !Number.isFinite(amount) || amount === 0) {
return;
}
this.inheritancePointAdjustments.push({ userId, key, amount });
this.inheritancePointAdjustments.push({ userId, key, amount, ...(phase ? { phase } : {}) });
}
queueInheritanceLog(log: PendingInheritanceLog): void {
if (!log.userId || !log.text) {
return;
}
this.pendingInheritanceLogs.push({ ...log });
}
queueNationBettingOpen(betting: PendingNationBettingOpen): void {
@@ -1271,6 +1307,9 @@ export class InMemoryTurnWorld {
generalId: id,
outcome: 'deleted',
before: structuredClone(general),
isUnitedAtEvent: Math.floor(
readMetaNumber(this.state.meta, 'isunited') ?? readMetaNumber(this.state.meta, 'isUnited') ?? 0
),
year,
month,
});
@@ -1811,6 +1850,7 @@ export class InMemoryTurnWorld {
closeAt: new Date(auction.closeAt.getTime()),
}));
const inheritancePointAdjustments = this.inheritancePointAdjustments.map((entry) => ({ ...entry }));
const pendingInheritanceLogs = this.pendingInheritanceLogs.map((entry) => ({ ...entry }));
const pendingNationBettingOpens = this.pendingNationBettingOpens.map((entry) => ({
...entry,
candidates: entry.candidates.map((candidate) => ({
@@ -1852,6 +1892,7 @@ export class InMemoryTurnWorld {
lifecycleEvents,
pendingNeutralAuctions,
inheritancePointAdjustments,
pendingInheritanceLogs,
pendingNationBettingOpens,
pendingNationBettingFinishes,
pendingYearbookSnapshots,
@@ -1889,6 +1930,7 @@ export class InMemoryTurnWorld {
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length);
this.pendingInheritanceLogs.splice(0, changes.pendingInheritanceLogs.length);
this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length);
this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length);
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length);
@@ -0,0 +1,670 @@
import {
asNumber,
asRecord,
LiteHashDRBG,
parseJson,
RandUtil,
rankDataMetaKey,
type TurnDaemonCommand,
type TurnDaemonCommandResult,
type TurnDaemonInheritanceAction,
} from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import {
isCentennialStatResetAllowed,
isWarTraitKey,
loadWarTraitModules,
resolveMessageTargetIcon,
WarTraitLoader,
type InheritBuffType,
type MessageDraft,
type MessageTarget,
} from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
type InheritanceActionCommand = Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>;
type InheritanceActionResult = Extract<TurnDaemonCommandResult, { type: 'inheritanceAction' }>;
interface InheritConstants {
inheritBornStatPoint: number;
inheritItemRandomPoint: number;
inheritBuffPoints: number[];
inheritSpecificSpecialPoint: number;
inheritResetAttrPointBase: number[];
inheritCheckOwnerPoint: number;
}
const DEFAULT_INHERIT_CONST: InheritConstants = {
inheritBornStatPoint: 1_000,
inheritItemRandomPoint: 3_000,
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
inheritSpecificSpecialPoint: 4_000,
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
inheritCheckOwnerPoint: 1_000,
};
const BUFF_LABELS: Record<InheritBuffType, string> = {
warAvoidRatio: '회피 확률 증가',
warCriticalRatio: '필살 확률 증가',
warMagicTrialProb: '전투계략 시도 확률 증가',
domesticSuccessProb: '내정 성공률 증가',
domesticFailProb: '내정 실패율 감소',
warAvoidRatioOppose: '상대 회피 확률 감소',
warCriticalRatioOppose: '상대 필살 확률 감소',
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
};
const SYSTEM_TARGET: MessageTarget = {
generalId: 0,
generalName: '',
nationId: 0,
nationName: 'System',
color: '#000000',
icon: '',
};
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const resolveNumberArray = (value: unknown, fallback: number[]): number[] => {
if (!Array.isArray(value)) return [...fallback];
const result = value
.map((entry) => (typeof entry === 'number' && Number.isFinite(entry) ? entry : null))
.filter((entry): entry is number => entry !== null);
return result.length > 0 ? result : [...fallback];
};
const resolveInheritConstants = (world: InMemoryTurnWorld): InheritConstants => {
const configConst = asRecord(world.getScenarioConfig().const);
return {
inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, DEFAULT_INHERIT_CONST.inheritBornStatPoint),
inheritItemRandomPoint: asNumber(
configConst.inheritItemRandomPoint,
DEFAULT_INHERIT_CONST.inheritItemRandomPoint
),
inheritBuffPoints: resolveNumberArray(configConst.inheritBuffPoints, DEFAULT_INHERIT_CONST.inheritBuffPoints),
inheritSpecificSpecialPoint: asNumber(
configConst.inheritSpecificSpecialPoint,
DEFAULT_INHERIT_CONST.inheritSpecificSpecialPoint
),
inheritResetAttrPointBase: resolveNumberArray(
configConst.inheritResetAttrPointBase,
DEFAULT_INHERIT_CONST.inheritResetAttrPointBase
),
inheritCheckOwnerPoint: asNumber(
configConst.inheritCheckOwnerPoint,
DEFAULT_INHERIT_CONST.inheritCheckOwnerPoint
),
};
};
const buildResetCost = (baseCosts: number[], level: number): number => {
const costs = [...baseCosts];
while (costs.length <= level) {
const size = costs.length;
costs.push((costs[size - 1] ?? 0) + (costs[size - 2] ?? 0));
}
return costs[level] ?? 0;
};
const readBuffRecord = (raw: unknown): Record<string, number> => {
const source = typeof raw === 'string' ? (parseJson<Record<string, unknown>>(raw) ?? {}) : asRecord(raw);
return Object.fromEntries(
Object.entries(source).filter((entry): entry is [string, number] => {
const value = entry[1];
return typeof value === 'number' && Number.isFinite(value);
})
);
};
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
};
const readStringList = (raw: unknown): string[] => {
const value = typeof raw === 'string' ? parseJson<unknown>(raw) : raw;
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
};
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
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 fallback;
};
const resolveSeasonValue = (meta: Record<string, unknown>): number | null => {
const value = meta.season;
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 readResetSeasons = (meta: Record<string, unknown>): number[] =>
Array.isArray(meta.last_stat_reset)
? meta.last_stat_reset
.map((value) => (typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null))
.filter((value): value is number => value !== null)
: [];
export const buildResetStatRandomBonus = (
rng: RandUtil,
baseStats: [number, number, number]
): [number, number, number] => {
const bonusCount = rng.nextRangeInt(3, 5);
const bonus = [0, 0, 0] as [number, number, number];
for (let index = 0; index < bonusCount; index += 1) {
const selected = Number(
rng.choiceUsingWeight({
0: baseStats[0],
1: baseStats[1],
2: baseStats[2],
})
) as 0 | 1 | 2;
bonus[selected] += 1;
}
return bonus;
};
const formatTurnTimeBaseLabel = (value: number): string => {
const wholeSeconds = Math.trunc(value);
const hours = String(Math.trunc(wholeSeconds / 3_600)).padStart(2, '0');
const minutes = String(Math.trunc((wholeSeconds % 3_600) / 60)).padStart(2, '0');
return `${hours}:${minutes}`;
};
const resolveResetTurnTimeBase = (options: {
hiddenSeed: string | number;
userId: string;
previousTurnTimeBase: string | number;
tickSeconds: number;
}): { nextTurnTimeBase: number; nextTurnTimeLabel: string } => {
const rng = new LiteHashDRBG(
simpleSerialize(options.hiddenSeed, 'ResetTurnTime', options.userId, options.previousTurnTimeBase)
);
const nextTurnTimeBase = rng.nextFloat1() * Math.max(60, options.tickSeconds);
return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) };
};
const reject = (
action: TurnDaemonInheritanceAction['action'],
code: Extract<InheritanceActionResult, { ok: false }>['code'],
reason: string
): InheritanceActionResult => ({ type: 'inheritanceAction', ok: false, action, code, reason });
const lockPreviousPoint = async (db: GamePrisma.TransactionClient, userId: string): Promise<number> => {
const rows = await db.$queryRaw<Array<{ value: number }>>(GamePrisma.sql`
SELECT value
FROM inheritance_point
WHERE user_id = ${userId} AND key = 'previous'
FOR UPDATE
`);
return rows[0]?.value ?? 0;
};
const appendInheritanceLog = async (
db: GamePrisma.TransactionClient,
userId: string,
year: number,
month: number,
text: string
): Promise<void> => {
await db.inheritanceLog.create({ data: { userId, year, month, text } });
};
const buildMessageTarget = (world: InMemoryTurnWorld, general: TurnGeneral): MessageTarget => {
const nation = general.nationId > 0 ? world.getNationById(general.nationId) : null;
return {
generalId: general.id,
generalName: general.name,
nationId: general.nationId,
nationName: nation?.name ?? '재야',
color: nation?.color ?? '#000000',
icon: resolveMessageTargetIcon(general),
};
};
const queueOwnerLookupMessages = (
world: InMemoryTurnWorld,
actor: TurnGeneral,
target: TurnGeneral,
ownerName: string,
gameNow: Date
): void => {
const validUntil = new Date('9999-12-31T00:00:00.000Z');
const messages: MessageDraft[] = [
{
msgType: 'private',
src: SYSTEM_TARGET,
dest: buildMessageTarget(world, actor),
text: `${target.name}의 소유자는 ${ownerName} 입니다.`,
time: gameNow,
validUntil,
option: {},
sendDestOnly: true,
},
{
msgType: 'private',
src: SYSTEM_TARGET,
dest: buildMessageTarget(world, target),
text: '소유자명이 누군가에 의해 확인되었습니다.',
time: gameNow,
validUntil,
option: {},
sendDestOnly: true,
},
];
for (const message of messages) world.queueMessage(message);
};
const applyCharge = (options: {
world: InMemoryTurnWorld;
general: TurnGeneral;
userId: string;
previousPoint: number;
cost: number;
patch: Partial<TurnGeneral>;
}): TurnGeneral => {
const { world, general, userId, previousPoint, cost, patch } = options;
const patchMeta = patch.meta ? asRecord(patch.meta) : general.meta;
const spentKey = rankDataMetaKey('inherit_spent_dyn');
const nextMeta = {
...patchMeta,
[spentKey]: readMetaNumber(general.meta, spentKey, 0) + cost,
} as TurnGeneral['meta'];
const next = world.updateGeneral(general.id, {
...patch,
meta: nextMeta,
inheritancePoints: {
...general.inheritancePoints,
previous: previousPoint - cost,
},
});
if (!next) throw new Error(`Inheritance action general ${general.id} disappeared during mutation.`);
world.queueInheritancePointAdjustment(userId, 'previous', -cost);
return next;
};
const isUnited = (world: InMemoryTurnWorld): boolean => {
const meta = asRecord(world.getState().meta);
return asNumber(meta.isunited, 0) !== 0 || asNumber(meta.isUnited, 0) !== 0;
};
export const resolveOwnerDisplayName = (rawMeta: unknown): string => {
const meta = asRecord(rawMeta);
for (const key of ['ownerDisplayName', 'owner_name', 'ownerName']) {
const value = meta[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
}
return '알수없음';
};
export const executeInheritanceAction = async (options: {
db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld;
command: InheritanceActionCommand;
gameNow: Date;
}): Promise<InheritanceActionResult> => {
const { db, world, command, gameNow } = options;
const { input, userId } = command;
const action = input.action;
const general = world.listGenerals().find((candidate) => candidate.userId === userId);
if (!general) return reject(action, 'PRECONDITION_FAILED', '장수가 존재하지 않습니다.');
const state = world.getState();
const worldMeta = asRecord(state.meta);
const config = world.getScenarioConfig();
const configRecord = asRecord(config);
const constants = resolveInheritConstants(world);
if (action === 'checkOwner') {
if (input.targetGeneralId === general.id) {
return reject(action, 'BAD_REQUEST', '자신의 정보는 확인할 수 없습니다.');
}
const target = world.getGeneralById(input.targetGeneralId);
if (!target) return reject(action, 'BAD_REQUEST', '대상 장수가 존재하지 않습니다.');
if (!target.userId) return reject(action, 'BAD_REQUEST', '대상 장수는 NPC입니다.');
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const previousPoint = await lockPreviousPoint(db, userId);
const cost = constants.inheritCheckOwnerPoint;
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const ownerName = resolveOwnerDisplayName(target.meta);
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 장수 소유자 확인`
);
queueOwnerLookupMessages(world, general, target, ownerName, gameNow);
applyCharge({ world, general, userId, previousPoint, cost, patch: {} });
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
ownerName,
targetName: target.name,
};
}
if (action === 'buyHiddenBuff') {
const buff = readBuffRecord(general.meta.inheritBuff);
const previousLevel = readBuffLevel(buff, input.buffType);
if (input.level === previousLevel) return reject(action, 'BAD_REQUEST', '이미 구입했습니다.');
if (input.level < previousLevel) return reject(action, 'BAD_REQUEST', '이미 더 높은 등급을 구입했습니다.');
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const cost =
(constants.inheritBuffPoints[input.level] ?? 0) - (constants.inheritBuffPoints[previousLevel] ?? 0);
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const moreText = previousLevel > 0 ? '추가' : '';
buff[input.buffType] = input.level;
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 ${BUFF_LABELS[input.buffType]} ${input.level} 단계 ${moreText}구입`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: { meta: { ...general.meta, inheritBuff: JSON.stringify(buff) } },
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
};
}
if (action === 'setNextSpecialWar') {
if (!isWarTraitKey(input.specialKey)) return reject(action, 'BAD_REQUEST', '잘못된 전투 특기입니다.');
const configConst = asRecord(config.const);
const allowed = Array.isArray(configConst.availableSpecialWar)
? configConst.availableSpecialWar.filter((key): key is string => typeof key === 'string')
: [];
if (allowed.length > 0 && !allowed.includes(input.specialKey)) {
return reject(action, 'BAD_REQUEST', '허용되지 않은 전투 특기입니다.');
}
if (general.role.specialWar === input.specialKey) {
return reject(action, 'BAD_REQUEST', '이미 그 특기를 보유하고 있습니다.');
}
const reserved =
typeof general.meta.inheritSpecificSpecialWar === 'string' ? general.meta.inheritSpecificSpecialWar : null;
if (reserved === input.specialKey) return reject(action, 'BAD_REQUEST', '이미 그 특기를 예약하였습니다.');
if (reserved) return reject(action, 'BAD_REQUEST', '이미 예약한 특기가 있습니다.');
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const cost = constants.inheritSpecificSpecialPoint;
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader());
const warName = warModule?.name ?? input.specialKey;
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 다음 전투 특기로 ${warName} 지정`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: { meta: { ...general.meta, inheritSpecificSpecialWar: input.specialKey } },
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
};
}
if (action === 'resetSpecialWar') {
const currentSpecial = general.role.specialWar;
if (!currentSpecial || currentSpecial === 'None') {
return reject(action, 'BAD_REQUEST', '이미 전투 특기가 공란입니다.');
}
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const currentLevel = readMetaNumber(general.meta, 'inheritResetSpecialWar', -1);
const nextLevel = currentLevel + 1;
const cost = buildResetCost(constants.inheritResetAttrPointBase, nextLevel);
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const previousTypes = readStringList(general.meta.prev_types_special2);
previousTypes.push(currentSpecial);
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 전투 특기 초기화`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: {
role: { ...general.role, specialWar: null },
meta: {
...general.meta,
inheritResetSpecialWar: nextLevel,
prev_types_special2: previousTypes,
},
},
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
};
}
if (action === 'resetTurnTime') {
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const currentLevel = readMetaNumber(general.meta, 'inheritResetTurnTime', -1);
const nextLevel = currentLevel + 1;
const cost = buildResetCost(constants.inheritResetAttrPointBase, nextLevel);
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const rawSeedTurnTime = general.meta.nextTurnTimeBase ?? general.turnTick ?? 0;
const seedTurnTime =
typeof rawSeedTurnTime === 'string' || typeof rawSeedTurnTime === 'number'
? rawSeedTurnTime
: typeof rawSeedTurnTime === 'bigint'
? Number(rawSeedTurnTime)
: 0;
const hiddenSeed =
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
? worldMeta.hiddenSeed
: 'inherit';
const timing = resolveResetTurnTimeBase({
hiddenSeed,
userId,
previousTurnTimeBase: seedTurnTime,
tickSeconds: state.tickSeconds,
});
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${timing.nextTurnTimeLabel} 적용`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: {
meta: {
...general.meta,
inheritResetTurnTime: nextLevel,
nextTurnTimeBase: timing.nextTurnTimeBase,
},
},
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
...timing,
};
}
if (action === 'resetStat') {
const statConfig = asRecord(configRecord.stat);
const statTotal = asNumber(statConfig.total, input.leadership + input.strength + input.intel);
const statMin = asNumber(statConfig.min, 1);
const statMax = asNumber(statConfig.max, 999);
if (input.leadership + input.strength + input.intel !== statTotal) {
return reject(action, 'BAD_REQUEST', `능력치 총합이 ${statTotal}이 아닙니다. 다시 입력해주세요!`);
}
if (
input.leadership < statMin ||
input.strength < statMin ||
input.intel < statMin ||
input.leadership > statMax ||
input.strength > statMax ||
input.intel > statMax
) {
return reject(action, 'BAD_REQUEST', '능력치 범위를 벗어났습니다.');
}
const bonus = input.inheritBonusStat ?? [0, 0, 0];
const bonusSum = bonus.reduce((sum, value) => sum + value, 0);
if (bonus.some((value) => value < 0)) {
return reject(action, 'BAD_REQUEST', '보너스 능력치가 음수입니다. 다시 입력해주세요!');
}
if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) {
return reject(action, 'BAD_REQUEST', '보너스 능력치 합이 잘못 지정되었습니다. 다시 입력해주세요!');
}
if (general.npcState !== 0) return reject(action, 'BAD_REQUEST', 'NPC는 능력치 초기화를 할 수 없습니다.');
if (!isCentennialStatResetAllowed(config)) {
return reject(action, 'BAD_REQUEST', '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.');
}
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const cost = bonusSum > 0 ? constants.inheritBornStatPoint : 0;
const season = resolveSeasonValue(worldMeta);
const userStateRow =
season === null
? null
: await db.inheritanceUserState.findUnique({ where: { userId }, select: { meta: true } });
const userState = asRecord(userStateRow?.meta);
const resetSeasons = readResetSeasons(userState);
if (season !== null && resetSeasons.includes(season)) {
return reject(action, 'BAD_REQUEST', '이번 시즌에 이미 능력치를 초기화하셨습니다.');
}
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const statHiddenSeed =
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
? worldMeta.hiddenSeed
: 'inherit';
const baseStats = [input.leadership, input.strength, input.intel] as [number, number, number];
const finalBonus =
bonusSum === 0
? buildResetStatRandomBonus(
new RandUtil(new LiteHashDRBG(simpleSerialize(statHiddenSeed, 'ResetStat', userId))),
baseStats
)
: (bonus as [number, number, number]);
const nextStats = {
leadership: input.leadership + finalBonus[0],
strength: input.strength + finalBonus[1],
intel: input.intel + finalBonus[2],
};
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`통솔 ${input.leadership}, 무력 ${input.strength}, 지력 ${input.intel} 스탯 재설정`
);
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
bonusSum > 0
? `${cost}로 통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
: `통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
);
if (season !== null) {
await db.inheritanceUserState.upsert({
where: { userId },
update: { meta: asJson({ ...userState, last_stat_reset: [...resetSeasons, season] }) },
create: { userId, meta: asJson({ ...userState, last_stat_reset: [...resetSeasons, season] }) },
});
}
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: {
stats: {
leadership: nextStats.leadership,
strength: nextStats.strength,
intelligence: nextStats.intel,
},
},
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
stats: nextStats,
};
}
if (general.meta.inheritRandomUnique !== undefined && general.meta.inheritRandomUnique !== null) {
return reject(action, 'BAD_REQUEST', '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.');
}
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const previousPoint = await lockPreviousPoint(db, userId);
const cost = constants.inheritItemRandomPoint;
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
await appendInheritanceLog(db, userId, state.currentYear, state.currentMonth, `${cost} 포인트로 랜덤 유니크 구입`);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: { meta: { ...general.meta, inheritRandomUnique: 1 } },
});
return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost };
};
@@ -0,0 +1,63 @@
import {
REBIRTH_INHERITANCE_COEFFICIENTS,
type MergedInheritanceKey,
} from '@sammo-ts/logic/inheritance/pointCalculation.js';
const LEGACY_KEY_ORDER = [
'lived_month',
'max_belong',
'max_domestic_critical',
'active_action',
'combat',
'sabotage',
'unifier',
'dex',
'tournament',
'betting',
] as const satisfies readonly MergedInheritanceKey[];
const LEGACY_CALCULATED_KEYS = new Set<MergedInheritanceKey>(['max_belong', 'combat', 'sabotage', 'dex', 'betting']);
const LEGACY_KEY_LABEL: Readonly<Record<'previous' | MergedInheritanceKey, string>> = {
previous: '기존 보유',
lived_month: '생존',
max_belong: '최대 임관년 수',
max_domestic_critical: '최대 연속 내정 성공',
active_action: '능동 행동 수',
combat: '전투 횟수',
sabotage: '계략 성공 횟수',
unifier: '천통 기여',
dex: '숙련도',
tournament: '토너먼트',
betting: '베팅 당첨',
};
const formatLegacyPoint = (value: number): string => {
if (!Number.isFinite(value)) {
return '0';
}
return String(Object.is(value, -0) ? 0 : value);
};
export const buildInheritanceSettlementLogTexts = (input: {
previous: number;
points: Readonly<Partial<Record<MergedInheritanceKey, number>>>;
storedKeys: ReadonlySet<string>;
total: number;
isRebirth: boolean;
}): string[] => {
const texts = input.storedKeys.has('previous')
? [`${LEGACY_KEY_LABEL.previous} 포인트 ${formatLegacyPoint(input.previous)} 증가`]
: [];
for (const key of LEGACY_KEY_ORDER) {
if (!LEGACY_CALCULATED_KEYS.has(key) && !input.storedKeys.has(key)) {
continue;
}
if (input.isRebirth && REBIRTH_INHERITANCE_COEFFICIENTS[key] === null) {
continue;
}
texts.push(`${LEGACY_KEY_LABEL[key]} 포인트 ${formatLegacyPoint(input.points[key] ?? 0)} 증가`);
}
texts.push(`포인트 ${formatLegacyPoint(input.previous)} => ${formatLegacyPoint(input.total)}`);
return texts;
};
@@ -377,7 +377,11 @@ export const createUpdateNationLevelHandler = (options: {
const isUnited = readNumber(state.meta.isunited ?? state.meta.isUnited);
if (chief?.userId && chief.npcState < 2 && isUnited === 0) {
const amount = 250 * levelDiff;
world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount);
// General turns (including rebirth settlement) finish before
// monthly actions in the processor. Persist this award after
// lifecycle so the retirement result cannot claim a later
// promotion as pre-rebirth retained state.
world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount, 'after_lifecycle');
world.updateGeneral(chief.id, {
inheritancePoints: {
...chief.inheritancePoints,
@@ -32,7 +32,7 @@ import {
loadItemModules,
resolveUniqueConfig,
readScenarioGeneralPoolClaim,
rollUniqueLottery,
rollUniqueLotteryDetailed,
getNextTurnAt,
getBillByLevel,
LEGACY_DEFAULT_MAX_LEVEL,
@@ -479,6 +479,8 @@ const buildUniqueLotteryRunner = (options: {
seedBase: string;
itemRegistry: Map<string, ItemModule>;
uniqueConfig: ReturnType<typeof resolveUniqueConfig>;
inheritItemRandomPoint: number;
inheritanceWorld?: InMemoryTurnWorld | null;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
}): UniqueLotteryRunner => {
if (!options.worldView) {
@@ -523,7 +525,7 @@ const buildUniqueLotteryRunner = (options: {
const relMonthByInit =
joinYearMonth(world.currentYear, world.currentMonth) - joinYearMonth(initYear, initMonth);
const availableBuyUnique = relMonthByInit >= minMonthToAllowInherit;
const itemKey = rollUniqueLottery({
const outcome = rollUniqueLotteryDetailed({
rng,
config: options.uniqueConfig,
itemRegistry: options.itemRegistry,
@@ -539,13 +541,54 @@ const buildUniqueLotteryRunner = (options: {
acquireType,
inheritRandomUnique,
});
if (!itemKey) {
if (outcome.status === 'NO_SLOT' || outcome.status === 'NO_SUPPLY') {
if (inheritRandomUnique) {
const turnGeneral = general as TurnGeneral;
const cost = options.inheritItemRandomPoint;
const nextMeta = {
...turnGeneral.meta,
// Explicit retirement resets every rank before this lottery in Ref,
// so a failed pending purchase leaves the post-rebirth delta at -cost.
inherit_spent_dyn:
reason === '은퇴'
? -cost
: readMetaNumber(asRecord(turnGeneral.meta), 'inherit_spent_dyn', 0) - cost,
} as TurnGeneral['meta'];
delete nextMeta.inheritRandomUnique;
turnGeneral.meta = nextMeta;
turnGeneral.inheritancePoints = {
...turnGeneral.inheritancePoints,
previous: readInheritanceNumber(turnGeneral.inheritancePoints?.previous) + cost,
};
if (turnGeneral.userId) {
const persistencePhase = reason === '은퇴' ? 'after_lifecycle' : undefined;
options.inheritanceWorld?.queueInheritancePointAdjustment(
turnGeneral.userId,
'previous',
cost,
persistencePhase
);
options.inheritanceWorld?.queueInheritanceLog({
userId: turnGeneral.userId,
year: world.currentYear,
month: world.currentMonth,
text:
outcome.status === 'NO_SLOT'
? `유니크를 얻을 공간이 없어 ${cost} 포인트 반환`
: `얻을 유니크가 없어 ${cost} 포인트 반환`,
...(persistencePhase ? { phase: persistencePhase } : {}),
});
}
}
return null;
}
if (outcome.status === 'ROLL_FAILED') {
return null;
}
if (inheritRandomUnique && availableBuyUnique) {
delete asRecord(general.meta).inheritRandomUnique;
}
return options.itemRegistry.get(itemKey) ?? null;
return options.itemRegistry.get(outcome.itemKey) ?? null;
};
};
@@ -885,6 +928,11 @@ export const createReservedTurnHandler = async (options: {
const env = options.commandEnv ?? buildCommandEnv(options.scenarioConfig, options.unitSet);
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const));
const inheritItemRandomPoint = readMetaNumber(
asRecord(options.scenarioConfig.const),
'inheritItemRandomPoint',
3_000
);
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
@@ -1133,6 +1181,8 @@ export const createReservedTurnHandler = async (options: {
seedBase,
itemRegistry,
uniqueConfig,
inheritItemRandomPoint,
inheritanceWorld: worldRef,
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
});
let actionRng = sharedActionRng ?? buildRng(actionKey);
@@ -2086,6 +2136,10 @@ export const createReservedTurnHandler = async (options: {
}
generalAiState = ai.getDebugState();
}
// che_은퇴 performs the rebirth inside the action, as Ref does. Preserve
// the fully accumulated pre-command state so lifecycle persistence can
// settle Hall/inheritance before observing that reset.
const explicitRetirementSnapshot = cloneTurnGeneral(currentGeneral);
const generalActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
const generalResult = isBlocked
? {
@@ -2176,7 +2230,10 @@ export const createReservedTurnHandler = async (options: {
delete currentGeneral.meta.nextTurnTimeBase;
}
let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = 'active';
const explicitlyRetired = generalResult.actionKey === 'che_은퇴' && generalResult.completed;
let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = explicitlyRetired
? 'retired'
: 'active';
let deleteGeneral = false;
const deletedTroopIds = Array.from(commandDeletedTroopIds);
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
@@ -2353,8 +2410,18 @@ export const createReservedTurnHandler = async (options: {
lifecycleEvent: {
generalId: currentGeneral.id,
outcome: lifecycleOutcome,
before: lifecycleOutcome === 'active' ? lifecycleBefore : lifecycleSnapshot,
before:
lifecycleOutcome === 'active'
? lifecycleBefore
: explicitlyRetired
? explicitRetirementSnapshot
: lifecycleSnapshot,
...(deleteGeneral ? {} : { after: currentGeneral }),
isUnitedAtEvent: readMetaNumber(
asRecord(context.world.meta),
'isunited',
readMetaNumber(asRecord(context.world.meta), 'isUnited', 0)
),
year: context.world.currentYear,
month: context.world.currentMonth,
},
@@ -2418,6 +2485,11 @@ export const createImmediateGeneralActionExecutor = async (options: {
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.world.getScenarioConfig().const));
const inheritItemRandomPoint = readMetaNumber(
asRecord(options.world.getScenarioConfig().const),
'inheritItemRandomPoint',
3_000
);
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
@@ -2488,6 +2560,8 @@ export const createImmediateGeneralActionExecutor = async (options: {
seedBase,
itemRegistry,
uniqueConfig,
inheritItemRandomPoint,
inheritanceWorld: options.world,
getAdditionalOccupiedUniqueItemKeys: () => additionalOccupiedUniqueItemKeys,
});
const startYear = resolveStartYear(state, options.scenarioMeta);
@@ -2,11 +2,17 @@ import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameTy
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
import {
readCentennialRecordableDexterity,
type CentennialDexKey,
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js';
import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js';
import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js';
import { buildOldNationArchiveData } from './oldNationArchive.js';
import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js';
import type { PendingUnificationAuctionCancellation } from './types.js';
const UNIFIER_POINT = 2000;
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
@@ -49,14 +55,13 @@ const ownerDisplayName = (meta: Record<string, unknown>): string | null => {
export const resolveStoredInheritancePoint = (
currentPoints: ReadonlyMap<string, number>,
general: Pick<TurnGeneral, 'inheritancePoints'>,
key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number],
unifierAward: number
): number =>
currentPoints.get(key) ??
(key === 'unifier'
? Math.max(0, (general.inheritancePoints?.[key] ?? 0) - unifierAward)
: (general.inheritancePoints?.[key] ?? 0));
key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number]
): number => {
// All turn/month/auction mutations are persisted before finalization. A
// missing row therefore means zero; the general snapshot can still contain
// a rebirth-paid bucket that the lifecycle transaction deliberately deleted.
return currentPoints.get(key) ?? 0;
};
const formatHistogram = (value: unknown): string =>
Object.entries(asRecord(value))
@@ -329,7 +334,7 @@ export const persistUnificationFinalization = async (
const unifierAward = general.nationId === input.winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0;
const mergedPoints = Object.fromEntries(
ALL_MERGED_INHERITANCE_KEYS.map((key) => {
const stored = resolveStoredInheritancePoint(currentPoints, general, key, unifierAward);
const stored = resolveStoredInheritancePoint(currentPoints, key);
const effectiveStored = key === 'unifier' ? stored + unifierAward : stored;
return [key, computeActiveInheritancePoint(general, key, effectiveStored)];
})
@@ -359,15 +364,23 @@ export const persistUnificationFinalization = async (
},
},
});
await transaction.inheritanceLog.create({
data: {
userId,
serverId,
year: input.year,
month: input.month,
text: `천하 통일 정산: ${total.toLocaleString('ko-KR')} 포인트`,
},
});
for (const text of buildInheritanceSettlementLogTexts({
previous,
points: mergedPoints,
storedKeys: new Set([...currentPoints.keys(), ...(unifierAward > 0 ? (['unifier'] as const) : [])]),
total,
isRebirth: false,
})) {
await transaction.inheritanceLog.create({
data: {
userId,
serverId,
year: input.year,
month: input.month,
text,
},
});
}
}
const rankRows = generals.length
@@ -388,7 +401,7 @@ export const persistUnificationFinalization = async (
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 serverIdx = await resolveOfficialGameIndex(transaction, meta);
const minHallAge = readInteger(asRecord(world.getScenarioConfig().const).minPushHallAge, 30);
const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => {
@@ -429,7 +442,7 @@ export const persistUnificationFinalization = async (
unitedTime,
ownerDisplayName: ownerDisplayName(generalMeta),
serverID: serverId,
serverIdx: serverCount,
serverIdx,
serverName,
scenarioName,
generationKey: input.generationKey,
@@ -445,7 +458,7 @@ export const persistUnificationFinalization = async (
? general.experience
: type === 'dedication'
? general.dedication
: readNumber(generalMeta[type]);
: readCentennialRecordableDexterity(generalMeta, type as CentennialDexKey);
if ((type === 'winrate' || type === 'killrate') && (ranks.warnum ?? 0) < 10) continue;
if (type === 'ttrate' && totals.tt < 50) continue;
if (type === 'tlrate' && totals.tl < 50) continue;
@@ -454,30 +467,16 @@ export const persistUnificationFinalization = async (
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 },
],
},
await persistHallOfFameCandidate(transaction, {
serverId,
season,
scenario,
generalNo: general.id,
type,
value,
owner: general.userId ?? null,
aux,
});
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 } });
}
}
}
@@ -631,7 +630,7 @@ export const persistUnificationFinalization = async (
await transaction.emperor.create({
data: {
serverId,
phase: `${serverName}${serverCount}`,
phase: `${serverName}${serverIdx}`,
nationCount,
nationName: statisticNationNames || archivedNationNames.join(', '),
nationHist: formatHistogram(statistics.maxNationHist),
@@ -62,6 +62,7 @@ import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGener
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
import { respondToActionableMessage } from './actionableMessageResponse.js';
import { executeInheritanceAction } from './inheritanceActionService.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -162,7 +163,8 @@ const resolveCommandAcceptedAt = async (
| 'selectPoolReserve'
| 'selectPoolCreate'
| 'selectPoolReselect'
| 'adjustGeneralIcon';
| 'adjustGeneralIcon'
| 'inheritanceAction';
}
>
): Promise<Date> => {
@@ -802,6 +804,20 @@ async function handlePatchGeneral(
return { type: 'patchGeneral', ok: true, generalId: command.generalId };
}
async function handleInheritanceAction(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx) as unknown as GamePrisma.TransactionClient;
const acceptedAt = await resolveCommandAcceptedAt(db as unknown as DatabaseClient, command);
return executeInheritanceAction({
db,
world: ctx.world,
command,
gameNow: ctx.world.getGameNow(acceptedAt),
});
}
async function handleAdjustGeneralIcon(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
@@ -2936,6 +2952,8 @@ export const createTurnDaemonCommandHandler = (options: {
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
patchGeneral: (command) =>
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
inheritanceAction: (command) =>
handleInheritanceAction(ctx, command as Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>),
adjustGeneralIcon: (command) =>
handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>),
joinCreateGeneral: (command) =>