import type { TurnDaemonCommandHandler, TurnDaemonCommand, TurnDaemonCommandExecutionContext, TurnDaemonCommandResult, } from '../lifecycle/types.js'; import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra'; import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { LogCategory, LogFormat, LogScope, ITEM_KEYS, addOccupiedUniqueItemKeys, buildVoteUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, isDefenceTrainPenaltyWaivedByScenarioEffect, isValidTroopNameWidth, loadItemModules, normalizeTroopName, resolveTroopSecretPermission, resolveUniqueConfig, rollUniqueLottery, type ItemModule, type MapDefinition, type ScenarioMeta, type TriggerValue, type TurnCommandProfile, } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { cloneItemInventory, ensureItemInventory, equipNewItem, removeEquippedItem, } from '@sammo-ts/logic/items/index.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; import type { TurnGeneral } from './types.js'; import { createImmediateGeneralActionExecutor, type ImmediateGeneralActionExecutor } from './reservedTurnHandler.js'; import { openAuction } from '../auction/opener.js'; import { hasScenarioStaticEventHandler, IMMEDIATE_TROOP_JOIN_MOVE_HANDLER, LEGACY_TROOP_JOIN_EVENT, } from './scenarioStaticEvents.js'; import { createGeneralFromSelectionPool, reselectGeneralFromSelectionPool, SelectPoolError, } from './selectPoolService.js'; import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js'; import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js'; let itemRegistryPromise: Promise> | null = null; const getItemRegistry = async (): Promise> => { if (!itemRegistryPromise) { itemRegistryPromise = loadItemModules([...ITEM_KEYS]).then((modules) => createItemModuleRegistry(modules)); } return itemRegistryPromise; }; const readMetaNumber = (meta: Record, 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 hasOfficerLock = (meta: Record, key: string, officerLevel: number): boolean => (readMetaNumber(meta, key, 0) & (1 << officerLevel)) !== 0; const setOfficerLock = ( meta: Record, key: string, officerLevel: number ): Record => ({ ...meta, [key]: readMetaNumber(meta, key, 0) | (1 << officerLevel), }); const resolveNationChiefLevel = (nationLevel: number): number => { if (nationLevel >= 6) return 5; if (nationLevel >= 4) return 7; if (nationLevel >= 2) return 9; return 11; }; const resolvePermissionKind = (general: TurnGeneral): 'normal' | 'ambassador' | 'auditor' => { const permission = general.meta.permission; return permission === 'ambassador' || permission === 'auditor' ? permission : 'normal'; }; const resolveMaxSecretPermission = (general: TurnGeneral): number => { const penalty = asRecord(general.penalty); if (penalty.noTopSecret || penalty.noChief) return 1; if (penalty.noAmbassador) return 2; return 4; }; const refreshActorKillturn = (world: InMemoryTurnWorld, actor: TurnGeneral): void => { const worldKillturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0); const actorKillturn = readMetaNumber(asRecord(actor.meta), 'killturn', 0); if (worldKillturn <= actorKillturn) { return; } world.updateGeneral(actor.id, { meta: { ...actor.meta, killturn: worldKillturn, }, }); }; interface CommandHandlerContext { world: InMemoryTurnWorld; commandDb?: GamePrisma.TransactionClient; auctionFinalizer?: AuctionFinalizer; auctionBidder?: AuctionBidder; tournamentRewardFinalizer?: TournamentRewardFinalizer; getImmediateGeneralActionExecutor?: () => Promise; } const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => { if (!ctx.commandDb) { throw new Error('ENGINE mutation transaction is required for selection-pool commands.'); } return ctx.commandDb as unknown as DatabaseClient; }; const resolveCommandAcceptedAt = async ( db: DatabaseClient, command: Extract< TurnDaemonCommand, { type: 'joinCreateGeneral' | 'npcPossessGeneral' | 'selectPoolCreate' | 'selectPoolReselect' } > ): Promise => { if (!command.requestId) { throw new Error(`${command.type} requestId is required.`); } const event = await db.inputEvent.findUnique({ where: { requestId: command.requestId }, select: { createdAt: true, actorUserId: true, target: true, eventType: true }, }); if (!event) { throw new Error(`ENGINE input event ${command.requestId} is missing.`); } if (event.actorUserId !== command.userId) { throw new Error(`ENGINE input event actor does not match ${command.type} user.`); } if (event.target !== 'ENGINE' || event.eventType !== command.type) { throw new Error(`ENGINE input event type does not match ${command.type}.`); } return event.createdAt; }; const assertImmediateGeneralActionActor = async ( ctx: CommandHandlerContext, command: Extract, general: TurnGeneral ): Promise => { if (general.userId !== command.userId) { throw new Error(`${command.type} general owner does not match command user.`); } if (!command.requestId) { return; } const db = requireCommandDatabase(ctx); const event = await db.inputEvent.findUnique({ where: { requestId: command.requestId }, select: { actorUserId: true }, }); if (!event) { throw new Error(`ENGINE input event ${command.requestId} is missing.`); } if (event.actorUserId !== command.userId) { throw new Error(`ENGINE input event actor does not match ${command.type} user.`); } }; async function handleJoinCreateGeneral( ctx: CommandHandlerContext, command: Extract ): Promise { const db = requireCommandDatabase(ctx); const worldState = await db.worldState.findUnique({ where: { id: ctx.world.getState().id }, }); if (!worldState) { throw new Error('Join world state is missing.'); } const acceptedAt = await resolveCommandAcceptedAt(db, command); try { return { type: 'joinCreateGeneral', ...(await createGeneralFromJoin({ db, world: ctx.world, worldState, input: { userId: command.userId, ownerDisplayName: command.ownerDisplayName, seedOwnerIdentity: command.seedOwnerIdentity, name: command.name, leadership: command.leadership, strength: command.strength, intel: command.intel, pic: command.pic, character: command.character, profileId: command.profileId, ...(command.ownerPicture !== undefined ? { ownerPicture: command.ownerPicture } : {}), ...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}), ...(command.ownerCanUsePicture !== undefined ? { ownerCanUsePicture: command.ownerCanUsePicture } : {}), ...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}), ...(command.inheritSpecial !== undefined ? { inheritSpecial: command.inheritSpecial } : {}), ...(command.inheritTurntimeZone !== undefined ? { inheritTurntimeZone: command.inheritTurntimeZone } : {}), ...(command.inheritCity !== undefined ? { inheritCity: command.inheritCity } : {}), ...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}), }, acceptedAt, })), }; } catch (error) { if (error instanceof JoinCreateGeneralError) { return { type: 'joinCreateGeneral', ok: false, code: error.code, reason: error.message, }; } throw error; } } async function handleNpcPossessGeneral( ctx: CommandHandlerContext, command: Extract ): Promise { const db = requireCommandDatabase(ctx); const worldState = await db.worldState.findUnique({ where: { id: ctx.world.getState().id }, }); if (!worldState) { throw new Error('NPC possession world state is missing.'); } const acceptedAt = await resolveCommandAcceptedAt(db, command); try { return { type: 'npcPossessGeneral', ...(await possessNpcGeneral({ db, world: ctx.world, worldState, userId: command.userId, ownerDisplayName: command.ownerDisplayName, profileId: command.profileId, ...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}), generalId: command.generalId, tokenNonce: command.tokenNonce, acceptedAt, })), }; } catch (error) { if (error instanceof NpcPossessionError) { return { type: 'npcPossessGeneral', ok: false, code: error.code, reason: error.message, }; } throw error; } } async function handleSelectPoolCreate( ctx: CommandHandlerContext, command: Extract ): Promise { const db = requireCommandDatabase(ctx); const worldState = await db.worldState.findUnique({ where: { id: ctx.world.getState().id }, }); if (!worldState) { throw new Error('Selection-pool world state is missing.'); } const acceptedAt = await resolveCommandAcceptedAt(db, command); try { return { type: 'selectPoolCreate', ...(await createGeneralFromSelectionPool({ db, world: ctx.world, worldState, userId: command.userId, ownerDisplayName: command.ownerDisplayName, uniqueName: command.uniqueName, personality: command.personality, seedOwnerIdentity: command.seedOwnerIdentity, now: acceptedAt, })), }; } catch (error) { if (error instanceof SelectPoolError) { return { type: 'selectPoolCreate', ok: false, code: error.code, reason: error.message, }; } throw error; } } async function handleSelectPoolReselect( ctx: CommandHandlerContext, command: Extract ): Promise { const db = requireCommandDatabase(ctx); const worldState = await db.worldState.findUnique({ where: { id: ctx.world.getState().id }, }); if (!worldState) { throw new Error('Selection-pool world state is missing.'); } const acceptedAt = await resolveCommandAcceptedAt(db, command); try { return { type: 'selectPoolReselect', ...(await reselectGeneralFromSelectionPool({ db, world: ctx.world, worldState, userId: command.userId, ownerDisplayName: command.ownerDisplayName, uniqueName: command.uniqueName, now: acceptedAt, })), }; } catch (error) { if (error instanceof SelectPoolError) { return { type: 'selectPoolReselect', ok: false, code: error.code, reason: error.message, }; } throw error; } } interface AuctionFinalizer { finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise; } interface AuctionBidder { bid( command: Extract, db?: GamePrisma.TransactionClient ): Promise; } interface TournamentRewardFinalizer { finalize( command: Extract, db?: GamePrisma.TransactionClient ): Promise; } async function handleSetNationMeta( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const nation = world.getNationById(command.nationId); if (!nation) { return { type: 'setNationMeta', ok: false, nationId: command.nationId, reason: '국가 정보를 찾을 수 없습니다.', }; } const meta = (nation.meta ?? {}) as Record; const currentUpdatedAt = typeof meta._updatedAt === 'string' ? meta._updatedAt : undefined; if (command.expectedUpdatedAt && currentUpdatedAt && command.expectedUpdatedAt !== currentUpdatedAt) { return { type: 'setNationMeta', ok: false, nationId: command.nationId, reason: 'CONFLICT', currentUpdatedAt, }; } const updatedAt = new Date().toISOString(); const nextMeta = { ...meta, ...command.updates, _updatedAt: updatedAt, }; world.updateNation(command.nationId, { meta: nextMeta, }); return { type: 'setNationMeta', ok: true, nationId: command.nationId, updatedAt, }; } async function handleAdjustGeneralResources( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; if (!command.adjustments || command.adjustments.length === 0) { return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' }; } const targets = command.adjustments.map((adjustment) => { const general = world.getGeneralById(adjustment.generalId); return { adjustment, general }; }); for (const { adjustment, general } of targets) { if (!general) { continue; } const nextGold = general.gold + (adjustment.goldDelta ?? 0); const nextRice = general.rice + (adjustment.riceDelta ?? 0); const minGoldAfter = adjustment.minGoldAfter ?? 0; if (nextGold < minGoldAfter || nextRice < 0) { return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' }; } } let processed = 0; let missing = 0; let totalGoldDelta = 0; let totalRiceDelta = 0; for (const { adjustment, general } of targets) { if (!general) { missing += 1; continue; } const goldDelta = adjustment.goldDelta ?? 0; const riceDelta = adjustment.riceDelta ?? 0; world.updateGeneral(adjustment.generalId, { gold: general.gold + goldDelta, rice: general.rice + riceDelta, }); processed += 1; totalGoldDelta += goldDelta; totalRiceDelta += riceDelta; } return { type: 'adjustGeneralResources', ok: true, processed, missing, totalGoldDelta, totalRiceDelta, }; } async function handleAdjustGeneralMeta( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; if (!command.adjustments || command.adjustments.length === 0) { return { type: 'adjustGeneralMeta', ok: false, reason: '메타 조정 대상이 없습니다.', }; } let processed = 0; let missing = 0; for (const adjustment of command.adjustments) { if (!adjustment || typeof adjustment.generalId !== 'number') { continue; } const general = world.getGeneralById(adjustment.generalId); if (!general) { missing += 1; continue; } const nextMeta = { ...general.meta } as TurnGeneral['meta']; for (const [key, delta] of Object.entries(adjustment.metaDelta ?? {})) { if (typeof delta !== 'number' || !Number.isFinite(delta)) { continue; } const current = typeof nextMeta[key] === 'number' ? Number(nextMeta[key]) : 0; nextMeta[key] = current + delta; } world.updateGeneral(adjustment.generalId, { meta: nextMeta }); processed += 1; } return { type: 'adjustGeneralMeta', ok: true, processed, missing, }; } async function handleTournamentMatchResult( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const resolvePrefix = (type: number): string => { switch (type) { case 1: return 'tl'; case 2: return 'ts'; case 3: return 'ti'; case 0: default: return 'tt'; } }; const prefix = resolvePrefix(command.tournamentType); const attacker = command.attackerId > 0 ? world.getGeneralById(command.attackerId) : null; const defender = command.defenderId > 0 ? world.getGeneralById(command.defenderId) : null; if (!attacker && !defender) { return { type: 'tournamentMatchResult', ok: false, tournamentType: command.tournamentType, attackerId: command.attackerId, defenderId: command.defenderId, result: command.result, reason: '장수 정보를 찾을 수 없습니다.', }; } const rankKey = (suffix: string): string => `${prefix}${suffix}`; const getRankNumber = (general: TurnGeneral | null, key: string): number => general && typeof general.meta[key] === 'number' ? Number(general.meta[key]) : 0; const attackerG = getRankNumber(attacker, rankKey('g')); const defenderG = getRankNumber(defender, rankKey('g')); let attackerGDelta: number; let defenderGDelta: number; let attackerW = 0; let attackerD = 0; let attackerL = 0; let defenderW = 0; let defenderD = 0; let defenderL = 0; if (command.result === 'attacker') { attackerW = 1; defenderL = 1; if (attackerG > defenderG) { attackerGDelta = 1; defenderGDelta = 0; } else if (attackerG === defenderG) { attackerGDelta = 2; defenderGDelta = -1; } else { attackerGDelta = 3; defenderGDelta = -2; } } else if (command.result === 'defender') { attackerL = 1; defenderW = 1; if (defenderG > attackerG) { defenderGDelta = 1; attackerGDelta = 0; } else if (attackerG === defenderG) { defenderGDelta = 2; attackerGDelta = -1; } else { defenderGDelta = 3; attackerGDelta = -2; } } else { attackerD = 1; defenderD = 1; if (attackerG > defenderG) { attackerGDelta = 1; defenderGDelta = -1; } else if (attackerG === defenderG) { attackerGDelta = 0; defenderGDelta = 0; } else { attackerGDelta = -1; defenderGDelta = 1; } } if (attacker) { const nextMeta = { ...attacker.meta } as TurnGeneral['meta']; nextMeta[rankKey('w')] = getRankNumber(attacker, rankKey('w')) + attackerW; nextMeta[rankKey('d')] = getRankNumber(attacker, rankKey('d')) + attackerD; nextMeta[rankKey('l')] = getRankNumber(attacker, rankKey('l')) + attackerL; nextMeta[rankKey('g')] = attackerG + attackerGDelta; world.updateGeneral(attacker.id, { meta: nextMeta }); } if (defender) { const nextMeta = { ...defender.meta } as TurnGeneral['meta']; nextMeta[rankKey('w')] = getRankNumber(defender, rankKey('w')) + defenderW; nextMeta[rankKey('d')] = getRankNumber(defender, rankKey('d')) + defenderD; nextMeta[rankKey('l')] = getRankNumber(defender, rankKey('l')) + defenderL; nextMeta[rankKey('g')] = defenderG + defenderGDelta; world.updateGeneral(defender.id, { meta: nextMeta }); } return { type: 'tournamentMatchResult', ok: true, tournamentType: command.tournamentType, attackerId: command.attackerId, defenderId: command.defenderId, result: command.result, }; } async function handlePatchGeneral( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'patchGeneral', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.', }; } const patch: Partial = {}; if (command.patch.meta) { patch.meta = { ...general.meta, ...command.patch.meta, } as TurnGeneral['meta']; } if (command.patch.turnTime) { const nextTurnTime = new Date(command.patch.turnTime); if (!Number.isNaN(nextTurnTime.getTime())) { patch.turnTime = nextTurnTime; } } if (command.patch.stats) { patch.stats = { ...general.stats, ...command.patch.stats, }; } if (typeof command.patch.specialWar === 'string') { patch.role = { ...general.role, specialWar: command.patch.specialWar, }; } world.updateGeneral(command.generalId, patch); return { type: 'patchGeneral', ok: true, generalId: command.generalId }; } async function handleShiftSchedule( ctx: CommandHandlerContext, command: Extract ): Promise { if (!ctx.commandDb) { return { type: 'shiftSchedule', ok: false, actionId: command.actionId, reason: '시간 조정은 데이터베이스 transaction 경계에서만 실행할 수 있습니다.', }; } const shifted = ctx.world.shiftSchedule(command.deltaMinutes); const shiftedAuctions = await ctx.commandDb.$executeRaw( GamePrisma.sql` UPDATE auction SET close_at = close_at + (${command.deltaMinutes} * INTERVAL '1 minute'), updated_at = NOW() WHERE status = 'OPEN' ` ); return { type: 'shiftSchedule', ok: true, actionId: command.actionId, deltaMinutes: command.deltaMinutes, lastTurnTime: shifted.lastTurnTime, shiftedGenerals: shifted.shiftedGenerals, shiftedAuctions, checkpoint: ctx.world.getCheckpoint(), }; } async function handleTroopJoin( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'troopJoin', ok: false, generalId: command.generalId, troopId: command.troopId, reason: '장수 정보를 찾을 수 없습니다.', }; } if (general.troopId !== 0) { return { type: 'troopJoin', ok: false, generalId: command.generalId, troopId: command.troopId, reason: '이미 부대에 소속되어 있습니다.', }; } if (general.nationId <= 0) { return { type: 'troopJoin', ok: false, generalId: command.generalId, troopId: command.troopId, reason: '국가에 소속되어 있지 않습니다.', }; } const troop = world.getTroopById(command.troopId); if (!troop || troop.nationId !== general.nationId) { return { type: 'troopJoin', ok: false, generalId: command.generalId, troopId: command.troopId, reason: '부대가 올바르지 않습니다.', }; } world.updateGeneral(command.generalId, { troopId: command.troopId, }); if ( hasScenarioStaticEventHandler( world.getScenarioConfig(), LEGACY_TROOP_JOIN_EVENT, IMMEDIATE_TROOP_JOIN_MOVE_HANDLER ) ) { const leader = world.getGeneralById(troop.id); if ( leader && leader.troopId === leader.id && leader.nationId === general.nationId && leader.cityId !== general.cityId ) { const city = world.getCityById(leader.cityId); if (city) { world.updateGeneral(command.generalId, { troopId: command.troopId, cityId: leader.cityId, }); const josaRo = JosaUtil.pick(city.name, '로'); world.pushLog({ scope: LogScope.GENERAL, category: LogCategory.ACTION, format: LogFormat.PLAIN, text: `부대 주둔지인 ${city.name}${josaRo} 즉시 이동합니다.`, generalId: general.id, meta: {}, }); } } } return { type: 'troopJoin', ok: true, generalId: command.generalId, troopId: command.troopId, }; } async function handleTroopCreate( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'troopCreate', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.', }; } const troopName = normalizeTroopName(command.troopName); if (!troopName) { return { type: 'troopCreate', ok: false, generalId: command.generalId, reason: '부대 이름이 없습니다.' }; } if (!isValidTroopNameWidth(troopName)) { return { type: 'troopCreate', ok: false, generalId: command.generalId, reason: '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.', }; } if (general.troopId !== 0 || world.getTroopById(general.id)) { return { type: 'troopCreate', ok: false, generalId: command.generalId, reason: '이미 부대에 소속되어 있습니다.', }; } if (general.nationId <= 0 || !world.getNationById(general.nationId)) { return { type: 'troopCreate', ok: false, generalId: command.generalId, reason: '국가에 소속되어 있지 않습니다.', }; } const troop = world.createTroop({ id: general.id, nationId: general.nationId, name: troopName, }); if (!troop) { return { type: 'troopCreate', ok: false, generalId: command.generalId, reason: '부대가 생성되지 않았습니다. 버그일 수 있습니다.', }; } world.updateGeneral(general.id, { troopId: general.id }); return { type: 'troopCreate', ok: true, generalId: general.id, troopId: troop.id, troopName: troop.name, }; } async function handleTroopExit( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'troopExit', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.', }; } if (general.troopId === 0) { return { type: 'troopExit', ok: false, generalId: command.generalId, reason: '부대에 소속되어 있지 않습니다.', }; } if (general.troopId !== general.id) { world.updateGeneral(command.generalId, { troopId: 0, }); return { type: 'troopExit', ok: true, generalId: command.generalId, wasLeader: false, }; } const troopId = general.troopId; const members = world.listGenerals().filter((entry) => entry.troopId === troopId); for (const member of members) { world.updateGeneral(member.id, { troopId: 0 }); } world.removeTroop(troopId); return { type: 'troopExit', ok: true, generalId: command.generalId, wasLeader: true, }; } async function handleTroopKick( ctx: CommandHandlerContext, command: Extract ): Promise { const fail = (reason: string): TurnDaemonCommandResult => ({ type: 'troopKick', ok: false, generalId: command.generalId, troopId: command.troopId, targetGeneralId: command.targetGeneralId, reason, }); const { world } = ctx; const actor = world.getGeneralById(command.generalId); if (!actor) { return fail('장수 정보를 찾을 수 없습니다.'); } const troop = world.getTroopById(command.troopId); if ( command.generalId !== command.troopId || actor.troopId !== actor.id || !troop || troop.id !== actor.id || troop.nationId !== actor.nationId ) { return fail('권한이 부족합니다.'); } const target = world.getGeneralById(command.targetGeneralId); if (!target) { return fail('장수 정보를 찾을 수 없습니다.'); } if (target.troopId === 0) { return fail('부대에 소속되어 있지 않습니다.'); } if (target.troopId !== command.troopId) { return fail('다른 부대에 소속되어 있습니다.'); } if (target.id === command.troopId) { return fail('부대장을 추방할 수 없습니다.'); } world.updateGeneral(target.id, { troopId: 0 }); return { type: 'troopKick', ok: true, generalId: actor.id, troopId: troop.id, targetGeneralId: target.id, }; } async function handleTroopRename( ctx: CommandHandlerContext, command: Extract ): Promise { const fail = (reason: string): TurnDaemonCommandResult => ({ type: 'troopRename', ok: false, generalId: command.generalId, troopId: command.troopId, reason, }); const { world } = ctx; const actor = world.getGeneralById(command.generalId); if (!actor) { return fail('장수 정보를 찾을 수 없습니다.'); } const nation = world.getNationById(actor.nationId); const permission = resolveTroopSecretPermission(actor, nation?.meta ?? {}, false); if (actor.id !== command.troopId && permission < 4) { return fail('권한이 부족합니다.'); } const troopName = normalizeTroopName(command.troopName); if (!troopName) { return fail('부대 이름이 없습니다.'); } if (!isValidTroopNameWidth(troopName)) { return fail('부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.'); } const troop = world.getTroopById(command.troopId); if (!troop || actor.nationId <= 0 || troop.nationId !== actor.nationId) { return fail('부대가 없습니다.'); } world.updateTroop(troop.id, { name: troopName }); return { type: 'troopRename', ok: true, generalId: actor.id, troopId: troop.id, troopName, }; } async function handleDieOnPrestart( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.', }; } 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: '가오픈 기간이 아닙니다.' }; } if (general.npcState !== 0 || general.nationId !== 0) { return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '삭제할 수 없는 상태입니다.' }; } world.removeGeneral(command.generalId); return { type: 'dieOnPrestart', ok: true, generalId: command.generalId }; } async function handleBuildNationCandidate( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'buildNationCandidate', ok: false, generalId: command.generalId, reason: '장수가 없습니다', }; } await assertImmediateGeneralActionActor(ctx, command, general); const worldState = world.getState(); const opentime = worldState.meta.opentime as string | undefined; if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) { return { type: 'buildNationCandidate', ok: false, generalId: command.generalId, reason: '게임이 시작되었습니다.', }; } if (general.nationId !== 0) { return { type: 'buildNationCandidate', ok: false, generalId: command.generalId, reason: '이미 국가에 소속되어있습니다.', }; } if (!ctx.getImmediateGeneralActionExecutor) { throw new Error('Immediate general action runtime is not configured.'); } const hiddenSeed = asRecord(worldState.meta).hiddenSeed ?? asRecord(worldState.meta).seed ?? worldState.id; const executor = await ctx.getImmediateGeneralActionExecutor(); const execution = await executor.execute({ actionKey: 'che_거병', generalId: command.generalId, rng: new RandUtil( new LiteHashDRBG( simpleSerialize( typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed), 'BuildNationCandidate', command.generalId ) ) ), refreshKillturn: true, }); return { type: 'buildNationCandidate', ok: execution.ok, generalId: command.generalId, ...(execution.reason ? { reason: execution.reason } : {}), }; } async function handleInstantRetreat( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const config = world.getScenarioConfig(); const availableInstantAction = config.const.availableInstantAction as Record | undefined; if (!availableInstantAction?.instantRetreat) { return { type: 'instantRetreat', ok: false, generalId: command.generalId, reason: '접경귀환을 사용할 수 없는 시나리오입니다.', }; } const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'instantRetreat', ok: false, generalId: command.generalId, reason: '장수가 없습니다', }; } await assertImmediateGeneralActionActor(ctx, command, general); if (!ctx.getImmediateGeneralActionExecutor) { throw new Error('Immediate general action runtime is not configured.'); } const state = world.getState(); const hiddenSeed = asRecord(state.meta).hiddenSeed ?? asRecord(state.meta).seed ?? state.id; const executor = await ctx.getImmediateGeneralActionExecutor(); const execution = await executor.execute({ actionKey: 'che_접경귀환', generalId: command.generalId, rng: new RandUtil( new LiteHashDRBG( simpleSerialize( typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed), 'InstantRetreat', command.generalId, state.currentYear, state.currentMonth, general.cityId ) ) ), }); return { type: 'instantRetreat', ok: execution.ok, generalId: command.generalId, ...(execution.reason ? { reason: execution.reason } : {}), }; } async function handleVacation( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'vacation', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } const autorunUser = asRecord(world.getState().meta.autorun_user); if (autorunUser.limit_minutes) { return { type: 'vacation', ok: false, generalId: command.generalId, reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.', }; } const killturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0); world.updateGeneral(general.id, { meta: { ...general.meta, killturn: killturn * 3, }, }); return { type: 'vacation', ok: true, generalId: command.generalId }; } const normalizeDefenceTrain = (value: number): number => { if (value <= 40) { return 40; } if (value <= 90) { return Math.round(value / 10) * 10; } return 999; }; async function handleSetMySetting( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'setMySetting', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.', }; } const settings = command.settings; const previousDefenceTrain = readMetaNumber(general.meta, 'defence_train', 80); const nextDefenceTrain = settings.defence_train === undefined ? previousDefenceTrain : normalizeDefenceTrain(settings.defence_train); const nextMeta = { ...general.meta }; if (settings.tnmt !== undefined) { nextMeta.tnmt = settings.tnmt < 0 || settings.tnmt > 1 ? 1 : settings.tnmt; } if (settings.use_treatment !== undefined) { nextMeta.use_treatment = Math.max(10, Math.min(100, settings.use_treatment)); } if (settings.use_auto_nation_turn !== undefined) { nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn; } let nextTrain = general.train; let nextAtmos = general.atmos; if (nextDefenceTrain !== previousDefenceTrain) { nextMeta.myset = readMetaNumber(general.meta, 'myset', 0) - 1; nextMeta.defence_train = nextDefenceTrain; if (nextDefenceTrain === 999) { const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect; const ignoresPenalty = isDefenceTrainPenaltyWaivedByScenarioEffect(scenarioEffect); const constValues = asRecord(world.getScenarioConfig().const); const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100); const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100); const trainDelta = ignoresPenalty ? 0 : -3; const atmosDelta = ignoresPenalty ? 0 : -6; nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta)); nextAtmos = Math.max(20, Math.min(maxAtmos, general.atmos + atmosDelta)); } } world.updateGeneral(command.generalId, { meta: nextMeta, train: nextTrain, atmos: nextAtmos, }); return { type: 'setMySetting', ok: true, generalId: command.generalId }; } async function handleDropItem( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } const slot = (['horse', 'weapon', 'book', 'item'] as const).find((candidate) => candidate === command.itemType); if (!slot || !general.role.items[slot]) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' }; } const nextGeneral = { ...general, role: { ...general.role, items: { ...general.role.items } }, itemInventory: cloneItemInventory(ensureItemInventory(general)), }; removeEquippedItem(nextGeneral, slot); world.updateGeneral(command.generalId, { role: nextGeneral.role, itemInventory: nextGeneral.itemInventory, }); return { type: 'dropItem', ok: true, generalId: command.generalId }; } async function handleAuctionFinalize( ctx: CommandHandlerContext, command: Extract ): Promise { if (!ctx.auctionFinalizer) { return { type: 'auctionFinalize', ok: false, auctionId: command.auctionId, reason: '경매 확정기가 준비되지 않았습니다.', }; } return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb); } async function handleAuctionOpen( ctx: CommandHandlerContext, command: Extract ): Promise { return openAuction(command, ctx.world, ctx.commandDb); } async function handleAuctionBid( ctx: CommandHandlerContext, command: Extract ): Promise { if (!ctx.auctionBidder) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '경매 입찰기가 준비되지 않았습니다.', }; } return ctx.auctionBidder.bid(command, ctx.commandDb); } async function handleChangePermission( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'changePermission', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.', }; } const nation = world.getNationById(general.nationId); if (!nation || general.officerLevel !== 12 || nation.chiefGeneralId !== general.id) { return { type: 'changePermission', ok: false, generalId: command.generalId, reason: '군주가 아닙니다.' }; } const uniqueTargetIds = [...new Set(command.targetGeneralIds)]; if (uniqueTargetIds.length > 2) { return { type: 'changePermission', ok: false, generalId: command.generalId, reason: command.isAmbassador ? '외교권자는 최대 둘까지만 설정 가능합니다.' : '조언자는 최대 둘까지만 설정 가능합니다.', }; } const targetType = command.isAmbassador ? 'ambassador' : 'auditor'; const requiredPermission = command.isAmbassador ? 4 : 3; const targets: TurnGeneral[] = []; for (const targetId of uniqueTargetIds) { const target = world.getGeneralById(targetId); if ( !target || target.nationId !== general.nationId || target.officerLevel === 12 || !['normal', targetType].includes(resolvePermissionKind(target)) || resolveMaxSecretPermission(target) < requiredPermission ) { continue; } targets.push(target); } for (const candidate of world.listGenerals()) { if (candidate.nationId !== general.nationId || resolvePermissionKind(candidate) !== targetType) { continue; } world.updateGeneral(candidate.id, { meta: { ...candidate.meta, permission: 'normal', }, }); } for (const target of targets) { world.updateGeneral(target.id, { meta: { ...target.meta, permission: targetType, }, }); } return { type: 'changePermission', ok: true, generalId: command.generalId }; } async function handleKick( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'kick', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } const nation = world.getNationById(general.nationId); if (!nation || general.officerLevel < 5) { return { type: 'kick', ok: false, generalId: command.generalId, reason: '수뇌가 아닙니다.' }; } const actorPenalty = asRecord(general.penalty); if (actorPenalty.noBanGeneral) { return { type: 'kick', ok: false, generalId: command.generalId, reason: '추방할 수 없는 상태입니다.' }; } if (hasOfficerLock(asRecord(nation.meta), 'chief_set', general.officerLevel)) { return { type: 'kick', ok: false, generalId: command.generalId, reason: '이미 추방 권한을 사용했습니다.', }; } const target = world.getGeneralById(command.destGeneralId); if (!target || target.id === general.id || target.nationId !== general.nationId) { return { type: 'kick', ok: false, generalId: command.generalId, reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.', }; } if (resolveMaxSecretPermission(target) === 4 && resolvePermissionKind(target) === 'ambassador') { return { type: 'kick', ok: false, generalId: command.generalId, reason: '외교권자는 추방할 수 없습니다.', }; } const config = asRecord(world.getScenarioConfig().const); const defaultGold = readMetaNumber(config, 'defaultGold', 1000); const defaultRice = readMetaNumber(config, 'defaultRice', 1000); const returnedGold = Math.max(0, target.gold - defaultGold); const returnedRice = Math.max(0, target.rice - defaultRice); const targetMeta = target.meta; const nextMeta: TurnGeneral['meta'] = { ...targetMeta, officerCity: 0, officer_city: 0, belong: 0, makelimit: 12, permission: 'normal', }; const worldState = world.getState(); const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta); const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear); if (worldState.currentYear > startYear || target.npcState >= 2) { const betray = Math.max(0, readMetaNumber(targetMeta, 'betray', 0)); const maxBetrayCnt = readMetaNumber(config, 'maxBetrayCnt', 9); nextMeta.betray = Math.min(maxBetrayCnt, betray + 1); world.updateGeneral(target.id, { experience: Math.max(0, Math.floor(target.experience - target.experience * 0.15 * betray)), dedication: Math.max(0, Math.floor(target.dedication - target.dedication * 0.15 * betray)), }); } else { nextMeta.makelimit = targetMeta.makelimit ?? 12; } if (worldState.currentYear < startYear + 3) { const ruler = world.getGeneralById(nation.chiefGeneralId ?? 0); if (ruler) { world.updateGeneral(ruler.id, { injury: Math.min(80, ruler.injury + 1) }); } } if (target.troopId === target.id) { for (const member of world.listGenerals()) { if (member.troopId === target.id) { world.updateGeneral(member.id, { troopId: 0 }); } } world.removeTroop(target.id); } world.updateGeneral(command.destGeneralId, { nationId: 0, officerLevel: 0, troopId: 0, gold: Math.min(target.gold, defaultGold), rice: Math.min(target.rice, defaultRice), meta: nextMeta, }); const nationMeta = worldState.currentYear >= startYear + 3 ? setOfficerLock(nation.meta, 'chief_set', general.officerLevel) : { ...nation.meta }; nationMeta.gennum = Math.max(0, readMetaNumber(nation.meta, 'gennum', 0) - (target.npcState !== 5 ? 1 : 0)); world.updateNation(nation.id, { gold: nation.gold + returnedGold, rice: nation.rice + returnedRice, meta: nationMeta, }); refreshActorKillturn(world, general); const josaYi = JosaUtil.pick(target.name, '이'); world.pushLog({ scope: LogScope.SYSTEM, category: LogCategory.SUMMARY, format: LogFormat.MONTH, text: `${target.name}${josaYi} ${nation.name}에서 추방당했습니다.`, meta: {}, }); world.pushLog({ scope: LogScope.GENERAL, category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, text: `${nation.name}에서 추방됨`, generalId: target.id, meta: {}, }); if (target.npcState >= 2) { const worldMeta = asRecord(worldState.meta); const hiddenSeed = worldMeta.hiddenSeed ?? worldMeta.seed ?? worldState.id; const rng = new RandUtil( new LiteHashDRBG( simpleSerialize( typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed), 'BanNPC', worldState.currentYear, worldState.currentMonth, target.id ) ) ); const npcBanMessageProb = readMetaNumber(config, 'npcBanMessageProb', 0.01); if (rng.nextBool(npcBanMessageProb)) { const text = rng.choice([ '날 버리다니... 곧 전장에서 복수해주겠다...', '추방이라... 내가 무얼 잘못했단 말인가...', '어디 추방해가면서 잘되나 보자... 꼭 복수하겠다.', '인덕이 제일이거늘... 추방이 웬말인가... 저주한다!', '날 추방했으니 그 복수로 적국에 정보를 팔아 넘겨야겠군요. 그럼 이만.', ]); const messageTarget = { generalId: target.id, generalName: target.name, nationId: nation.id, nationName: nation.name, color: nation.color, icon: target.picture === null ? '' : String(target.picture), }; world.queueMessage({ msgType: 'public', src: messageTarget, dest: messageTarget, text, time: new Date(), validUntil: new Date('9999-12-31T00:00:00.000Z'), option: {}, }); } } return { type: 'kick', ok: true, generalId: command.generalId }; } async function handleAppoint( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } const nation = world.getNationById(general.nationId); if (!nation || general.officerLevel < 5) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '수뇌가 아닙니다.' }; } const actorPenalty = asRecord(general.penalty); if (command.officerLevel === 12) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '군주를 대상으로 할 수 없습니다.', }; } const target = world.getGeneralById(command.destGeneralId); if (command.destGeneralId !== 0 && (!target || target.nationId !== general.nationId)) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.', }; } if (command.officerLevel >= 5 && command.officerLevel < 12) { if (actorPenalty.noChiefChange) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '수뇌를 임명할 수 없는 상태입니다.', }; } const minLevel = resolveNationChiefLevel(nation.level); if (command.officerLevel < minLevel) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '임명불가능한 관직입니다.', }; } if (hasOfficerLock(asRecord(nation.meta), 'chief_set', command.officerLevel)) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '지금은 임명할 수 없습니다.', }; } if (target) { const targetPenalty = asRecord(target.penalty); if (targetPenalty.noChief) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '수뇌가 될 수 없는 상태입니다.', }; } const chiefStatMin = world.getScenarioConfig().stat.chiefMin; if (command.officerLevel !== 11 && command.officerLevel % 2 === 0 && target.stats.strength < chiefStatMin) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '무력이 부족합니다.' }; } if ( command.officerLevel !== 11 && command.officerLevel % 2 === 1 && target.stats.intelligence < chiefStatMin ) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '지력이 부족합니다.' }; } } for (const g of world.listGenerals()) { if (g.nationId === general.nationId && g.officerLevel === command.officerLevel) { world.updateGeneral(g.id, { officerLevel: 1, meta: { ...g.meta, officerCity: 0, officer_city: 0 }, }); } } if (command.destGeneralId !== 0) { world.updateGeneral(command.destGeneralId, { officerLevel: command.officerLevel, meta: { ...target!.meta, officerCity: 0, officer_city: 0 }, }); } world.updateNation(nation.id, { meta: setOfficerLock(nation.meta, 'chief_set', command.officerLevel), }); } else if (command.officerLevel >= 2 && command.officerLevel <= 4) { const city = world.getCityById(command.destCityId); if (!city || city.nationId !== general.nationId) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '도시를 찾을 수 없거나 아군 도시가 아닙니다.', }; } if (hasOfficerLock(asRecord(city.meta), 'officer_set', command.officerLevel)) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '이미 다른 장수가 임명되어있습니다.', }; } if (target && target.officerLevel >= 4 && actorPenalty.noChiefChange) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '수뇌인 장수를 변경할 수 없는 상태입니다.', }; } const chiefStatMin = world.getScenarioConfig().stat.chiefMin; if (target && command.officerLevel === 4 && target.stats.strength < chiefStatMin) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '무력이 부족합니다.' }; } if (target && command.officerLevel === 3 && target.stats.intelligence < chiefStatMin) { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '지력이 부족합니다.' }; } for (const g of world.listGenerals()) { if ( g.nationId === general.nationId && g.meta.officerCity === command.destCityId && g.officerLevel === command.officerLevel ) { world.updateGeneral(g.id, { officerLevel: 1, meta: { ...g.meta, officerCity: 0, officer_city: 0 }, }); } } if (command.destGeneralId !== 0) { world.updateGeneral(command.destGeneralId, { officerLevel: command.officerLevel, meta: { ...target!.meta, officerCity: command.destCityId, officer_city: command.destCityId }, }); } world.updateCity(city.id, { meta: setOfficerLock(city.meta, 'officer_set', command.officerLevel), }); } else { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' }; } refreshActorKillturn(world, general); return { type: 'appoint', ok: true, generalId: command.generalId }; } async function handleTournamentRefund( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; if (!command.refunds || command.refunds.length === 0) { return { type: 'tournamentRefund', ok: false, bettingId: command.bettingId, reason: '환불 대상이 없습니다.', }; } let processed = 0; let missing = 0; let totalRefund = 0; for (const refund of command.refunds) { if (!refund || typeof refund.generalId !== 'number' || typeof refund.amount !== 'number') { continue; } if (refund.amount <= 0) { continue; } const general = world.getGeneralById(refund.generalId); if (!general) { missing += 1; continue; } world.updateGeneral(refund.generalId, { gold: general.gold + refund.amount, }); processed += 1; totalRefund += refund.amount; } return { type: 'tournamentRefund', ok: true, bettingId: command.bettingId, processed, missing, totalRefund, }; } async function handleTournamentBettingPayout( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; if (!command.payouts || command.payouts.length === 0) { return { type: 'tournamentBettingPayout', ok: false, bettingId: command.bettingId, reason: '정산 대상이 없습니다.', }; } let processed = 0; let missing = 0; let totalPayout = 0; const metaDeltas = new Map(); for (const payout of command.payouts) { if (!payout || typeof payout.generalId !== 'number' || typeof payout.amount !== 'number') { continue; } if (payout.amount <= 0) { continue; } const general = world.getGeneralById(payout.generalId); if (!general) { missing += 1; continue; } world.updateGeneral(payout.generalId, { gold: general.gold + payout.amount, }); processed += 1; totalPayout += payout.amount; const currentDelta = metaDeltas.get(payout.generalId) ?? { betwin: 0, betwingold: 0 }; metaDeltas.set(payout.generalId, { betwin: currentDelta.betwin + 1, betwingold: currentDelta.betwingold + payout.amount, }); } for (const [generalId, delta] of metaDeltas) { const general = world.getGeneralById(generalId); if (!general) { continue; } const nextMeta = { ...general.meta } as TurnGeneral['meta']; const betwinKey = 'betwin'; const betwingoldKey = 'betwingold'; const currentBetwin = typeof nextMeta[betwinKey] === 'number' ? Number(nextMeta[betwinKey]) : 0; const currentBetwingold = typeof nextMeta[betwingoldKey] === 'number' ? Number(nextMeta[betwingoldKey]) : 0; nextMeta[betwinKey] = currentBetwin + delta.betwin; nextMeta[betwingoldKey] = currentBetwingold + delta.betwingold; world.updateGeneral(generalId, { meta: nextMeta }); } return { type: 'tournamentBettingPayout', ok: true, bettingId: command.bettingId, processed, missing, totalPayout, }; } async function handleTournamentReward( ctx: CommandHandlerContext, command: Extract ): Promise { if (!ctx.tournamentRewardFinalizer) { return { type: 'tournamentReward', ok: false, winnerId: command.winnerId, runnerUpId: command.runnerUpId, reason: '보상 처리기가 준비되지 않았습니다.', }; } return ctx.tournamentRewardFinalizer.finalize(command, ctx.commandDb); } // 설문 보상은 API에서 전달된 RNG 결과를 재검증한 뒤 월드에 반영한다. async function handleVoteReward( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'voteReward', ok: false, voteId: command.voteId, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.', }; } const baseMeta = general.meta; const metaRecord = asRecord(baseMeta); const existingRewards = asRecord(metaRecord.voteRewards); const rewardKey = String(command.voteId); if (Object.prototype.hasOwnProperty.call(existingRewards, rewardKey)) { const existingValue = existingRewards[rewardKey]; const existingEntry = asRecord(existingValue); const existingItemKey = typeof existingEntry.itemKey === 'string' ? existingEntry.itemKey : null; const awarded = existingEntry.awarded === true || Boolean(existingItemKey); return { type: 'voteReward', ok: true, voteId: command.voteId, generalId: command.generalId, awardedUnique: awarded, itemKey: existingItemKey ?? null, alreadyApplied: true, }; } const worldState = world.getState(); const worldMeta = asRecord(worldState.meta); const scenarioMeta = asRecord(worldMeta.scenarioMeta); const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear); const initYear = readMetaNumber(worldMeta, 'initYear', startYear); const initMonth = readMetaNumber(worldMeta, 'initMonth', 1); const scenarioId = readMetaNumber(worldMeta, 'scenarioId', 0); const hiddenSeed = worldMeta.hiddenSeed ?? worldMeta.seed ?? worldState.id; const itemRegistry = await getItemRegistry(); const configConst = asRecord(world.getScenarioConfig().const); const uniqueConfig = resolveUniqueConfig(configConst); const generals = world.listGenerals(); const occupiedUniqueCounts = countOccupiedUniqueItems( generals.map((entry) => entry.role.items), itemRegistry ); if (ctx.commandDb) { const reservedUniqueRows = await ctx.commandDb.auction.findMany({ where: { type: 'UNIQUE_ITEM', status: { in: ['OPEN', 'FINALIZING'] }, targetCode: { not: null }, }, select: { targetCode: true }, }); addOccupiedUniqueItemKeys( occupiedUniqueCounts, reservedUniqueRows.map((row) => row.targetCode), itemRegistry ); } const userCount = generals.filter((entry) => entry.npcState < 2).length; const rngSeed = buildVoteUniqueSeed( typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed), command.voteId, command.generalId ); const rng = new RandUtil(LiteHashDRBG.build(rngSeed)); const itemKey = rollUniqueLottery({ rng, config: uniqueConfig, itemRegistry, generalItems: general.role.items, occupiedUniqueCounts, scenarioId, userCount, currentYear: worldState.currentYear, currentMonth: worldState.currentMonth, startYear, initYear, initMonth, acquireType: '설문조사', }); const expectedUnique = command.unique?.expected ?? false; const expectedItemKey = command.unique?.itemKey ?? null; if (expectedUnique !== Boolean(itemKey)) { return { type: 'voteReward', ok: false, voteId: command.voteId, generalId: command.generalId, reason: '유니크 판정이 일치하지 않습니다.', }; } if (expectedUnique) { if (!expectedItemKey || !itemKey || expectedItemKey !== itemKey) { return { type: 'voteReward', ok: false, voteId: command.voteId, generalId: command.generalId, reason: '유니크 판정이 일치하지 않습니다.', }; } } else if (itemKey) { return { type: 'voteReward', ok: false, voteId: command.voteId, generalId: command.generalId, reason: '유니크 판정이 일치하지 않습니다.', }; } const rewardEntry: Record = { awarded: Boolean(itemKey), at: new Date().toISOString(), }; if (itemKey) { rewardEntry.itemKey = itemKey; } const nextVoteRewards: Record = { ...(existingRewards as Record), [rewardKey]: rewardEntry, }; const nextMeta: TurnGeneral['meta'] = { ...baseMeta, voteRewards: nextVoteRewards, }; const patch: Partial = { gold: general.gold + command.goldReward, meta: nextMeta, }; if (itemKey) { const itemModule = itemRegistry.get(itemKey); if (!itemModule) { return { type: 'voteReward', ok: false, voteId: command.voteId, generalId: command.generalId, reason: '유니크 아이템을 찾을 수 없습니다.', }; } const nextGeneral = { ...general, role: { ...general.role, items: { ...general.role.items } }, itemInventory: cloneItemInventory(ensureItemInventory(general)), }; equipNewItem(nextGeneral, itemModule.slot, itemKey, { ...(itemModule.initialCharges === undefined ? {} : { charges: itemModule.initialCharges }), }); patch.role = nextGeneral.role; patch.itemInventory = nextGeneral.itemInventory; const nationName = world.getNationById(general.nationId)?.name ?? '재야'; const generalName = general.name; const itemName = itemModule.name; const itemRawName = itemModule.rawName; const josaYi = JosaUtil.pick(generalName, '이'); const josaUl = JosaUtil.pick(itemRawName, '을'); world.pushLog({ scope: LogScope.GENERAL, category: LogCategory.ACTION, format: LogFormat.MONTH, text: `${itemName}${josaUl} 습득했습니다!`, generalId: general.id, meta: {}, }); world.pushLog({ scope: LogScope.GENERAL, category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, text: `${itemName}${josaUl} 습득`, generalId: general.id, meta: {}, }); world.pushLog({ scope: LogScope.SYSTEM, category: LogCategory.SUMMARY, format: LogFormat.MONTH, text: `${generalName}${josaYi} ${itemName}${josaUl} 습득했습니다!`, meta: {}, }); world.pushLog({ scope: LogScope.SYSTEM, category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, text: `【설문조사】${nationName}${generalName}${josaYi} ${itemName}${josaUl} 습득했습니다!`, meta: {}, }); } world.updateGeneral(command.generalId, patch); return { type: 'voteReward', ok: true, voteId: command.voteId, generalId: command.generalId, awardedUnique: Boolean(itemKey), ...(itemKey ? { itemKey } : {}), }; } export const createTurnDaemonCommandHandler = (options: { world: InMemoryTurnWorld; reservedTurns?: InMemoryReservedTurnStore; scenarioMeta?: ScenarioMeta; map?: MapDefinition; commandProfile?: TurnCommandProfile; getAdditionalOccupiedUniqueItemKeys?: () => Iterable; auctionFinalizer?: AuctionFinalizer; auctionBidder?: AuctionBidder; tournamentRewardFinalizer?: TournamentRewardFinalizer; }): TurnDaemonCommandHandler => { let immediateGeneralActionExecutor: Promise | null = null; const ctx: CommandHandlerContext = { world: options.world, auctionFinalizer: options.auctionFinalizer, auctionBidder: options.auctionBidder, tournamentRewardFinalizer: options.tournamentRewardFinalizer, getImmediateGeneralActionExecutor: () => { immediateGeneralActionExecutor ??= createImmediateGeneralActionExecutor({ world: options.world, reservedTurns: options.reservedTurns, scenarioMeta: options.scenarioMeta, map: options.map, commandProfile: options.commandProfile, getAdditionalOccupiedUniqueItemKeys: async () => { if (ctx.commandDb) { const rows = await ctx.commandDb.auction.findMany({ where: { type: 'UNIQUE_ITEM', status: { in: ['OPEN', 'FINALIZING'] }, targetCode: { not: null }, }, select: { targetCode: true }, }); return rows.map((row) => row.targetCode); } return options.getAdditionalOccupiedUniqueItemKeys?.() ?? []; }, }); return immediateGeneralActionExecutor; }, }; type HandlerMap = Partial< Record Promise> >; const handlers: HandlerMap = { troopCreate: (command) => handleTroopCreate(ctx, command as Extract), troopJoin: (command) => handleTroopJoin(ctx, command as Extract), troopExit: (command) => handleTroopExit(ctx, command as Extract), troopKick: (command) => handleTroopKick(ctx, command as Extract), troopRename: (command) => handleTroopRename(ctx, command as Extract), dieOnPrestart: (command) => handleDieOnPrestart(ctx, command as Extract), buildNationCandidate: (command) => handleBuildNationCandidate(ctx, command as Extract), instantRetreat: (command) => handleInstantRetreat(ctx, command as Extract), vacation: (command) => handleVacation(ctx, command as Extract), setMySetting: (command) => handleSetMySetting(ctx, command as Extract), dropItem: (command) => handleDropItem(ctx, command as Extract), auctionFinalize: (command) => handleAuctionFinalize(ctx, command as Extract), auctionOpen: (command) => handleAuctionOpen(ctx, command as Extract), auctionBid: (command) => handleAuctionBid(ctx, command as Extract), changePermission: (command) => handleChangePermission(ctx, command as Extract), kick: (command) => handleKick(ctx, command as Extract), appoint: (command) => handleAppoint(ctx, command as Extract), tournamentRefund: (command) => handleTournamentRefund(ctx, command as Extract), tournamentBettingPayout: (command) => handleTournamentBettingPayout( ctx, command as Extract ), tournamentReward: (command) => handleTournamentReward(ctx, command as Extract), voteReward: (command) => handleVoteReward(ctx, command as Extract), setNationMeta: (command) => handleSetNationMeta(ctx, command as Extract), adjustGeneralResources: (command) => handleAdjustGeneralResources( ctx, command as Extract ), adjustGeneralMeta: (command) => handleAdjustGeneralMeta(ctx, command as Extract), tournamentMatchResult: (command) => handleTournamentMatchResult(ctx, command as Extract), patchGeneral: (command) => handlePatchGeneral(ctx, command as Extract), joinCreateGeneral: (command) => handleJoinCreateGeneral(ctx, command as Extract), npcPossessGeneral: (command) => handleNpcPossessGeneral(ctx, command as Extract), selectPoolCreate: (command) => handleSelectPoolCreate(ctx, command as Extract), selectPoolReselect: (command) => handleSelectPoolReselect(ctx, command as Extract), shiftSchedule: (command) => handleShiftSchedule(ctx, command as Extract), }; return { handle: async ( command, executionContext?: TurnDaemonCommandExecutionContext ): Promise => { const handler = handlers[command.type]; if (!handler) { return null; } ctx.commandDb = executionContext?.db; try { return await handler(command); } finally { ctx.commandDb = undefined; } }, }; };