feat: port pre-start general deletion lifecycle

This commit is contained in:
2026-07-31 05:44:36 +00:00
parent f1929be3fe
commit 71ec02d091
24 changed files with 1257 additions and 160 deletions
@@ -88,6 +88,13 @@ const zTroopRename = z.object({
const zDieOnPrestart = z.object({
type: z.literal('dieOnPrestart'),
userId: z.string().min(1),
generalId: zFiniteNumber,
});
const zEnsureDieOnPrestartStatus = z.object({
type: z.literal('ensureDieOnPrestartStatus'),
userId: z.string().min(1),
generalId: zFiniteNumber,
});
@@ -415,6 +422,14 @@ const normalizeDieOnPrestart: CommandNormalizer<'dieOnPrestart'> = (envelope) =>
return { ...command, requestId: envelope.requestId };
};
const normalizeEnsureDieOnPrestartStatus: CommandNormalizer<'ensureDieOnPrestartStatus'> = (envelope) => {
const command = parseWith(zEnsureDieOnPrestartStatus, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeBuildNationCandidate: CommandNormalizer<'buildNationCandidate'> = (envelope) => {
const command = parseWith(zBuildNationCandidate, envelope.command);
if (!command) {
@@ -629,6 +644,7 @@ const normalizers: CommandNormalizerMap = {
troopKick: normalizeTroopKick,
troopRename: normalizeTroopRename,
dieOnPrestart: normalizeDieOnPrestart,
ensureDieOnPrestartStatus: normalizeEnsureDieOnPrestartStatus,
buildNationCandidate: normalizeBuildNationCandidate,
instantRetreat: normalizeInstantRetreat,
vacation: normalizeVacation,
@@ -74,13 +74,13 @@ const settleInheritance = async (
const ranks = new Map(rankRows.map((row) => [row.type, row.value]));
const rank = (key: string): number => ranks.get(key) ?? readNumber(meta, `rank_${key}`);
const previous = points.get('previous') ?? 0;
const refund =
(meta.inheritRandomUnique
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000)
: 0) +
(meta.inheritSpecificSpecialWar
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
: 0);
const randomUniqueRefund = meta.inheritRandomUnique
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000)
: 0;
const specificSpecialRefund = meta.inheritSpecificSpecialWar
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
: 0;
const refund = randomUniqueRefund + specificSpecialRefund;
const lived = readNumber(meta, 'inherit_lived_month');
const maxBelong = readNumber(meta, 'inherit_max_belong') * 10;
const maxDomestic = readNumber(meta, 'max_domestic_critical');
@@ -129,6 +129,19 @@ const settleInheritance = async (
}),
},
});
for (const text of [
...(randomUniqueRefund > 0 ? [`사망으로 랜덤 유니크 구입 ${randomUniqueRefund} 포인트 반환`] : []),
...(specificSpecialRefund > 0 ? [`사망으로 전투 특기 지정 ${specificSpecialRefund} 포인트 반환`] : []),
]) {
await prisma.inheritanceLog.create({
data: {
userId,
year: event.year,
month: event.month,
text,
},
});
}
await prisma.inheritanceLog.create({
data: {
userId,
@@ -139,8 +152,7 @@ const settleInheritance = async (
});
};
const computeRate = (numerator: number, denominator: number): number =>
denominator > 0 ? numerator / denominator : 0;
const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0);
const settleHall = async (
prisma: GamePrisma.TransactionClient,
@@ -186,7 +198,9 @@ const settleHall = async (
const season = readWorldNumber(worldMeta, 'season', 1);
const scenario = readWorldNumber(worldMeta, 'scenarioId', 0);
const scenarioName =
typeof asRecord(worldMeta.scenarioMeta).title === 'string' ? String(asRecord(worldMeta.scenarioMeta).title) : '';
typeof asRecord(worldMeta.scenarioMeta).title === 'string'
? String(asRecord(worldMeta.scenarioMeta).title)
: '';
const aux = {
name: event.before.name,
nationName: nation?.name ?? '재야',
@@ -270,8 +284,12 @@ const archiveDeletedGeneral = async (
orderBy: { id: 'desc' },
select: { text: true },
});
const archivedMeta = { ...asRecord(event.before.meta) };
delete archivedMeta.inheritRandomUnique;
delete archivedMeta.inheritSpecificSpecialWar;
const data = {
...event.before,
meta: archivedMeta,
turnTime: event.before.turnTime.toISOString(),
recentWarTime: event.before.recentWarTime?.toISOString() ?? null,
history: history.map((entry) => entry.text),
+15
View File
@@ -699,6 +699,21 @@ export class InMemoryTurnWorld {
return true;
}
deleteGeneralWithLifecycle(id: number, year: number, month: number): boolean {
const general = this.generals.get(id);
if (!general) {
return false;
}
this.lifecycleEvents.push({
generalId: id,
outcome: 'deleted',
before: structuredClone(general),
year,
month,
});
return this.removeGeneral(id);
}
updateCity(id: number, patch: Partial<City>): City | null {
const target = this.cities.get(id);
if (!target) {
@@ -20,6 +20,7 @@ import type { WarTraitKey } from '@sammo-ts/logic';
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import { buildPrestartDeleteAfter } from './prestartDeletion.js';
import type { TurnGeneral } from './types.js';
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
@@ -702,6 +703,7 @@ export const createGeneralFromJoin = async (options: {
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
const prestartDeleteAfter = buildPrestartDeleteAfter(acceptedAt, worldState.tickSeconds, config);
const general: TurnGeneral = {
id: generalId,
userId: input.userId,
@@ -778,6 +780,7 @@ export const createGeneralFromJoin = async (options: {
tournament: 0,
newvote: 0,
inherit_spent_dyn: inheritRequiredPoint,
prestart_delete_after: prestartDeleteAfter.toISOString(),
},
};
if (!world.addGeneral(general)) {
@@ -0,0 +1,27 @@
import { asNumber, asRecord } from '@sammo-ts/common';
const DEFAULT_MIN_TURNS = 2;
export const readPrestartDeleteAfter = (meta: Record<string, unknown>): Date | null => {
const value = meta.prestart_delete_after;
if (typeof value !== 'string' || !value.trim()) {
return null;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
export const buildPrestartDeleteAfter = (base: Date, tickSeconds: number, config: Record<string, unknown>): Date => {
const configConst = asRecord(config.const);
const minTurns = Math.max(0, Math.floor(asNumber(configConst.minTurnDieOnPrestart, DEFAULT_MIN_TURNS)));
return new Date(base.getTime() + Math.max(1, Math.floor(tickSeconds)) * minTurns * 1_000);
};
export const formatPrestartDeleteAfter = (value: Date): string => {
const seoul = new Date(value.getTime() + 9 * 60 * 60 * 1_000);
const pad = (part: number): string => String(part).padStart(2, '0');
return [
`${seoul.getUTCFullYear()}-${pad(seoul.getUTCMonth() + 1)}-${pad(seoul.getUTCDate())}`,
`${pad(seoul.getUTCHours())}:${pad(seoul.getUTCMinutes())}:${pad(seoul.getUTCSeconds())}`,
].join(' ');
};
+27 -89
View File
@@ -1,12 +1,6 @@
import { z } from 'zod';
import {
asNumber,
asRecord,
JosaUtil,
LiteHashDRBG,
RandUtil,
} from '@sammo-ts/common';
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { GamePrisma, LogCategory, LogScope } from '@sammo-ts/infra';
import {
EventDomesticTraitLoader,
@@ -18,15 +12,12 @@ import {
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import { buildPrestartDeleteAfter } from './prestartDeletion.js';
import type { TurnGeneral } from './types.js';
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
export type SelectPoolErrorCode =
| 'BAD_REQUEST'
| 'PRECONDITION_FAILED'
| 'CONFLICT'
| 'INTERNAL_SERVER_ERROR';
export type SelectPoolErrorCode = 'BAD_REQUEST' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
export class SelectPoolError extends Error {
constructor(
@@ -98,10 +89,7 @@ export interface SelectPoolReservationDto {
candidates: SelectPoolCandidateDto[];
}
const fail = (
code: SelectPoolErrorCode,
message: string
): never => {
const fail = (code: SelectPoolErrorCode, message: string): never => {
throw new SelectPoolError(code, message);
};
@@ -135,12 +123,7 @@ export const resolveSelectionMaxGeneral = (worldState: WorldStateRow): number =>
return Math.max(
0,
Math.floor(
asNumber(
config.maxGeneral ??
configConst.defaultMaxGeneral ??
configConst.maxGeneral,
DEFAULT_MAX_GENERAL
)
asNumber(config.maxGeneral ?? configConst.defaultMaxGeneral ?? configConst.maxGeneral, DEFAULT_MAX_GENERAL)
)
);
};
@@ -174,9 +157,7 @@ const candidateWeight = (candidate: SelectPoolCandidateInfo): number =>
const eventDomesticTraitLoader = new EventDomesticTraitLoader();
const toCandidateDto = async (
candidate: SelectPoolCandidateInfo
): Promise<SelectPoolCandidateDto> => {
const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<SelectPoolCandidateDto> => {
const trait = isEventDomesticTraitKey(candidate.specialDomestic)
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
: null;
@@ -187,8 +168,7 @@ const toCandidateDto = async (
strength: candidate.strength,
intel: candidate.intel,
specialDomestic: candidate.specialDomestic,
specialDomesticName:
trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
specialDomesticName: trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
specialDomesticInfo: trait?.info ?? '',
specialWar: candidate.specialWar ?? null,
ego: candidate.ego ?? null,
@@ -204,18 +184,12 @@ const toReservationDto = (
): Promise<SelectPoolReservationDto> => {
const validUntil = rows[0]?.reservedUntil;
if (!validUntil) {
throw new SelectPoolError(
'INTERNAL_SERVER_ERROR',
'장수 선택 후보의 유효기간이 없습니다.'
);
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
}
const expiresAt = validUntil;
const sorted = rows
.map((row) => ({ id: row.id, info: parseCandidate(row) }))
.sort(
(left, right) =>
candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id
);
.sort((left, right) => candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id);
return Promise.all(sorted.map((entry) => toCandidateDto(entry.info))).then((candidates) => ({
poolName: SUPPORTED_POOL,
hasGeneral,
@@ -229,16 +203,11 @@ const formatLegacySeedTime = (value: Date): string => {
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
koreaTime.getUTCDate()
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
koreaTime.getUTCSeconds()
)}`;
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`;
};
export const buildSelectPoolSeed = (
hiddenSeed: string | number,
ownerIdentity: string | number,
now: Date
): string => simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now));
export const buildSelectPoolSeed = (hiddenSeed: string | number, ownerIdentity: string | number, now: Date): string =>
simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now));
export const claimWeightedSelectionCandidates = async <T extends { id: number }>(options: {
weighted: [T, number][];
@@ -365,13 +334,7 @@ export const reserveSelectionPool = async (options: {
}
const rng = new RandUtil(
new LiteHashDRBG(
buildSelectPoolSeed(
getWorldHiddenSeed(worldState),
options.seedOwnerIdentity ?? userId,
now
)
)
new LiteHashDRBG(buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, now))
);
const weighted = available.map((row) => [row, candidateWeight(parseCandidate(row))] as [SelectPoolRow, number]);
const reservedUntil = new Date(
@@ -398,10 +361,10 @@ export const reserveSelectionPool = async (options: {
},
});
const reserved = selected.map((candidate) => ({
...candidate,
ownerUserId: userId,
reservedUntil,
}));
...candidate,
ownerUserId: userId,
reservedUntil,
}));
if (reserved.length !== RESERVATION_COUNT) {
fail('CONFLICT', '장수 선택 후보를 예약하지 못했습니다. 다시 시도해 주세요.');
}
@@ -413,10 +376,7 @@ const lockSelectionMutationTables = async (db: DatabaseClient): Promise<void> =>
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "select_pool" IN SHARE ROW EXCLUSIVE MODE`);
};
const assertGeneralIdSnapshotMatches = async (
db: DatabaseClient,
world: InMemoryTurnWorld
): Promise<void> => {
const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemoryTurnWorld): Promise<void> => {
const persistedIds = (
await db.general.findMany({
select: { id: true },
@@ -427,13 +387,8 @@ const assertGeneralIdSnapshotMatches = async (
.listGenerals()
.map(({ id }) => id)
.sort((left, right) => left - right);
if (
persistedIds.length !== runtimeIds.length ||
persistedIds.some((id, index) => id !== runtimeIds[index])
) {
throw new Error(
'DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.'
);
if (persistedIds.length !== runtimeIds.length || persistedIds.some((id, index) => id !== runtimeIds[index])) {
throw new Error('DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.');
}
};
@@ -468,12 +423,7 @@ const resolveRandomPersonality = (
): string =>
new RandUtil(
new LiteHashDRBG(
simpleSerialize(
getWorldHiddenSeed(worldState),
'selectPickedGeneralPersonality',
ownerIdentity,
uniqueName
)
simpleSerialize(getWorldHiddenSeed(worldState), 'selectPickedGeneralPersonality', ownerIdentity, uniqueName)
)
).choice([...PERSONALITY_TRAIT_KEYS]);
@@ -495,19 +445,10 @@ const resolveSelectedPersonality = (
return requested;
};
const resolvePoolRng = (
worldState: WorldStateRow,
ownerIdentity: string | number,
uniqueName: string
): RandUtil =>
const resolvePoolRng = (worldState: WorldStateRow, ownerIdentity: string | number, uniqueName: string): RandUtil =>
new RandUtil(
new LiteHashDRBG(
simpleSerialize(
getWorldHiddenSeed(worldState),
'selectPickedGeneral',
ownerIdentity,
uniqueName
)
simpleSerialize(getWorldHiddenSeed(worldState), 'selectPickedGeneral', ownerIdentity, uniqueName)
)
);
@@ -612,16 +553,12 @@ export const createGeneralFromSelectionPool = async (options: {
const nextChangeAt = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
);
const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config);
const showImgLevel = asNumber(config.showImgLevel, 0);
const picture = showImgLevel >= 3 ? info.picture : 'default.jpg';
const defaultSpecialWar =
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
const personality = resolveSelectedPersonality(
worldState,
seedOwnerIdentity,
uniqueName,
options.personality
);
const personality = resolveSelectedPersonality(worldState, seedOwnerIdentity, uniqueName, options.personality);
// 모든 사용자 입력과 DB 선조건을 검증한 뒤에만 allocator를 변경한다.
// SelectPoolError는 정상 command 결과로 commit되므로 이보다 먼저
// getNextGeneralId()를 호출하면 실패한 요청도 lastGeneralId를 소비한다.
@@ -692,6 +629,7 @@ export const createGeneralFromSelectionPool = async (options: {
dex5: info.dex[4],
next_change: nextChangeAt.toISOString(),
nextChangeAt: nextChangeAt.toISOString(),
prestart_delete_after: prestartDeleteAfter.toISOString(),
npc_org: 0,
},
};
@@ -884,6 +822,6 @@ export const getSelectionPoolStatus = async (
poolName,
allowOptions: resolvePoolAllowOptions(worldState),
hasGeneral: Boolean(general),
nextChangeAt: general ? readNextChangeAt(general.meta)?.toISOString() ?? null : null,
nextChangeAt: general ? (readNextChangeAt(general.meta)?.toISOString() ?? null) : null,
};
};
+126 -7
View File
@@ -52,6 +52,7 @@ import {
} from './selectPoolService.js';
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -141,7 +142,15 @@ const resolveCommandAcceptedAt = async (
db: DatabaseClient,
command: Extract<
TurnDaemonCommand,
{ type: 'joinCreateGeneral' | 'npcPossessGeneral' | 'selectPoolCreate' | 'selectPoolReselect' }
{
type:
| 'dieOnPrestart'
| 'ensureDieOnPrestartStatus'
| 'joinCreateGeneral'
| 'npcPossessGeneral'
| 'selectPoolCreate'
| 'selectPoolReselect';
}
>
): Promise<Date> => {
if (!command.requestId) {
@@ -1029,31 +1038,136 @@ async function handleTroopRename(
};
}
const ensurePrestartDeleteAfter = async (
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' | 'ensureDieOnPrestartStatus' }>,
general: TurnGeneral,
acceptedAt: Date
): Promise<Date> => {
const current = readPrestartDeleteAfter(asRecord(general.meta));
if (current) {
return current;
}
const db = requireCommandDatabase(ctx);
const access = await db.generalAccessLog.findUnique({
where: { generalId: general.id },
select: { lastRefresh: true },
});
const deleteAfter = buildPrestartDeleteAfter(
access?.lastRefresh ?? acceptedAt,
ctx.world.getState().tickSeconds,
asRecord(ctx.world.getScenarioConfig())
);
const updated = ctx.world.updateGeneral(general.id, {
meta: {
...general.meta,
prestart_delete_after: deleteAfter.toISOString(),
},
});
if (!updated) {
throw new Error(`${command.type} general disappeared while fixing the pre-start deletion time.`);
}
return deleteAfter;
};
async function handleEnsureDieOnPrestartStatus(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'ensureDieOnPrestartStatus' }>
): Promise<TurnDaemonCommandResult> {
const general = ctx.world.getGeneralById(command.generalId);
if (!general || general.npcState !== 0 || general.userId !== command.userId) {
return {
type: 'ensureDieOnPrestartStatus',
generalId: command.generalId,
show: false,
available: false,
};
}
const db = requireCommandDatabase(ctx);
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const worldState = ctx.world.getState();
const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null;
if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) {
return {
type: 'ensureDieOnPrestartStatus',
generalId: command.generalId,
show: false,
available: false,
};
}
const availableAt = await ensurePrestartDeleteAfter(ctx, command, general, acceptedAt);
return {
type: 'ensureDieOnPrestartStatus',
generalId: command.generalId,
show: true,
available: availableAt.getTime() <= acceptedAt.getTime(),
availableAt: availableAt.toISOString(),
};
}
async function handleDieOnPrestart(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>
): Promise<TurnDaemonCommandResult> {
const { world } = ctx;
const db = requireCommandDatabase(ctx);
const general = world.getGeneralById(command.generalId);
if (!general) {
if (!general || general.npcState !== 0 || general.userId !== command.userId) {
return {
type: 'dieOnPrestart',
ok: false,
generalId: command.generalId,
reason: '장수 정보를 찾을 수 없습니다.',
reason: '장수 없습니다',
};
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined;
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '가오픈 기간이 아닙니다.' };
return {
type: 'dieOnPrestart',
ok: false,
generalId: command.generalId,
reason: '게임이 시작되었습니다.',
};
}
if (general.npcState !== 0 || general.nationId !== 0) {
return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '삭제할 수 없는 상태입니다.' };
if (general.nationId !== 0) {
return {
type: 'dieOnPrestart',
ok: false,
generalId: command.generalId,
reason: '이미 국가에 소속되어있습니다.',
};
}
world.removeGeneral(command.generalId);
const deleteAfter = await ensurePrestartDeleteAfter(ctx, command, general, acceptedAt);
if (deleteAfter.getTime() > acceptedAt.getTime()) {
return {
type: 'dieOnPrestart',
ok: false,
generalId: command.generalId,
reason: `아직 삭제할 수 없습니다. ${formatPrestartDeleteAfter(deleteAfter)} 부터 가능합니다.`,
};
}
if (general.troopId === general.id) {
for (const member of world.listGenerals()) {
if (member.troopId === general.id) {
world.updateGeneral(member.id, { troopId: 0 });
}
}
world.removeTroop(general.id);
}
const josaYi = JosaUtil.pick(general.name, '이');
world.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
text: `<Y>${general.name}</>${josaYi} 홀연히 모습을 <R>감추었습니다</>`,
meta: {},
});
world.deleteGeneralWithLifecycle(command.generalId, worldState.currentYear, worldState.currentMonth);
return { type: 'dieOnPrestart', ok: true, generalId: command.generalId };
}
@@ -2135,6 +2249,11 @@ export const createTurnDaemonCommandHandler = (options: {
troopKick: (command) => handleTroopKick(ctx, command as Extract<TurnDaemonCommand, { type: 'troopKick' }>),
troopRename: (command) =>
handleTroopRename(ctx, command as Extract<TurnDaemonCommand, { type: 'troopRename' }>),
ensureDieOnPrestartStatus: (command) =>
handleEnsureDieOnPrestartStatus(
ctx,
command as Extract<TurnDaemonCommand, { type: 'ensureDieOnPrestartStatus' }>
),
dieOnPrestart: (command) =>
handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
buildNationCandidate: (command) =>