fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다
수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다. 요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, isRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { LogCategory, LogFormat, LogScope, type MessageDraft, type MessagePayload } from '@sammo-ts/logic';
|
||||
|
||||
@@ -15,6 +15,7 @@ interface MessageRow {
|
||||
id: number;
|
||||
mailbox: number;
|
||||
type: string;
|
||||
time: Date;
|
||||
validUntil: Date;
|
||||
message: unknown;
|
||||
}
|
||||
@@ -25,8 +26,31 @@ export interface ActionableMessageResponseResult {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const parsePayload = (value: unknown): MessagePayload =>
|
||||
(typeof value === 'string' ? JSON.parse(value) : value) as MessagePayload;
|
||||
const parsePayload = (value: unknown): MessagePayload | null => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const payload = asRecord(parsed);
|
||||
const src = asRecord(payload.src);
|
||||
const dest = asRecord(payload.dest);
|
||||
const isTarget = (target: Record<string, unknown>): boolean =>
|
||||
Number.isSafeInteger(target.generalId) &&
|
||||
Number.isSafeInteger(target.nationId) &&
|
||||
typeof target.generalName === 'string' &&
|
||||
typeof target.nationName === 'string' &&
|
||||
typeof target.color === 'string' &&
|
||||
typeof target.icon === 'string';
|
||||
if (!isTarget(src) || !isTarget(dest) || typeof payload.text !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (payload.option !== undefined && payload.option !== null && !isRecord(payload.option)) {
|
||||
return null;
|
||||
}
|
||||
return payload as unknown as MessagePayload;
|
||||
};
|
||||
|
||||
const systemTarget: MessageDraft['src'] = {
|
||||
generalId: 0,
|
||||
@@ -37,6 +61,16 @@ const systemTarget: MessageDraft['src'] = {
|
||||
icon: '',
|
||||
};
|
||||
|
||||
const isLegacyTruthy = (value: unknown): boolean => {
|
||||
if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const queuePrivateNotice = (
|
||||
world: InMemoryTurnWorld,
|
||||
destination: MessagePayload['dest'],
|
||||
@@ -103,7 +137,7 @@ const fetchMessageForUpdate = async (
|
||||
): Promise<MessageRow | null> => {
|
||||
const currentTick = BigInt(world.dateToGameTick(now));
|
||||
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
|
||||
SELECT id, mailbox, type, valid_until AS "validUntil", message
|
||||
SELECT id, mailbox, type, time, valid_until AS "validUntil", message
|
||||
FROM message
|
||||
WHERE id = ${messageId}
|
||||
AND (
|
||||
@@ -130,7 +164,7 @@ const respondToScout = async (options: {
|
||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
|
||||
}
|
||||
if (asRecord(payload.option).used === true) {
|
||||
if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) {
|
||||
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
||||
}
|
||||
|
||||
@@ -280,6 +314,7 @@ export const respondToActionableMessage = async (options: {
|
||||
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
|
||||
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
|
||||
const payload = parsePayload(row.message);
|
||||
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
|
||||
const action = asRecord(payload.option).action;
|
||||
if (action === 'scout') {
|
||||
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
|
||||
|
||||
@@ -25,6 +25,8 @@ const parseWith = <T>(schema: z.ZodType<T>, value: unknown): T | null => {
|
||||
const zFiniteNumber = z.number().finite();
|
||||
const zSafeInteger = zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER);
|
||||
const zRecord = z.record(z.string(), z.unknown());
|
||||
const zBoundedCodePointText = (maximumCodePoints: number) =>
|
||||
z.string().refine((value) => Array.from(value).length <= maximumCodePoints);
|
||||
|
||||
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
|
||||
const zTurnRunBudget = z.object({
|
||||
@@ -42,6 +44,7 @@ const zAuctionFinalize = z.object({
|
||||
|
||||
const zAuctionOpen = z.object({
|
||||
type: z.literal('auctionOpen'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
auctionType: z.enum(['BUY_RICE', 'SELL_RICE', 'UNIQUE_ITEM']),
|
||||
amount: zFiniteNumber,
|
||||
@@ -53,6 +56,7 @@ const zAuctionOpen = z.object({
|
||||
|
||||
const zAuctionBid = z.object({
|
||||
type: z.literal('auctionBid'),
|
||||
userId: z.string().min(1),
|
||||
auctionId: zFiniteNumber,
|
||||
generalId: zFiniteNumber,
|
||||
amount: zFiniteNumber,
|
||||
@@ -62,23 +66,27 @@ const zAuctionBid = z.object({
|
||||
|
||||
const zTroopJoin = z.object({
|
||||
type: z.literal('troopJoin'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
troopId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zTroopCreate = z.object({
|
||||
type: z.literal('troopCreate'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
troopName: z.string(),
|
||||
});
|
||||
|
||||
const zTroopExit = z.object({
|
||||
type: z.literal('troopExit'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zTroopKick = z.object({
|
||||
type: z.literal('troopKick'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
troopId: zFiniteNumber,
|
||||
targetGeneralId: zFiniteNumber,
|
||||
@@ -86,6 +94,7 @@ const zTroopKick = z.object({
|
||||
|
||||
const zTroopRename = z.object({
|
||||
type: z.literal('troopRename'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
troopId: zFiniteNumber,
|
||||
troopName: z.string(),
|
||||
@@ -125,11 +134,13 @@ const zMessageRespond = z.object({
|
||||
|
||||
const zVacation = z.object({
|
||||
type: z.literal('vacation'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zSetMySetting = z.object({
|
||||
type: z.literal('setMySetting'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
settings: z.object({
|
||||
tnmt: z.number().int().optional(),
|
||||
@@ -146,25 +157,30 @@ const zSetMySetting = z.object({
|
||||
|
||||
const zDropItem = z.object({
|
||||
type: z.literal('dropItem'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
itemType: z.string().min(1),
|
||||
itemType: z.enum(['horse', 'weapon', 'book', 'item']),
|
||||
});
|
||||
|
||||
const zChangePermission = z.object({
|
||||
type: z.literal('changePermission'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
isAmbassador: z.boolean(),
|
||||
targetGeneralIds: z.array(zFiniteNumber).min(1),
|
||||
// Ref uses an empty selection to clear every holder of the role.
|
||||
targetGeneralIds: z.array(zFiniteNumber),
|
||||
});
|
||||
|
||||
const zKick = z.object({
|
||||
type: z.literal('kick'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
destGeneralId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zAppoint = z.object({
|
||||
type: z.literal('appoint'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
destGeneralId: zFiniteNumber,
|
||||
destCityId: zFiniteNumber,
|
||||
@@ -198,17 +214,42 @@ const zTournamentReward = z.object({
|
||||
|
||||
const zVoteReward = z.object({
|
||||
type: z.literal('voteReward'),
|
||||
userId: z.string().min(1),
|
||||
voteId: zFiniteNumber,
|
||||
generalId: zFiniteNumber,
|
||||
selection: z.array(zFiniteNumber.int()).min(1),
|
||||
acceptedGameTick: zSafeInteger.optional(),
|
||||
});
|
||||
|
||||
const zSetNationMeta = z.object({
|
||||
type: z.literal('setNationMeta'),
|
||||
const zSetNationSetting = z.object({
|
||||
type: z.literal('setNationSetting'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
nationId: zFiniteNumber,
|
||||
updates: zRecord,
|
||||
expectedUpdatedAt: z.string().optional(),
|
||||
mutation: z.discriminatedUnion('kind', [
|
||||
// Ref validates required content before HTML purification. A raw,
|
||||
// non-empty value can therefore become an empty persisted string.
|
||||
z.object({ kind: z.literal('notice'), message: zBoundedCodePointText(16_384) }),
|
||||
z.object({ kind: z.literal('scoutMessage'), message: zBoundedCodePointText(1_000) }),
|
||||
z.object({ kind: z.literal('rate'), amount: z.number().int().min(5).max(30) }),
|
||||
z.object({ kind: z.literal('bill'), amount: z.number().int().min(20).max(200) }),
|
||||
z.object({ kind: z.literal('secretLimit'), amount: z.number().int().min(1).max(99) }),
|
||||
z.object({ kind: z.literal('blockWar'), value: z.boolean() }),
|
||||
z.object({ kind: z.literal('blockScout'), value: z.boolean() }),
|
||||
]),
|
||||
});
|
||||
|
||||
const zSetNpcPolicy = z.object({
|
||||
type: z.literal('setNpcPolicy'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
nationId: zFiniteNumber,
|
||||
expectedUpdatedAt: z.string().nullable(),
|
||||
mutation: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('nationPolicy'), values: zRecord }),
|
||||
z.object({ kind: z.literal('nationPriority'), priority: z.array(z.string()) }),
|
||||
z.object({ kind: z.literal('generalPriority'), priority: z.array(z.string()) }),
|
||||
]),
|
||||
});
|
||||
|
||||
const zAdjustGeneralResources = z.object({
|
||||
@@ -640,8 +681,16 @@ const normalizeVoteReward: CommandNormalizer<'voteReward'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeSetNationMeta: CommandNormalizer<'setNationMeta'> = (envelope) => {
|
||||
const command = parseWith(zSetNationMeta, envelope.command);
|
||||
const normalizeSetNationSetting: CommandNormalizer<'setNationSetting'> = (envelope) => {
|
||||
const command = parseWith(zSetNationSetting, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
|
||||
const command = parseWith(zSetNpcPolicy, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
@@ -804,7 +853,8 @@ const normalizers: CommandNormalizerMap = {
|
||||
tournamentBettingPayout: normalizeTournamentBettingPayout,
|
||||
tournamentReward: normalizeTournamentReward,
|
||||
voteReward: normalizeVoteReward,
|
||||
setNationMeta: normalizeSetNationMeta,
|
||||
setNationSetting: normalizeSetNationSetting,
|
||||
setNpcPolicy: normalizeSetNpcPolicy,
|
||||
adjustGeneralResources: normalizeAdjustGeneralResources,
|
||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { asRecord, formatServerDateTime, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import { resolveTroopSecretPermission } from '@sammo-ts/logic';
|
||||
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
|
||||
type SetNationSettingCommand = Extract<TurnDaemonCommand, { type: 'setNationSetting' }>;
|
||||
type SetNationSettingResult = Extract<TurnDaemonCommandResult, { type: 'setNationSetting' }>;
|
||||
|
||||
const MAX_AVAILABLE_WAR_SETTING_COUNT = 10;
|
||||
|
||||
const reject = (
|
||||
code: Extract<SetNationSettingResult, { ok: false }>['code'],
|
||||
reason: string,
|
||||
nationId?: number
|
||||
): SetNationSettingResult => ({
|
||||
type: 'setNationSetting',
|
||||
ok: false,
|
||||
code,
|
||||
reason,
|
||||
...(nationId === undefined ? {} : { nationId }),
|
||||
});
|
||||
|
||||
const readInteger = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readWarSettingRemain = (meta: Record<string, unknown>): number => {
|
||||
const legacy = readInteger(meta.available_war_setting_cnt);
|
||||
const migrated = readInteger(meta.availableWarSettingCnt);
|
||||
// Ref treats an absent counter as zero. The monthly refill creates it;
|
||||
// a missing legacy migration value must not grant ten extra changes.
|
||||
const value = legacy ?? migrated ?? 0;
|
||||
return Math.max(0, Math.min(MAX_AVAILABLE_WAR_SETTING_COUNT, value));
|
||||
};
|
||||
|
||||
const isLegacyTruthy = (value: unknown): boolean => {
|
||||
if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const buildRevision = (acceptedAt: Date, requestId: string): string => {
|
||||
const suffix = createHash('sha256').update(requestId).digest('hex').slice(0, 16);
|
||||
return `${acceptedAt.toISOString()}#${suffix}`;
|
||||
};
|
||||
|
||||
export const applyNationSettingMutation = (options: {
|
||||
world: InMemoryTurnWorld;
|
||||
command: SetNationSettingCommand;
|
||||
acceptedAt: Date;
|
||||
}): SetNationSettingResult => {
|
||||
const { world, command, acceptedAt } = options;
|
||||
const actor = world.getGeneralById(command.generalId);
|
||||
if (!actor) {
|
||||
return reject('NOT_FOUND', '장수 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
if (actor.userId !== command.userId) {
|
||||
return reject('FORBIDDEN', '인증된 사용자와 장수의 소유자가 일치하지 않습니다.');
|
||||
}
|
||||
if (actor.nationId <= 0 || actor.nationId !== command.nationId) {
|
||||
return reject('PRECONDITION_FAILED', '국가에 소속되어있지 않거나 소속 국가가 변경되었습니다.');
|
||||
}
|
||||
|
||||
const nation = world.getNationById(command.nationId);
|
||||
if (!nation) {
|
||||
return reject('NOT_FOUND', '국가 정보를 찾을 수 없습니다.', command.nationId);
|
||||
}
|
||||
const permission = resolveTroopSecretPermission(actor, nation.meta, false);
|
||||
if (permission < 0 || (actor.officerLevel < 5 && permission !== 4)) {
|
||||
return reject('FORBIDDEN', '권한이 부족합니다.', command.nationId);
|
||||
}
|
||||
|
||||
const currentMeta = asRecord(nation.meta);
|
||||
let updates: Record<string, unknown>;
|
||||
let availableCnt: number | undefined;
|
||||
switch (command.mutation.kind) {
|
||||
case 'notice': {
|
||||
const message = command.mutation.message;
|
||||
if (Array.from(message).length > 16_384) {
|
||||
return reject('BAD_REQUEST', '올바른 국가 방침을 입력해주세요.', command.nationId);
|
||||
}
|
||||
updates = {
|
||||
notice: message,
|
||||
nationNotice: {
|
||||
date: formatServerDateTime(world.getGameNow(acceptedAt)),
|
||||
msg: message,
|
||||
author: actor.name,
|
||||
authorID: actor.id,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'scoutMessage': {
|
||||
const message = command.mutation.message;
|
||||
if (Array.from(message).length > 1_000) {
|
||||
return reject('BAD_REQUEST', '올바른 임관 권유문을 입력해주세요.', command.nationId);
|
||||
}
|
||||
updates = { infoText: message };
|
||||
break;
|
||||
}
|
||||
case 'rate':
|
||||
if (!Number.isInteger(command.mutation.amount) || command.mutation.amount < 5 || command.mutation.amount > 30) {
|
||||
return reject('BAD_REQUEST', '올바른 세율을 입력해주세요.', command.nationId);
|
||||
}
|
||||
updates = { rate: command.mutation.amount };
|
||||
break;
|
||||
case 'bill':
|
||||
if (
|
||||
!Number.isInteger(command.mutation.amount) ||
|
||||
command.mutation.amount < 20 ||
|
||||
command.mutation.amount > 200
|
||||
) {
|
||||
return reject('BAD_REQUEST', '올바른 지급률을 입력해주세요.', command.nationId);
|
||||
}
|
||||
updates = { bill: command.mutation.amount };
|
||||
break;
|
||||
case 'secretLimit':
|
||||
if (!Number.isInteger(command.mutation.amount) || command.mutation.amount < 1 || command.mutation.amount > 99) {
|
||||
return reject('BAD_REQUEST', '올바른 기밀 공개 기준을 입력해주세요.', command.nationId);
|
||||
}
|
||||
updates = { secretlimit: command.mutation.amount };
|
||||
break;
|
||||
case 'blockWar': {
|
||||
const remain = readWarSettingRemain(currentMeta);
|
||||
if (remain <= 0) {
|
||||
return reject('BAD_REQUEST', '잔여 횟수가 부족합니다.', command.nationId);
|
||||
}
|
||||
availableCnt = remain - 1;
|
||||
updates = {
|
||||
war: command.mutation.value ? 1 : 0,
|
||||
available_war_setting_cnt: availableCnt,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'blockScout':
|
||||
if (isLegacyTruthy(asRecord(world.getState().meta).block_change_scout)) {
|
||||
return reject('FORBIDDEN', '임관 설정을 바꿀 수 없도록 설정되어 있습니다.', command.nationId);
|
||||
}
|
||||
updates = { scout: command.mutation.value ? 1 : 0 };
|
||||
break;
|
||||
}
|
||||
|
||||
const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`);
|
||||
world.updateNation(command.nationId, {
|
||||
meta: {
|
||||
...nation.meta,
|
||||
...updates,
|
||||
_updatedAt: updatedAt,
|
||||
},
|
||||
});
|
||||
return {
|
||||
type: 'setNationSetting',
|
||||
ok: true,
|
||||
nationId: command.nationId,
|
||||
updatedAt,
|
||||
...(availableCnt === undefined ? {} : { availableCnt }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
asRecord,
|
||||
formatServerDateTime,
|
||||
isRecord,
|
||||
type TurnDaemonCommand,
|
||||
type TurnDaemonCommandResult,
|
||||
} from '@sammo-ts/common';
|
||||
import { resolveTroopSecretPermission } from '@sammo-ts/logic';
|
||||
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
|
||||
export type NationPolicy = {
|
||||
reqNationGold: number;
|
||||
reqNationRice: number;
|
||||
CombatForce: Record<number, [number, number]>;
|
||||
SupportForce: number[];
|
||||
DevelopForce: number[];
|
||||
reqHumanWarUrgentGold: number;
|
||||
reqHumanWarUrgentRice: number;
|
||||
reqHumanWarRecommandGold: number;
|
||||
reqHumanWarRecommandRice: number;
|
||||
reqHumanDevelGold: number;
|
||||
reqHumanDevelRice: number;
|
||||
reqNPCWarGold: number;
|
||||
reqNPCWarRice: number;
|
||||
reqNPCDevelGold: number;
|
||||
reqNPCDevelRice: number;
|
||||
minimumResourceActionAmount: number;
|
||||
maximumResourceActionAmount: number;
|
||||
minNPCWarLeadership: number;
|
||||
minWarCrew: number;
|
||||
minNPCRecruitCityPopulation: number;
|
||||
safeRecruitCityPopulationRatio: number;
|
||||
properWarTrainAtmos: number;
|
||||
cureThreshold: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_NATION_PRIORITY = [
|
||||
'불가침제의',
|
||||
'선전포고',
|
||||
'천도',
|
||||
'유저장긴급포상',
|
||||
'부대전방발령',
|
||||
'유저장구출발령',
|
||||
'유저장후방발령',
|
||||
'부대유저장후방발령',
|
||||
'유저장전방발령',
|
||||
'유저장포상',
|
||||
'부대구출발령',
|
||||
'부대후방발령',
|
||||
'NPC긴급포상',
|
||||
'NPC구출발령',
|
||||
'NPC후방발령',
|
||||
'NPC포상',
|
||||
'NPC전방발령',
|
||||
'유저장내정발령',
|
||||
'NPC내정발령',
|
||||
'NPC몰수',
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_GENERAL_PRIORITY = [
|
||||
'NPC사망대비',
|
||||
'귀환',
|
||||
'금쌀구매',
|
||||
'출병',
|
||||
'긴급내정',
|
||||
'전투준비',
|
||||
'전방워프',
|
||||
'NPC헌납',
|
||||
'징병',
|
||||
'후방워프',
|
||||
'전쟁내정',
|
||||
'소집해제',
|
||||
'일반내정',
|
||||
'내정워프',
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_NATION_POLICY: NationPolicy = {
|
||||
reqNationGold: 10000,
|
||||
reqNationRice: 12000,
|
||||
CombatForce: {},
|
||||
SupportForce: [],
|
||||
DevelopForce: [],
|
||||
reqHumanWarUrgentGold: 0,
|
||||
reqHumanWarUrgentRice: 0,
|
||||
reqHumanWarRecommandGold: 0,
|
||||
reqHumanWarRecommandRice: 0,
|
||||
reqHumanDevelGold: 10000,
|
||||
reqHumanDevelRice: 10000,
|
||||
reqNPCWarGold: 0,
|
||||
reqNPCWarRice: 0,
|
||||
reqNPCDevelGold: 0,
|
||||
reqNPCDevelRice: 500,
|
||||
minimumResourceActionAmount: 1000,
|
||||
maximumResourceActionAmount: 10000,
|
||||
minNPCWarLeadership: 40,
|
||||
minWarCrew: 1500,
|
||||
minNPCRecruitCityPopulation: 50000,
|
||||
safeRecruitCityPopulationRatio: 0.5,
|
||||
properWarTrainAtmos: 90,
|
||||
cureThreshold: 10,
|
||||
};
|
||||
|
||||
const NATION_POLICY_KEYS = new Set<keyof NationPolicy>(Object.keys(DEFAULT_NATION_POLICY) as Array<keyof NationPolicy>);
|
||||
|
||||
const INTEGER_POLICY_KEYS = [
|
||||
'reqNationGold',
|
||||
'reqNationRice',
|
||||
'reqHumanWarUrgentGold',
|
||||
'reqHumanWarUrgentRice',
|
||||
'reqHumanWarRecommandGold',
|
||||
'reqHumanWarRecommandRice',
|
||||
'reqHumanDevelGold',
|
||||
'reqHumanDevelRice',
|
||||
'reqNPCWarGold',
|
||||
'reqNPCWarRice',
|
||||
'reqNPCDevelGold',
|
||||
'reqNPCDevelRice',
|
||||
'minimumResourceActionAmount',
|
||||
'maximumResourceActionAmount',
|
||||
'minNPCWarLeadership',
|
||||
'minWarCrew',
|
||||
'minNPCRecruitCityPopulation',
|
||||
'properWarTrainAtmos',
|
||||
'cureThreshold',
|
||||
] as const satisfies ReadonlyArray<keyof NationPolicy>;
|
||||
|
||||
const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const satisfies ReadonlyArray<keyof NationPolicy>;
|
||||
const INTEGER_POLICY_KEY_SET = new Set<string>(INTEGER_POLICY_KEYS);
|
||||
const FLOAT_POLICY_KEY_SET = new Set<string>(FLOAT_POLICY_KEYS);
|
||||
|
||||
type SetNpcPolicyCommand = Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>;
|
||||
type SetNpcPolicyResult = Extract<TurnDaemonCommandResult, { type: 'setNpcPolicy' }>;
|
||||
|
||||
const reject = (
|
||||
code: Extract<SetNpcPolicyResult, { ok: false }>['code'],
|
||||
reason: string,
|
||||
extra: Pick<Extract<SetNpcPolicyResult, { ok: false }>, 'nationId' | 'currentUpdatedAt'> = {}
|
||||
): SetNpcPolicyResult => ({ type: 'setNpcPolicy', ok: false, code, reason, ...extra });
|
||||
|
||||
const buildRevision = (acceptedAt: Date, requestId: string): string => {
|
||||
const suffix = createHash('sha256').update(requestId).digest('hex').slice(0, 16);
|
||||
return `${acceptedAt.toISOString()}#${suffix}`;
|
||||
};
|
||||
|
||||
const validateGeneralPriority = (priority: readonly string[]): string | null => {
|
||||
const mustHave = new Set(['출병', '일반내정']);
|
||||
const orderMap = new Map<string, number>();
|
||||
|
||||
for (const item of priority) {
|
||||
if (!DEFAULT_GENERAL_PRIORITY.includes(item as (typeof DEFAULT_GENERAL_PRIORITY)[number])) {
|
||||
return `${item}은 올바른 명령이 아닙니다.`;
|
||||
}
|
||||
mustHave.delete(item);
|
||||
// Ref uses the count of distinct keys at each assignment. Updating an
|
||||
// existing key therefore advances it after the currently known keys.
|
||||
orderMap.set(item, orderMap.size);
|
||||
}
|
||||
|
||||
const sortieIndex = orderMap.get('출병');
|
||||
const domesticIndex = orderMap.get('일반내정');
|
||||
if (sortieIndex !== undefined && domesticIndex !== undefined && sortieIndex > domesticIndex) {
|
||||
return '출병 명령은 일반내정 명령보다 먼저여야 합니다.';
|
||||
}
|
||||
if (mustHave.size > 0) {
|
||||
return `${mustHave.values().next().value}은 항상 사용해야 합니다.`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyNationPolicyValues = (
|
||||
world: InMemoryTurnWorld,
|
||||
nationId: number,
|
||||
currentValues: Record<string, unknown>,
|
||||
values: Record<string, unknown>
|
||||
): { values?: Record<string, unknown>; error?: string } => {
|
||||
if (Object.keys(values).length === 0) {
|
||||
return { error: '올바른 입력이 아닙니다.' };
|
||||
}
|
||||
|
||||
for (const key of Object.keys(values)) {
|
||||
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
|
||||
return { error: `${key}는 올바른 정책값이 아닙니다.` };
|
||||
}
|
||||
}
|
||||
|
||||
// Ref persists only the supplied delta on top of the existing nation
|
||||
// overrides. Materialising defaults here would freeze later server-policy
|
||||
// changes and is not equivalent to j_set_npc_control.php.
|
||||
const nextValues = { ...currentValues };
|
||||
for (const key of INTEGER_POLICY_KEYS) {
|
||||
if (!Object.hasOwn(values, key)) {
|
||||
continue;
|
||||
}
|
||||
const value = values[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
|
||||
return { error: `${key}는 올바른 값이 아닙니다.` };
|
||||
}
|
||||
nextValues[key] = Math.max(0, value);
|
||||
}
|
||||
for (const key of FLOAT_POLICY_KEYS) {
|
||||
if (!Object.hasOwn(values, key)) {
|
||||
continue;
|
||||
}
|
||||
const value = values[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return { error: `${key}는 올바른 값이 아닙니다.` };
|
||||
}
|
||||
// Ref clamps negative integers but deliberately leaves floating-point
|
||||
// ratios unchanged.
|
||||
nextValues[key] = value;
|
||||
}
|
||||
|
||||
const troopIds = new Set(
|
||||
world
|
||||
.listTroops()
|
||||
.filter((troop) => troop.nationId === nationId)
|
||||
.map((troop) => troop.id)
|
||||
);
|
||||
const assigned = new Set<number>();
|
||||
|
||||
if (Object.hasOwn(values, 'CombatForce')) {
|
||||
const rawCombat = values.CombatForce;
|
||||
if (!isRecord(rawCombat)) {
|
||||
return { error: 'CombatForce는 올바른 정책값이 아닙니다.' };
|
||||
}
|
||||
for (const [rawLeaderId, rawTarget] of Object.entries(rawCombat)) {
|
||||
const leaderId = Number(rawLeaderId);
|
||||
if (!Number.isSafeInteger(leaderId) || !troopIds.has(leaderId)) {
|
||||
return { error: `${rawLeaderId}는 국가의 부대가 아닙니다.` };
|
||||
}
|
||||
if (assigned.has(leaderId)) {
|
||||
return { error: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.` };
|
||||
}
|
||||
if (!Array.isArray(rawTarget) || rawTarget.length !== 2) {
|
||||
return { error: `${leaderId}의 입력양식이 올바르지 않습니다.` };
|
||||
}
|
||||
// Ref j_set_npc_control.php accidentally destructures the complete
|
||||
// troop-role cache instead of this rawTarget. Positive troop IDs
|
||||
// therefore leave indexes 0/1 undefined and every non-empty
|
||||
// CombatForce is rejected with this observable empty-city error.
|
||||
// Preserve that legacy bug until a separately approved contract
|
||||
// change fixes Ref and Core together.
|
||||
return { error: `${leaderId}의 도시 , 가 올바른 도시 번호가 아닙니다.` };
|
||||
}
|
||||
nextValues.CombatForce = {};
|
||||
}
|
||||
|
||||
for (const key of ['SupportForce', 'DevelopForce'] as const) {
|
||||
if (!Object.hasOwn(values, key)) {
|
||||
continue;
|
||||
}
|
||||
const rawList = values[key];
|
||||
if (!Array.isArray(rawList)) {
|
||||
return { error: `${key}는 올바른 정책값이 아닙니다.` };
|
||||
}
|
||||
const list: number[] = [];
|
||||
for (const rawLeaderId of rawList) {
|
||||
const leaderId = Number(rawLeaderId);
|
||||
if (!Number.isSafeInteger(leaderId) || !troopIds.has(leaderId)) {
|
||||
return { error: `${String(rawLeaderId)}는 국가의 부대가 아닙니다.` };
|
||||
}
|
||||
if (assigned.has(leaderId)) {
|
||||
return { error: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.` };
|
||||
}
|
||||
assigned.add(leaderId);
|
||||
list.push(leaderId);
|
||||
}
|
||||
nextValues[key] = list;
|
||||
}
|
||||
|
||||
// Numeric keys have already been handled. The three role keys are handled
|
||||
// above, leaving no accepted policy key unprocessed.
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (
|
||||
!INTEGER_POLICY_KEY_SET.has(key) &&
|
||||
!FLOAT_POLICY_KEY_SET.has(key) &&
|
||||
!['CombatForce', 'SupportForce', 'DevelopForce'].includes(key)
|
||||
) {
|
||||
nextValues[key] = value;
|
||||
}
|
||||
}
|
||||
return { values: nextValues };
|
||||
};
|
||||
|
||||
export const applyNpcPolicyMutation = (options: {
|
||||
world: InMemoryTurnWorld;
|
||||
command: SetNpcPolicyCommand;
|
||||
acceptedAt: Date;
|
||||
}): SetNpcPolicyResult => {
|
||||
const { world, command, acceptedAt } = options;
|
||||
const actor = world.getGeneralById(command.generalId);
|
||||
if (!actor) {
|
||||
return reject('NOT_FOUND', '장수 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
if (actor.userId !== command.userId) {
|
||||
return reject('FORBIDDEN', '인증된 사용자와 장수의 소유자가 일치하지 않습니다.');
|
||||
}
|
||||
if (actor.nationId <= 0 || actor.nationId !== command.nationId) {
|
||||
return reject('PRECONDITION_FAILED', '국가에 소속되어있지 않거나 소속 국가가 변경되었습니다.');
|
||||
}
|
||||
|
||||
const nation = world.getNationById(command.nationId);
|
||||
if (!nation) {
|
||||
return reject('NOT_FOUND', '국가 정보를 찾을 수 없습니다.', { nationId: command.nationId });
|
||||
}
|
||||
if (resolveTroopSecretPermission(actor, nation.meta, true) < 3) {
|
||||
return reject('FORBIDDEN', '권한이 부족합니다. 군주, 외교권자, 조언자가 아닙니다.', {
|
||||
nationId: command.nationId,
|
||||
});
|
||||
}
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const currentUpdatedAt =
|
||||
typeof nationMeta._npcPolicyUpdatedAt === 'string'
|
||||
? nationMeta._npcPolicyUpdatedAt
|
||||
: typeof nationMeta._updatedAt === 'string'
|
||||
? nationMeta._updatedAt
|
||||
: null;
|
||||
if (command.expectedUpdatedAt !== currentUpdatedAt) {
|
||||
return reject('CONFLICT', '다른 사용자가 정책을 변경했습니다. 재시도하거나 현재 상태로 갱신해주세요.', {
|
||||
nationId: command.nationId,
|
||||
currentUpdatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const gameNow = formatServerDateTime(world.getGameNow(acceptedAt));
|
||||
let updates: Record<string, unknown>;
|
||||
if (command.mutation.kind === 'nationPolicy') {
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const applied = applyNationPolicyValues(
|
||||
world,
|
||||
command.nationId,
|
||||
asRecord(policyRoot.values),
|
||||
command.mutation.values
|
||||
);
|
||||
if (!applied.values) {
|
||||
return reject('BAD_REQUEST', applied.error ?? '올바른 입력이 아닙니다.', { nationId: command.nationId });
|
||||
}
|
||||
updates = {
|
||||
npc_nation_policy: {
|
||||
...policyRoot,
|
||||
values: applied.values,
|
||||
valueSetter: actor.name,
|
||||
valueSetTime: gameNow,
|
||||
},
|
||||
};
|
||||
} else if (command.mutation.kind === 'nationPriority') {
|
||||
if (command.mutation.priority.length === 0) {
|
||||
return reject('BAD_REQUEST', '올바른 입력이 아닙니다.', { nationId: command.nationId });
|
||||
}
|
||||
for (const item of command.mutation.priority) {
|
||||
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
|
||||
return reject('BAD_REQUEST', `${item}은 올바른 명령이 아닙니다.`, { nationId: command.nationId });
|
||||
}
|
||||
}
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
updates = {
|
||||
npc_nation_policy: {
|
||||
...policyRoot,
|
||||
priority: [...command.mutation.priority],
|
||||
prioritySetter: actor.name,
|
||||
prioritySetTime: gameNow,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const validationError = validateGeneralPriority(command.mutation.priority);
|
||||
if (validationError) {
|
||||
return reject('BAD_REQUEST', validationError, { nationId: command.nationId });
|
||||
}
|
||||
const policyRoot = asRecord(nationMeta.npc_general_policy);
|
||||
updates = {
|
||||
npc_general_policy: {
|
||||
...policyRoot,
|
||||
priority: [...command.mutation.priority],
|
||||
prioritySetter: actor.name,
|
||||
prioritySetTime: gameNow,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// input_event timestamps have millisecond precision, so two independent
|
||||
// accepted commands can share the same time. Include the durable request
|
||||
// identity to keep the strict CAS token unique.
|
||||
const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`);
|
||||
world.updateNation(command.nationId, {
|
||||
meta: {
|
||||
...nation.meta,
|
||||
...updates,
|
||||
// Keep the policy CAS independent from notice/tax/scout settings.
|
||||
// The legacy shared _updatedAt remains a one-time migration fallback.
|
||||
_npcPolicyUpdatedAt: updatedAt,
|
||||
},
|
||||
});
|
||||
return { type: 'setNpcPolicy', ok: true, nationId: command.nationId, updatedAt };
|
||||
};
|
||||
@@ -9,6 +9,11 @@ const readNumber = (value: unknown): number => {
|
||||
const readTextArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
||||
|
||||
const readArchiveText = (values: readonly unknown[], fallback: string | null): string | null => {
|
||||
const value = values.find((candidate) => candidate !== undefined && candidate !== null);
|
||||
return value === undefined ? fallback : String(value);
|
||||
};
|
||||
|
||||
export const buildOldNationArchiveData = (options: {
|
||||
nation: Nation;
|
||||
generalIds: readonly number[];
|
||||
@@ -22,6 +27,7 @@ export const buildOldNationArchiveData = (options: {
|
||||
...maxPower,
|
||||
};
|
||||
const maxCities = readTextArray(maxPower.maxCities);
|
||||
const nationNotice = asRecord(meta.nationNotice);
|
||||
|
||||
return {
|
||||
...nation,
|
||||
@@ -34,5 +40,7 @@ export const buildOldNationArchiveData = (options: {
|
||||
aux,
|
||||
generals: [...options.generalIds],
|
||||
history: [...options.history],
|
||||
msg: readArchiveText([meta.notice, nationNotice.msg, meta.msg], ''),
|
||||
scout_msg: readArchiveText([meta.infoText, meta.scout_msg], null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2541,7 +2541,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
const failureText =
|
||||
definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ??
|
||||
`${reason} ${definition.name} 실패.`;
|
||||
if (input.actionKey === 'che_접경귀환' || input.actionKey === 'che_등용수락') {
|
||||
if (input.actionKey === 'che_접경귀환') {
|
||||
options.world.pushLog(createGeneralActionLog(general.id, failureText), general.turnTime);
|
||||
}
|
||||
return { ok: false, reason: failureText };
|
||||
@@ -2671,10 +2671,16 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
nextGeneral = applyLegacyGeneralProgression(
|
||||
{
|
||||
...nextGeneral,
|
||||
lastTurn: {
|
||||
command: definition.name,
|
||||
arg: extractArgsRecord(args),
|
||||
},
|
||||
// Ref's recruitment-letter acceptance is an immediate
|
||||
// side action and never replaces the receiver's
|
||||
// reserved-command repetition state.
|
||||
lastTurn:
|
||||
input.actionKey === 'che_등용수락'
|
||||
? general.lastTurn
|
||||
: {
|
||||
command: definition.name,
|
||||
arg: extractArgsRecord(args),
|
||||
},
|
||||
},
|
||||
general,
|
||||
input.actionKey,
|
||||
|
||||
@@ -962,6 +962,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
scenarioMeta: snapshot.scenarioMeta,
|
||||
map: snapshot.map,
|
||||
commandProfile,
|
||||
generalActionModules: monthlyActionModules.general,
|
||||
getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys,
|
||||
auctionFinalizer: auctionFinalizer ?? undefined,
|
||||
auctionBidder: auctionBidder ?? undefined,
|
||||
|
||||
@@ -496,15 +496,12 @@ export const persistUnificationFinalization = async (
|
||||
const cityCount = cities.filter((city) => city.nationId === input.winnerNationId).length;
|
||||
const totalPop = cities.reduce((sum, city) => sum + city.population, 0);
|
||||
const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0);
|
||||
const winnerMeta = asRecord(winner.meta);
|
||||
const winnerData = {
|
||||
...buildOldNationArchiveData({
|
||||
nation: winner,
|
||||
generalIds: winnerGenerals.map((general) => general.id),
|
||||
history: nationHistory,
|
||||
}),
|
||||
msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''),
|
||||
scout_msg: String(winnerMeta.scout_msg ?? ''),
|
||||
generationKey: input.generationKey,
|
||||
};
|
||||
await transaction.oldNation.upsert({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user