초기화 시 전투 특기를 null로 정규화하고 이전 특기 배열을 보존해 다음 월 고정 배정이 daemon 재시작 없이 동작하게 한다. 다중 변경 내역이 문서 높이를 늘리도록 하고 API, 월간 persistence, 실제 Chromium 회귀 검증을 추가한다.
2698 lines
98 KiB
TypeScript
2698 lines
98 KiB
TypeScript
import type {
|
|
TurnDaemonCommandHandler,
|
|
TurnDaemonCommand,
|
|
TurnDaemonCommandExecutionContext,
|
|
TurnDaemonCommandResult,
|
|
} from '../lifecycle/types.js';
|
|
import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra';
|
|
import {
|
|
asRecord,
|
|
GAME_TICKS_PER_TURN,
|
|
isCanonicalIsoTimestamp,
|
|
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';
|
|
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
|
|
import { respondToActionableMessage } from './actionableMessageResponse.js';
|
|
|
|
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
|
|
|
const getItemRegistry = async (): Promise<Map<string, ItemModule>> => {
|
|
if (!itemRegistryPromise) {
|
|
itemRegistryPromise = loadItemModules([...ITEM_KEYS]).then((modules) => createItemModuleRegistry(modules));
|
|
}
|
|
return itemRegistryPromise;
|
|
};
|
|
|
|
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 hasOfficerLock = (meta: Record<string, unknown>, key: string, officerLevel: number): boolean =>
|
|
(readMetaNumber(meta, key, 0) & (1 << officerLevel)) !== 0;
|
|
|
|
const setOfficerLock = (
|
|
meta: Record<string, TriggerValue>,
|
|
key: string,
|
|
officerLevel: number
|
|
): Record<string, TriggerValue> => ({
|
|
...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<ImmediateGeneralActionExecutor>;
|
|
reservedTurns?: InMemoryReservedTurnStore;
|
|
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
|
}
|
|
|
|
const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
|
if (!ctx.commandDb) {
|
|
throw new Error('ENGINE mutation transaction is required for authenticated commands.');
|
|
}
|
|
return ctx.commandDb as unknown as DatabaseClient;
|
|
};
|
|
|
|
const resolveCommandAcceptedAt = async (
|
|
db: DatabaseClient,
|
|
command: Extract<
|
|
TurnDaemonCommand,
|
|
{
|
|
type:
|
|
| 'dieOnPrestart'
|
|
| 'ensureDieOnPrestartStatus'
|
|
| 'joinCreateGeneral'
|
|
| 'npcPossessGeneral'
|
|
| 'selectPoolCreate'
|
|
| 'selectPoolReselect'
|
|
| 'adjustGeneralIcon';
|
|
}
|
|
>
|
|
): Promise<Date> => {
|
|
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 resolveOperationalAcceptedAt = async (
|
|
db: DatabaseClient,
|
|
command: Pick<TurnDaemonCommand, 'type' | 'requestId'>
|
|
): Promise<Date> => {
|
|
if (!command.requestId) {
|
|
return new Date();
|
|
}
|
|
const event = await db.inputEvent.findUnique({
|
|
where: { requestId: command.requestId },
|
|
select: { createdAt: true, target: true, eventType: true },
|
|
});
|
|
if (!event) {
|
|
throw new Error(`ENGINE input event ${command.requestId} is missing.`);
|
|
}
|
|
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<TurnDaemonCommand, { type: 'buildNationCandidate' | 'instantRetreat' }>,
|
|
general: TurnGeneral
|
|
): Promise<void> => {
|
|
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<TurnDaemonCommand, { type: 'joinCreateGeneral' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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 operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
|
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
|
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.ownerIconRevision !== undefined
|
|
? { ownerIconRevision: command.ownerIconRevision }
|
|
: {}),
|
|
...(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<TurnDaemonCommand, { type: 'npcPossessGeneral' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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 operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
|
const acceptedAt = command.acceptedGameAt
|
|
? new Date(command.acceptedGameAt)
|
|
: ctx.world.getGameNow(operationalAcceptedAt);
|
|
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<TurnDaemonCommand, { type: 'selectPoolCreate' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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 operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
|
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
|
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,
|
|
...(command.ownerPicture ? { ownerPicture: command.ownerPicture } : {}),
|
|
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
|
|
...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}),
|
|
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<TurnDaemonCommand, { type: 'selectPoolReselect' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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 operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
|
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
|
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<TurnDaemonCommandResult>;
|
|
}
|
|
|
|
interface AuctionBidder {
|
|
bid(
|
|
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>,
|
|
db?: GamePrisma.TransactionClient
|
|
): Promise<TurnDaemonCommandResult>;
|
|
}
|
|
|
|
interface TournamentRewardFinalizer {
|
|
finalize(
|
|
command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>,
|
|
db?: GamePrisma.TransactionClient
|
|
): Promise<TurnDaemonCommandResult>;
|
|
}
|
|
|
|
async function handleSetNationMeta(
|
|
ctx: CommandHandlerContext,
|
|
command: Extract<TurnDaemonCommand, { type: 'setNationMeta' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<string, unknown>;
|
|
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<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'tournamentMatchResult' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'patchGeneral' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
const { world } = ctx;
|
|
const general = world.getGeneralById(command.generalId);
|
|
if (!general) {
|
|
return {
|
|
type: 'patchGeneral',
|
|
ok: false,
|
|
generalId: command.generalId,
|
|
reason: '장수 정보를 찾을 수 없습니다.',
|
|
};
|
|
}
|
|
|
|
const patch: Partial<typeof general> = {};
|
|
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 (command.patch.specialWar !== undefined) {
|
|
patch.role = {
|
|
...general.role,
|
|
specialWar: command.patch.specialWar === 'None' ? null : command.patch.specialWar,
|
|
};
|
|
}
|
|
|
|
world.updateGeneral(command.generalId, patch);
|
|
return { type: 'patchGeneral', ok: true, generalId: command.generalId };
|
|
}
|
|
|
|
async function handleAdjustGeneralIcon(
|
|
ctx: CommandHandlerContext,
|
|
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
const db = requireCommandDatabase(ctx);
|
|
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
|
const general = ctx.world
|
|
.listGenerals()
|
|
.find((candidate) => candidate.userId === command.userId && candidate.npcState === 0);
|
|
if (!general) {
|
|
return {
|
|
type: 'adjustGeneralIcon',
|
|
ok: true,
|
|
generalId: null,
|
|
updated: false,
|
|
};
|
|
}
|
|
|
|
if (command.enforceCooldown) {
|
|
if (general.picture === command.picture && general.imageServer === command.imageServer) {
|
|
return {
|
|
type: 'adjustGeneralIcon',
|
|
ok: true,
|
|
generalId: general.id,
|
|
updated: false,
|
|
};
|
|
}
|
|
const changedAt = general.meta.generalIconChangedAt;
|
|
if (typeof changedAt === 'string' && isCanonicalIsoTimestamp(changedAt)) {
|
|
const availableAt = new Date(new Date(changedAt).getTime() + 24 * 60 * 60 * 1000);
|
|
if (availableAt.getTime() > acceptedAt.getTime()) {
|
|
return {
|
|
type: 'adjustGeneralIcon',
|
|
ok: false,
|
|
code: 'TOO_MANY_REQUESTS',
|
|
reason: `${availableAt.toISOString()}부터 전용 아이콘을 다시 바꿀 수 있습니다.`,
|
|
availableAt: availableAt.toISOString(),
|
|
};
|
|
}
|
|
}
|
|
ctx.world.updateGeneral(general.id, {
|
|
picture: command.picture,
|
|
imageServer: command.imageServer,
|
|
meta: {
|
|
...general.meta,
|
|
generalIconChangedAt: acceptedAt.toISOString(),
|
|
},
|
|
});
|
|
return {
|
|
type: 'adjustGeneralIcon',
|
|
ok: true,
|
|
generalId: general.id,
|
|
updated: true,
|
|
};
|
|
}
|
|
|
|
const currentRevision = general.meta.accountIconUpdatedAt;
|
|
if (currentRevision !== undefined) {
|
|
if (typeof currentRevision !== 'string' || !isCanonicalIsoTimestamp(currentRevision)) {
|
|
return {
|
|
type: 'adjustGeneralIcon',
|
|
ok: false,
|
|
code: 'PRECONDITION_FAILED',
|
|
reason: '장수의 계정 아이콘 revision이 올바르지 않습니다.',
|
|
};
|
|
}
|
|
const currentRevisionTime = new Date(currentRevision).getTime();
|
|
const nextRevisionTime = new Date(command.iconRevision).getTime();
|
|
if (nextRevisionTime < currentRevisionTime) {
|
|
return {
|
|
type: 'adjustGeneralIcon',
|
|
ok: false,
|
|
code: 'CONFLICT',
|
|
reason: '더 최신 계정 아이콘이 이미 적용되었습니다.',
|
|
};
|
|
}
|
|
if (nextRevisionTime === currentRevisionTime) {
|
|
if (general.picture === command.picture && general.imageServer === command.imageServer) {
|
|
return {
|
|
type: 'adjustGeneralIcon',
|
|
ok: true,
|
|
generalId: general.id,
|
|
updated: false,
|
|
};
|
|
}
|
|
return {
|
|
type: 'adjustGeneralIcon',
|
|
ok: false,
|
|
code: 'CONFLICT',
|
|
reason: '같은 revision에 다른 계정 아이콘이 요청되었습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
ctx.world.updateGeneral(general.id, {
|
|
picture: command.picture,
|
|
imageServer: command.imageServer,
|
|
meta: {
|
|
...general.meta,
|
|
accountIconUpdatedAt: command.iconRevision,
|
|
},
|
|
});
|
|
return {
|
|
type: 'adjustGeneralIcon',
|
|
ok: true,
|
|
generalId: general.id,
|
|
updated: true,
|
|
};
|
|
}
|
|
|
|
async function handleShiftSchedule(
|
|
ctx: CommandHandlerContext,
|
|
command: Extract<TurnDaemonCommand, { type: 'shiftSchedule' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
if (!ctx.commandDb) {
|
|
return {
|
|
type: 'shiftSchedule',
|
|
ok: false,
|
|
actionId: command.actionId,
|
|
reason: '시간 조정은 데이터베이스 transaction 경계에서만 실행할 수 있습니다.',
|
|
};
|
|
}
|
|
|
|
const operationalAcceptedAt = await resolveOperationalAcceptedAt(ctx.commandDb, command);
|
|
const shifted = ctx.world.shiftSchedule(command.deltaMinutes, operationalAcceptedAt);
|
|
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'
|
|
`
|
|
);
|
|
await ctx.commandDb.$executeRaw(
|
|
GamePrisma.sql`
|
|
UPDATE select_pool
|
|
SET reserved_until = reserved_until + (${command.deltaMinutes} * INTERVAL '1 minute')
|
|
WHERE reserved_until IS NOT NULL
|
|
`
|
|
);
|
|
await ctx.commandDb.$executeRaw(
|
|
GamePrisma.sql`
|
|
UPDATE select_npc_token
|
|
SET valid_until = valid_until + (${command.deltaMinutes} * INTERVAL '1 minute'),
|
|
pick_more_from = pick_more_from + (${command.deltaMinutes} * INTERVAL '1 minute')
|
|
`
|
|
);
|
|
await ctx.commandDb.$executeRaw(
|
|
GamePrisma.sql`
|
|
UPDATE message
|
|
SET time = CASE WHEN time_tick IS NULL THEN time ELSE time + (${command.deltaMinutes} * INTERVAL '1 minute') END,
|
|
valid_until = CASE
|
|
WHEN valid_until_tick IS NULL THEN valid_until
|
|
ELSE valid_until + (${command.deltaMinutes} * INTERVAL '1 minute')
|
|
END
|
|
WHERE time_tick IS NOT NULL OR valid_until_tick IS NOT NULL
|
|
`
|
|
);
|
|
await ctx.commandDb.$executeRaw(
|
|
GamePrisma.sql`
|
|
UPDATE vote_poll
|
|
SET start_at = CASE WHEN start_tick IS NULL THEN start_at ELSE start_at + (${command.deltaMinutes} * INTERVAL '1 minute') END,
|
|
end_at = CASE
|
|
WHEN end_tick IS NULL THEN end_at
|
|
ELSE end_at + (${command.deltaMinutes} * INTERVAL '1 minute')
|
|
END,
|
|
closed_at = CASE
|
|
WHEN closed_at IS NULL THEN NULL
|
|
ELSE closed_at + (${command.deltaMinutes} * INTERVAL '1 minute')
|
|
END
|
|
WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL OR closed_at IS NOT NULL
|
|
`
|
|
);
|
|
|
|
return {
|
|
type: 'shiftSchedule',
|
|
ok: true,
|
|
actionId: command.actionId,
|
|
deltaMinutes: command.deltaMinutes,
|
|
lastTurnTime: shifted.lastTurnTime,
|
|
shiftedGenerals: shifted.shiftedGenerals,
|
|
shiftedAuctions,
|
|
checkpoint: ctx.world.getCheckpoint(),
|
|
};
|
|
}
|
|
|
|
async function handleUpdateRuntimeSettings(
|
|
ctx: CommandHandlerContext,
|
|
command: Extract<TurnDaemonCommand, { type: 'updateRuntimeSettings' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
if (!ctx.commandDb) {
|
|
return {
|
|
type: 'updateRuntimeSettings',
|
|
ok: false,
|
|
actionId: command.actionId,
|
|
reason: '런타임 설정 변경은 데이터베이스 transaction 경계에서만 실행할 수 있습니다.',
|
|
};
|
|
}
|
|
|
|
const operationalAcceptedAt = await resolveOperationalAcceptedAt(ctx.commandDb, command);
|
|
if (command.settings.blockGeneralCreate !== undefined) {
|
|
ctx.world.updateWorldConfig({ blockGeneralCreate: command.settings.blockGeneralCreate });
|
|
}
|
|
if (command.settings.autorunUser !== undefined) {
|
|
ctx.world.updateWorldMeta({
|
|
autorun_user:
|
|
command.settings.autorunUser === null
|
|
? null
|
|
: {
|
|
limit_minutes: command.settings.autorunUser.limitMinutes,
|
|
options: Object.fromEntries(
|
|
command.settings.autorunUser.options.map((option) => [option, true])
|
|
),
|
|
},
|
|
});
|
|
}
|
|
|
|
const currentState = ctx.world.getState();
|
|
const currentTermMinutes = Math.max(1, Math.round(currentState.tickSeconds / 60));
|
|
const term =
|
|
command.settings.turnTermMinutes === undefined
|
|
? {
|
|
changed: false,
|
|
previousTurnTermMinutes: currentTermMinutes,
|
|
turnTermMinutes: currentTermMinutes,
|
|
previousClockBaseTime: (currentState.clockBaseTime ?? currentState.lastTurnTime).toISOString(),
|
|
clockBaseTime: (currentState.clockBaseTime ?? currentState.lastTurnTime).toISOString(),
|
|
shiftedGenerals: 0,
|
|
lastTurnTime: currentState.lastTurnTime.toISOString(),
|
|
}
|
|
: ctx.world.changeTurnTerm(command.settings.turnTermMinutes, operationalAcceptedAt);
|
|
|
|
let reprojectedAuctions = 0;
|
|
let reprojectedMessages = 0;
|
|
let reprojectedVotes = 0;
|
|
if (term.changed) {
|
|
const ticksPerSecond = BigInt(GAME_TICKS_PER_TURN / (term.turnTermMinutes * 60));
|
|
const baseTime = new Date(term.clockBaseTime);
|
|
reprojectedAuctions = await ctx.commandDb.$executeRaw(
|
|
GamePrisma.sql`
|
|
UPDATE auction
|
|
SET close_at = CAST(${baseTime} AS timestamp)
|
|
+ (close_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
|
+ (((close_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond',
|
|
updated_at = NOW()
|
|
WHERE close_tick IS NOT NULL
|
|
`
|
|
);
|
|
reprojectedMessages = await ctx.commandDb.$executeRaw(
|
|
GamePrisma.sql`
|
|
UPDATE message
|
|
SET time = CASE
|
|
WHEN time_tick IS NULL THEN time
|
|
ELSE CAST(${baseTime} AS timestamp)
|
|
+ (time_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
|
+ (((time_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
|
|
END,
|
|
valid_until = CASE
|
|
WHEN valid_until_tick IS NULL THEN valid_until
|
|
ELSE CAST(${baseTime} AS timestamp)
|
|
+ (valid_until_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
|
+ (((valid_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
|
|
END
|
|
WHERE time_tick IS NOT NULL OR valid_until_tick IS NOT NULL
|
|
`
|
|
);
|
|
reprojectedVotes = await ctx.commandDb.$executeRaw(
|
|
GamePrisma.sql`
|
|
UPDATE vote_poll
|
|
SET start_at = CASE
|
|
WHEN start_tick IS NULL THEN start_at
|
|
ELSE CAST(${baseTime} AS timestamp)
|
|
+ (start_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
|
+ (((start_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
|
|
END,
|
|
end_at = CASE
|
|
WHEN end_tick IS NULL THEN end_at
|
|
ELSE CAST(${baseTime} AS timestamp)
|
|
+ (end_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
|
+ (((end_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
|
|
END
|
|
WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL
|
|
`
|
|
);
|
|
ctx.world.pushLog({
|
|
scope: LogScope.SYSTEM,
|
|
category: LogCategory.HISTORY,
|
|
text: `<R>★</>턴시간이 <C>${term.turnTermMinutes}분</>으로 변경됩니다.`,
|
|
});
|
|
}
|
|
|
|
return {
|
|
type: 'updateRuntimeSettings',
|
|
ok: true,
|
|
actionId: command.actionId,
|
|
settings: command.settings,
|
|
termChanged: term.changed,
|
|
previousTurnTermMinutes: term.previousTurnTermMinutes,
|
|
turnTermMinutes: term.turnTermMinutes,
|
|
previousClockBaseTime: term.previousClockBaseTime,
|
|
clockBaseTime: term.clockBaseTime,
|
|
lastTurnTime: term.lastTurnTime,
|
|
shiftedGenerals: term.shiftedGenerals,
|
|
reprojectedAuctions,
|
|
reprojectedMessages,
|
|
reprojectedVotes,
|
|
checkpoint: ctx.world.getCheckpoint(),
|
|
};
|
|
}
|
|
|
|
async function handleTroopJoin(
|
|
ctx: CommandHandlerContext,
|
|
command: Extract<TurnDaemonCommand, { type: 'troopJoin' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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: `부대 주둔지인 <G><b>${city.name}</b></>${josaRo} 즉시 이동합니다.`,
|
|
generalId: general.id,
|
|
meta: {},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
type: 'troopJoin',
|
|
ok: true,
|
|
generalId: command.generalId,
|
|
troopId: command.troopId,
|
|
};
|
|
}
|
|
|
|
async function handleTroopCreate(
|
|
ctx: CommandHandlerContext,
|
|
command: Extract<TurnDaemonCommand, { type: 'troopCreate' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'troopExit' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'troopKick' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'troopRename' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 || general.npcState !== 0 || general.userId !== command.userId) {
|
|
return {
|
|
type: 'dieOnPrestart',
|
|
ok: false,
|
|
generalId: command.generalId,
|
|
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: '게임이 시작되었습니다.',
|
|
};
|
|
}
|
|
|
|
if (general.nationId !== 0) {
|
|
return {
|
|
type: 'dieOnPrestart',
|
|
ok: false,
|
|
generalId: command.generalId,
|
|
reason: '이미 국가에 소속되어있습니다.',
|
|
};
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
async function handleBuildNationCandidate(
|
|
ctx: CommandHandlerContext,
|
|
command: Extract<TurnDaemonCommand, { type: 'buildNationCandidate' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'instantRetreat' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
const { world } = ctx;
|
|
const config = world.getScenarioConfig();
|
|
const availableInstantAction = config.const.availableInstantAction as Record<string, boolean> | 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 handleMessageRespond(
|
|
ctx: CommandHandlerContext,
|
|
command: Extract<TurnDaemonCommand, { type: 'messageRespond' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
if (!ctx.getImmediateGeneralActionExecutor) {
|
|
throw new Error('Immediate general action runtime is not configured.');
|
|
}
|
|
const result = await respondToActionableMessage({
|
|
db: requireCommandDatabase(ctx) as GamePrisma.TransactionClient,
|
|
world: ctx.world,
|
|
reservedTurns: ctx.reservedTurns,
|
|
executor: await ctx.getImmediateGeneralActionExecutor(),
|
|
requestId: command.requestId,
|
|
userId: command.userId,
|
|
generalId: command.generalId,
|
|
messageId: command.messageId,
|
|
response: command.response,
|
|
loadArchivedNationMaxId: ctx.loadArchivedNationMaxId,
|
|
});
|
|
return {
|
|
type: 'messageRespond',
|
|
ok: result.ok,
|
|
generalId: command.generalId,
|
|
messageId: command.messageId,
|
|
...(result.action ? { action: result.action } : {}),
|
|
reason: result.reason,
|
|
};
|
|
}
|
|
|
|
async function handleVacation(
|
|
ctx: CommandHandlerContext,
|
|
command: Extract<TurnDaemonCommand, { type: 'vacation' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'setMySetting' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'dropItem' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'auctionFinalize' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'auctionOpen' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
const worldMeta = asRecord(ctx.world.getState().meta);
|
|
if (Number(worldMeta.isUnited ?? 0) !== 0 || Number(worldMeta.isunited ?? 0) !== 0) {
|
|
return { type: 'auctionOpen', ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' };
|
|
}
|
|
return openAuction(command, ctx.world, ctx.commandDb);
|
|
}
|
|
|
|
async function handleAuctionBid(
|
|
ctx: CommandHandlerContext,
|
|
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
const worldMeta = asRecord(ctx.world.getState().meta);
|
|
if (Number(worldMeta.isUnited ?? 0) !== 0 || Number(worldMeta.isunited ?? 0) !== 0) {
|
|
return {
|
|
type: 'auctionBid',
|
|
ok: false,
|
|
auctionId: command.auctionId,
|
|
reason: '천하통일 후에는 경매를 이용할 수 없습니다.',
|
|
};
|
|
}
|
|
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<TurnDaemonCommand, { type: 'changePermission' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'kick' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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: `<Y>${target.name}</>${josaYi} <D><b>${nation.name}</b></>에서 <R>추방</>당했습니다.`,
|
|
meta: {},
|
|
});
|
|
world.pushLog({
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.HISTORY,
|
|
format: LogFormat.YEAR_MONTH,
|
|
text: `<D>${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: world.getGameNow(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<TurnDaemonCommand, { type: 'appoint' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'tournamentRefund' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<number, { betwin: number; betwingold: number }>();
|
|
|
|
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<TurnDaemonCommand, { type: 'tournamentReward' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<TurnDaemonCommand, { type: 'voteReward' }>
|
|
): Promise<TurnDaemonCommandResult> {
|
|
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<string, TriggerValue> = {
|
|
awarded: Boolean(itemKey),
|
|
at: new Date().toISOString(),
|
|
};
|
|
if (itemKey) {
|
|
rewardEntry.itemKey = itemKey;
|
|
}
|
|
|
|
const nextVoteRewards: Record<string, TriggerValue> = {
|
|
...(existingRewards as Record<string, TriggerValue>),
|
|
[rewardKey]: rewardEntry,
|
|
};
|
|
|
|
const nextMeta: TurnGeneral['meta'] = {
|
|
...baseMeta,
|
|
voteRewards: nextVoteRewards,
|
|
};
|
|
|
|
const patch: Partial<TurnGeneral> = {
|
|
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: `<C>${itemName}</>${josaUl} 습득했습니다!`,
|
|
generalId: general.id,
|
|
meta: {},
|
|
});
|
|
world.pushLog({
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.HISTORY,
|
|
format: LogFormat.YEAR_MONTH,
|
|
text: `<C>${itemName}</>${josaUl} 습득`,
|
|
generalId: general.id,
|
|
meta: {},
|
|
});
|
|
world.pushLog({
|
|
scope: LogScope.SYSTEM,
|
|
category: LogCategory.SUMMARY,
|
|
format: LogFormat.MONTH,
|
|
text: `<Y>${generalName}</>${josaYi} <C>${itemName}</>${josaUl} 습득했습니다!`,
|
|
meta: {},
|
|
});
|
|
world.pushLog({
|
|
scope: LogScope.SYSTEM,
|
|
category: LogCategory.HISTORY,
|
|
format: LogFormat.YEAR_MONTH,
|
|
text: `<C><b>【설문조사】</b></><D><b>${nationName}</b></>의 <Y>${generalName}</>${josaYi} <C>${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<string | null | undefined>;
|
|
auctionFinalizer?: AuctionFinalizer;
|
|
auctionBidder?: AuctionBidder;
|
|
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
|
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
|
}): TurnDaemonCommandHandler => {
|
|
let immediateGeneralActionExecutor: Promise<ImmediateGeneralActionExecutor> | null = null;
|
|
const ctx: CommandHandlerContext = {
|
|
world: options.world,
|
|
auctionFinalizer: options.auctionFinalizer,
|
|
auctionBidder: options.auctionBidder,
|
|
tournamentRewardFinalizer: options.tournamentRewardFinalizer,
|
|
reservedTurns: options.reservedTurns,
|
|
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
|
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<TurnDaemonCommand['type'], (command: TurnDaemonCommand) => Promise<TurnDaemonCommandResult>>
|
|
>;
|
|
|
|
const handlers: HandlerMap = {
|
|
troopCreate: (command) =>
|
|
handleTroopCreate(ctx, command as Extract<TurnDaemonCommand, { type: 'troopCreate' }>),
|
|
troopJoin: (command) => handleTroopJoin(ctx, command as Extract<TurnDaemonCommand, { type: 'troopJoin' }>),
|
|
troopExit: (command) => handleTroopExit(ctx, command as Extract<TurnDaemonCommand, { type: 'troopExit' }>),
|
|
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) =>
|
|
handleBuildNationCandidate(ctx, command as Extract<TurnDaemonCommand, { type: 'buildNationCandidate' }>),
|
|
instantRetreat: (command) =>
|
|
handleInstantRetreat(ctx, command as Extract<TurnDaemonCommand, { type: 'instantRetreat' }>),
|
|
messageRespond: (command) =>
|
|
handleMessageRespond(ctx, command as Extract<TurnDaemonCommand, { type: 'messageRespond' }>),
|
|
vacation: (command) => handleVacation(ctx, command as Extract<TurnDaemonCommand, { type: 'vacation' }>),
|
|
setMySetting: (command) =>
|
|
handleSetMySetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setMySetting' }>),
|
|
dropItem: (command) => handleDropItem(ctx, command as Extract<TurnDaemonCommand, { type: 'dropItem' }>),
|
|
auctionFinalize: (command) =>
|
|
handleAuctionFinalize(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>),
|
|
auctionOpen: (command) =>
|
|
handleAuctionOpen(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionOpen' }>),
|
|
auctionBid: (command) => handleAuctionBid(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionBid' }>),
|
|
changePermission: (command) =>
|
|
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
|
|
kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>),
|
|
appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>),
|
|
tournamentRefund: (command) =>
|
|
handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
|
|
tournamentBettingPayout: (command) =>
|
|
handleTournamentBettingPayout(
|
|
ctx,
|
|
command as Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>
|
|
),
|
|
tournamentReward: (command) =>
|
|
handleTournamentReward(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentReward' }>),
|
|
voteReward: (command) => handleVoteReward(ctx, command as Extract<TurnDaemonCommand, { type: 'voteReward' }>),
|
|
setNationMeta: (command) =>
|
|
handleSetNationMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationMeta' }>),
|
|
adjustGeneralResources: (command) =>
|
|
handleAdjustGeneralResources(
|
|
ctx,
|
|
command as Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
|
|
),
|
|
adjustGeneralMeta: (command) =>
|
|
handleAdjustGeneralMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>),
|
|
tournamentMatchResult: (command) =>
|
|
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
|
|
patchGeneral: (command) =>
|
|
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
|
|
adjustGeneralIcon: (command) =>
|
|
handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>),
|
|
joinCreateGeneral: (command) =>
|
|
handleJoinCreateGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>),
|
|
npcPossessGeneral: (command) =>
|
|
handleNpcPossessGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
|
|
selectPoolCreate: (command) =>
|
|
handleSelectPoolCreate(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>),
|
|
selectPoolReselect: (command) =>
|
|
handleSelectPoolReselect(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>),
|
|
shiftSchedule: (command) =>
|
|
handleShiftSchedule(ctx, command as Extract<TurnDaemonCommand, { type: 'shiftSchedule' }>),
|
|
updateRuntimeSettings: (command) =>
|
|
handleUpdateRuntimeSettings(ctx, command as Extract<TurnDaemonCommand, { type: 'updateRuntimeSettings' }>),
|
|
};
|
|
|
|
return {
|
|
handle: async (
|
|
command,
|
|
executionContext?: TurnDaemonCommandExecutionContext
|
|
): Promise<TurnDaemonCommandResult | null> => {
|
|
const handler = handlers[command.type];
|
|
if (!handler) {
|
|
return null;
|
|
}
|
|
ctx.commandDb = executionContext?.db;
|
|
try {
|
|
return await handler(command);
|
|
} finally {
|
|
ctx.commandDb = undefined;
|
|
}
|
|
},
|
|
};
|
|
};
|