fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다

수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다.

요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
2026-08-24 12:10:57 +00:00
parent 5389f8ed94
commit 630bc29100
74 changed files with 3893 additions and 1141 deletions
+204 -56
View File
@@ -22,20 +22,24 @@ import {
buildVoteUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
GeneralActionPipeline,
isDefenceTrainPenaltyWaivedByScenarioEffect,
isValidTroopNameWidth,
loadItemModules,
normalizeTroopName,
resolveTroopSecretPermission,
resolveMessageTargetIcon,
type GeneralActionModule,
resolveUniqueConfig,
rollUniqueLottery,
type ItemModule,
type LogEntryDraft,
type MapDefinition,
type ScenarioMeta,
type TriggerValue,
type TurnCommandProfile,
} from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { round as roundLegacyInteger, simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import {
cloneItemInventory,
ensureItemInventory,
@@ -45,7 +49,12 @@ import {
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 {
applyLegacyGeneralProgression,
createImmediateGeneralActionExecutor,
type ImmediateGeneralActionExecutor,
} from './reservedTurnHandler.js';
import { buildCommandEnv } from './reservedTurnCommands.js';
import { openAuction } from '../auction/opener.js';
import {
hasScenarioStaticEventHandler,
@@ -63,6 +72,8 @@ import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
import { respondToActionableMessage } from './actionableMessageResponse.js';
import { executeInheritanceAction } from './inheritanceActionService.js';
import { applyNpcPolicyMutation } from './npcPolicyMutation.js';
import { applyNationSettingMutation } from './nationSettingMutation.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -140,6 +151,7 @@ interface CommandHandlerContext {
tournamentRewardFinalizer?: TournamentRewardFinalizer;
getImmediateGeneralActionExecutor?: () => Promise<ImmediateGeneralActionExecutor>;
reservedTurns?: InMemoryReservedTurnStore;
generalActionModules?: ReadonlyArray<GeneralActionModule>;
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
}
@@ -150,6 +162,72 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
return ctx.commandDb as unknown as DatabaseClient;
};
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'troopCreate',
'troopJoin',
'troopExit',
'troopKick',
'troopRename',
'vacation',
'setMySetting',
'dropItem',
'auctionOpen',
'auctionBid',
'changePermission',
'kick',
'appoint',
'voteReward',
] as const satisfies readonly TurnDaemonCommand['type'][];
type ActorBoundGeneralCommandType = (typeof ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST)[number];
const ACTOR_BOUND_GENERAL_COMMAND_TYPES = new Set<ActorBoundGeneralCommandType>(ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST);
type ActorBoundGeneralCommand = Extract<TurnDaemonCommand, { type: ActorBoundGeneralCommandType }>;
const isActorBoundGeneralCommand = (command: TurnDaemonCommand): command is ActorBoundGeneralCommand =>
ACTOR_BOUND_GENERAL_COMMAND_TYPES.has(command.type as ActorBoundGeneralCommandType);
const rejectActorBoundGeneralCommand = (
command: ActorBoundGeneralCommand,
reason: string
): TurnDaemonCommandResult => ({
type: 'commandRejected',
ok: false,
commandType: command.type,
reason,
});
const validateActorBoundGeneralCommand = async (
ctx: CommandHandlerContext,
command: ActorBoundGeneralCommand
): Promise<TurnDaemonCommandResult | null> => {
if (!ctx.commandDb) {
return null;
}
if (!command.requestId) {
return rejectActorBoundGeneralCommand(command, '인증 명령의 요청 ID가 없습니다.');
}
const event = await ctx.commandDb.inputEvent.findUnique({
where: { requestId: command.requestId },
select: { actorUserId: true, target: true, eventType: true },
});
if (
!event ||
event.actorUserId !== command.userId ||
event.target !== 'ENGINE' ||
event.eventType !== command.type
) {
return rejectActorBoundGeneralCommand(command, '인증 명령의 입력 이벤트가 일치하지 않습니다.');
}
const general = ctx.world.getGeneralById(command.generalId);
if (!general || general.userId !== command.userId) {
return rejectActorBoundGeneralCommand(command, '명령 수행 장수의 현재 소유자가 일치하지 않습니다.');
}
return null;
};
const resolveCommandAcceptedAt = async (
db: DatabaseClient,
command: Extract<
@@ -164,7 +242,9 @@ const resolveCommandAcceptedAt = async (
| 'selectPoolCreate'
| 'selectPoolReselect'
| 'adjustGeneralIcon'
| 'inheritanceAction';
| 'inheritanceAction'
| 'setNationSetting'
| 'setNpcPolicy';
}
>
): Promise<Date> => {
@@ -504,49 +584,22 @@ interface TournamentRewardFinalizer {
): Promise<TurnDaemonCommandResult>;
}
async function handleSetNationMeta(
async function handleSetNationSetting(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'setNationMeta' }>
command: Extract<TurnDaemonCommand, { type: 'setNationSetting' }>
): Promise<TurnDaemonCommandResult> {
const { world } = ctx;
const nation = world.getNationById(command.nationId);
if (!nation) {
return {
type: 'setNationMeta',
ok: false,
nationId: command.nationId,
reason: '국가 정보를 찾을 수 없습니다.',
};
}
const db = requireCommandDatabase(ctx);
const acceptedAt = await resolveCommandAcceptedAt(db, command);
return applyNationSettingMutation({ world: ctx.world, command, acceptedAt });
}
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 handleSetNpcPolicy(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx);
const acceptedAt = await resolveCommandAcceptedAt(db, command);
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
}
async function handleAdjustGeneralResources(
@@ -1832,10 +1885,15 @@ async function handleDropItem(
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]) {
const slot = command.itemType;
const itemKey = general.role.items[slot];
if (!itemKey) {
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' };
}
const item = (await getItemRegistry()).get(itemKey);
if (!item) {
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템 정보를 찾을 수 없습니다.' };
}
const nextGeneral = {
...general,
role: { ...general.role, items: { ...general.role.items } },
@@ -1847,6 +1905,34 @@ async function handleDropItem(
role: nextGeneral.role,
itemInventory: nextGeneral.itemInventory,
});
const josaUl = JosaUtil.pick(item.rawName, '을');
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
text: `<C>${item.name}</>${josaUl} 버렸습니다.`,
generalId: general.id,
meta: {},
});
if (!item.buyable) {
const nationName = world.getNationById(general.nationId)?.name ?? '재야';
const josaYi = JosaUtil.pick(general.name, '이');
world.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
text: `<Y>${general.name}</>${josaYi} <C>${item.name}</>${josaUl} 잃었습니다!`,
meta: {},
});
world.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
text: `<R><b>【망실】</b></><D><b>${nationName}</b></>의 <Y>${general.name}</>${josaYi} <C>${item.name}</>${josaUl} 잃었습니다!`,
meta: {},
});
}
return { type: 'dropItem', ok: true, generalId: command.generalId };
}
@@ -1975,6 +2061,9 @@ async function handleKick(
command: Extract<TurnDaemonCommand, { type: 'kick' }>
): Promise<TurnDaemonCommandResult> {
const { world } = ctx;
const operationalAcceptedAt = ctx.commandDb
? await resolveOperationalAcceptedAt(ctx.commandDb, command)
: new Date();
const general = world.getGeneralById(command.generalId);
if (!general) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
@@ -2043,14 +2132,38 @@ async function handleKick(
const worldState = world.getState();
const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta);
const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear);
let nextExperience = target.experience;
let nextDedication = target.dedication;
let applyProgression = false;
if (worldState.currentYear > startYear || target.npcState >= 2) {
const betray = Math.max(0, readMetaNumber(targetMeta, 'betray', 0));
const maxBetrayCnt = readMetaNumber(config, 'maxBetrayCnt', 9);
const detachedTarget: TurnGeneral = {
...target,
nationId: 0,
officerLevel: 0,
troopId: 0,
meta: nextMeta,
};
const pipeline = new GeneralActionPipeline(ctx.generalActionModules ?? []);
const actionContext = {
general: detachedTarget,
nation: null,
worldView: world,
time: {
year: worldState.currentYear,
month: worldState.currentMonth,
startYear,
},
};
nextExperience = roundLegacyInteger(
target.experience + pipeline.onCalcStat(actionContext, 'experience', -target.experience * 0.15 * betray)
);
nextDedication = roundLegacyInteger(
target.dedication + pipeline.onCalcStat(actionContext, 'dedication', -target.dedication * 0.15 * betray)
);
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)),
});
applyProgression = true;
} else {
nextMeta.makelimit = targetMeta.makelimit ?? 12;
}
@@ -2070,14 +2183,28 @@ async function handleKick(
world.removeTroop(target.id);
}
world.updateGeneral(command.destGeneralId, {
const detachedTarget: TurnGeneral = {
...target,
nationId: 0,
officerLevel: 0,
troopId: 0,
gold: Math.min(target.gold, defaultGold),
rice: Math.min(target.rice, defaultRice),
experience: nextExperience,
dedication: nextDedication,
meta: nextMeta,
});
};
const progressionLogs: LogEntryDraft[] = [];
const nextTarget = applyProgression
? applyLegacyGeneralProgression(
detachedTarget,
target,
'kick',
buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
progressionLogs
)
: detachedTarget;
world.updateGeneral(command.destGeneralId, nextTarget);
const nationMeta =
worldState.currentYear >= startYear + 3
? setOfficerLock(nation.meta, 'chief_set', general.officerLevel)
@@ -2098,6 +2225,17 @@ async function handleKick(
text: `<Y>${target.name}</>${josaYi} <D><b>${nation.name}</b></>에서 <R>추방</>당했습니다.`,
meta: {},
});
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
text: `<D><b>${nation.name}</b></>에서 <R>추방</>당했습니다.`,
generalId: target.id,
meta: {},
});
for (const log of progressionLogs) {
world.pushLog(log);
}
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
@@ -2125,7 +2263,7 @@ async function handleKick(
const text = rng.choice([
'날 버리다니... 곧 전장에서 복수해주겠다...',
'추방이라... 내가 무얼 잘못했단 말인가...',
'어디 추방해가면서 잘되나 보자... 꼭 복수하겠다.',
'어디 추방해가면서 잘되나 보자... 꼭 복수하겠다...',
'인덕이 제일이거늘... 추방이 웬말인가... 저주한다!',
'날 추방했으니 그 복수로 적국에 정보를 팔아 넘겨야겠군요. 그럼 이만.',
]);
@@ -2135,14 +2273,14 @@ async function handleKick(
nationId: nation.id,
nationName: nation.name,
color: nation.color,
icon: target.picture === null ? '' : String(target.picture),
icon: resolveMessageTargetIcon(target),
};
world.queueMessage({
msgType: 'public',
src: messageTarget,
dest: messageTarget,
text,
time: world.getGameNow(new Date()),
time: world.getGameNow(operationalAcceptedAt),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
option: {},
});
@@ -2873,6 +3011,7 @@ export const createTurnDaemonCommandHandler = (options: {
scenarioMeta?: ScenarioMeta;
map?: MapDefinition;
commandProfile?: TurnCommandProfile;
generalActionModules?: ReadonlyArray<GeneralActionModule>;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
auctionFinalizer?: AuctionFinalizer;
auctionBidder?: AuctionBidder;
@@ -2886,6 +3025,7 @@ export const createTurnDaemonCommandHandler = (options: {
auctionBidder: options.auctionBidder,
tournamentRewardFinalizer: options.tournamentRewardFinalizer,
reservedTurns: options.reservedTurns,
generalActionModules: options.generalActionModules,
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
getImmediateGeneralActionExecutor: () => {
immediateGeneralActionExecutor ??= createImmediateGeneralActionExecutor({
@@ -2961,8 +3101,10 @@ export const createTurnDaemonCommandHandler = (options: {
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' }>),
setNationSetting: (command) =>
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
setNpcPolicy: (command) =>
handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>),
adjustGeneralResources: (command) =>
handleAdjustGeneralResources(
ctx,
@@ -3005,6 +3147,12 @@ export const createTurnDaemonCommandHandler = (options: {
}
ctx.commandDb = executionContext?.db;
try {
if (isActorBoundGeneralCommand(command)) {
const rejected = await validateActorBoundGeneralCommand(ctx, command);
if (rejected) {
return rejected;
}
}
return await handler(command);
} finally {
ctx.commandDb = undefined;