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
+4
View File
@@ -58,6 +58,10 @@
"types": "./dist/turn/monthlyNationBettingAction.d.ts",
"default": "./dist/turn/monthlyNationBettingAction.js"
},
"./turn/npcPolicyMutation.js": {
"types": "./dist/turn/npcPolicyMutation.d.ts",
"default": "./dist/turn/npcPolicyMutation.js"
},
"./turn/npcPossessionService.js": {
"types": "./dist/turn/npcPossessionService.d.ts",
"default": "./dist/turn/npcPossessionService.js"
@@ -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 });
+59 -9
View File
@@ -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,
+1
View File
@@ -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({
+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;
@@ -92,6 +92,7 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
id: 29,
mailbox: actor.id,
type: 'private',
time: new Date('0200-01-01T00:00:00.000Z'),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
message: {
src: source,
@@ -172,6 +173,76 @@ describe('actionable message response', () => {
expect(world.peekDirtyState().messages).toHaveLength(0);
});
it('treats legacy truthy used values and an inverted validity interval as invalid scout letters', async () => {
for (const row of [
buildRow('scout', { option: { action: 'scout', used: 1 } }),
{
...buildRow('scout'),
validUntil: new Date('0199-12-31T23:59:59.000Z'),
},
]) {
const world = buildWorld();
const { db, updateMany } = buildDb([[row]]);
const executor = buildExecutor();
await expect(
respondToActionableMessage({
db,
world,
executor,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
response: true,
})
).resolves.toEqual({ ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' });
expect(executor.execute).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
}
});
it("keeps PHP's special string-zero used value false", async () => {
const world = buildWorld();
const row = buildRow('scout', { option: { action: 'scout', used: '0' } });
const { db, updateMany } = buildDb([[row], []]);
const executor = buildExecutor();
await expect(
respondToActionableMessage({
db,
world,
executor,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
response: true,
})
).resolves.toEqual({ ok: true, action: 'scout', reason: 'success' });
expect(executor.execute).toHaveBeenCalledOnce();
expect(updateMany).toHaveBeenCalledOnce();
});
it('rejects malformed actionable payloads without throwing inside the daemon transaction', async () => {
const world = buildWorld();
const row = { ...buildRow('scout'), message: { option: { action: 'scout' }, dest: null } };
const { db, updateMany } = buildDb([[row]]);
const executor = buildExecutor();
await expect(
respondToActionableMessage({
db,
world,
executor,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
response: true,
})
).resolves.toEqual({ ok: false, reason: '응답할 수 없는 메시지입니다.' });
expect(executor.execute).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
});
it('does not invalidate an invader prompt before validating its receiver', async () => {
const world = buildWorld();
const row = { ...buildRow('raiseInvader'), mailbox: 99 };
@@ -110,6 +110,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
const result = await auctionBidder.bid(
{
type: 'auctionBid',
userId: 'user-7',
auctionId: 31,
generalId: general.id,
amount,
@@ -155,6 +156,7 @@ describe('resource auction Ref compatibility', () => {
sentAt: '2026-08-23T00:00:00.000Z',
command: {
type: 'auctionBid',
userId: 'user-7',
auctionId: 31,
generalId: 7,
amount: 500,
@@ -120,6 +120,7 @@ describe('unique auction inheritance log compatibility', () => {
const result = await openAuction(
{
type: 'auctionOpen',
userId: 'user-7',
auctionType: 'UNIQUE_ITEM',
generalId: general.id,
amount: 6_000,
@@ -0,0 +1,183 @@
import { describe, expect, it, vi } from 'vitest';
import type { TurnDaemonCommand } from '@sammo-ts/common';
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
{ type: 'troopKick', requestId: 'troopKick', userId, generalId: 7, troopId: 7, targetGeneralId: 8 },
{ type: 'troopRename', requestId: 'troopRename', userId, generalId: 7, troopId: 7, troopName: '신대' },
{ type: 'vacation', requestId: 'vacation', userId, generalId: 7 },
{ type: 'setMySetting', requestId: 'setMySetting', userId, generalId: 7, settings: { tnmt: 1 } },
{ type: 'dropItem', requestId: 'dropItem', userId, generalId: 7, itemType: 'weapon' },
{
type: 'auctionOpen',
requestId: 'auctionOpen',
userId,
generalId: 7,
auctionType: 'BUY_RICE',
amount: 1_000,
closeTurnCnt: 3,
startBidAmount: 100,
finishBidAmount: 500,
},
{ type: 'auctionBid', requestId: 'auctionBid', userId, generalId: 7, auctionId: 1, amount: 200 },
{
type: 'changePermission',
requestId: 'changePermission',
userId,
generalId: 7,
isAmbassador: true,
targetGeneralIds: [],
},
{ type: 'kick', requestId: 'kick', userId, generalId: 7, destGeneralId: 8 },
{
type: 'appoint',
requestId: 'appoint',
userId,
generalId: 7,
destGeneralId: 8,
destCityId: 1,
officerLevel: 4,
},
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
];
const buildReadOnlyWorld = (ownerUserId: string) => {
const mutation = vi.fn();
const world = {
getGeneralById: vi.fn(() => ({ id: 7, userId: ownerUserId })),
updateGeneral: mutation,
updateNation: mutation,
createTroop: mutation,
updateTroop: mutation,
removeTroop: mutation,
pushLog: mutation,
queueMessage: mutation,
} as unknown as InMemoryTurnWorld;
return { world, mutation };
};
describe('authenticated actor-bound command registry and execution', () => {
it.each(['horse', 'weapon', 'book', 'item'] as const)(
'accepts the Ref equipment slot %s for dropItem',
(itemType) => {
expect(
normalizeTurnDaemonCommand({
requestId: `drop-item:${itemType}`,
sentAt: '2026-08-24T00:00:00.000Z',
command: { type: 'dropItem', userId: 'user-7', generalId: 7, itemType },
})
).toEqual({
type: 'dropItem',
requestId: `drop-item:${itemType}`,
userId: 'user-7',
generalId: 7,
itemType,
});
}
);
it.each(['armor', '', 0, null])('rejects the invalid dropItem slot %j at the daemon boundary', (itemType) => {
expect(
normalizeTurnDaemonCommand({
requestId: 'drop-item:invalid-slot',
sentAt: '2026-08-24T00:00:00.000Z',
command: {
type: 'dropItem',
userId: 'user-7',
generalId: 7,
itemType,
} as unknown as TurnDaemonCommand,
})
).toBeNull();
});
it('rejects every actor-bound queue payload that omits userId', () => {
for (const command of buildActorBoundCommands()) {
const {
userId: _userId,
requestId: _requestId,
...payload
} = command as unknown as Record<string, unknown>;
expect(
normalizeTurnDaemonCommand({
requestId: `missing-user:${command.type}`,
sentAt: '2026-08-24T00:00:00.000Z',
command: payload as TurnDaemonCommand,
}),
command.type
).toBeNull();
}
});
it('rejects all stale-owner commands before any world mutation', async () => {
const commands = buildActorBoundCommands();
const { world, mutation } = buildReadOnlyWorld('new-owner');
const eventTypes = new Map(commands.map((command) => [command.requestId, command.type]));
const db = {
inputEvent: {
findUnique: vi.fn(async ({ where }: { where: { requestId: string } }) => ({
actorUserId: 'old-owner',
target: 'ENGINE',
eventType: eventTypes.get(where.requestId),
})),
},
};
const handler = createTurnDaemonCommandHandler({ world });
for (const command of commands) {
await expect(handler.handle(command, { db: db as never }), command.type).resolves.toMatchObject({
type: 'commandRejected',
ok: false,
commandType: command.type,
});
}
expect(mutation).not.toHaveBeenCalled();
});
it.each([
['missing event', null],
['actor mismatch', { actorUserId: 'other-owner', target: 'ENGINE', eventType: 'vacation' }],
['target mismatch', { actorUserId: 'old-owner', target: 'API', eventType: 'vacation' }],
['event type mismatch', { actorUserId: 'old-owner', target: 'ENGINE', eventType: 'dropItem' }],
])('returns commandRejected for %s without looking up or mutating the general', async (_label, event) => {
const { world, mutation } = buildReadOnlyWorld('old-owner');
const getGeneralById = world.getGeneralById as ReturnType<typeof vi.fn>;
const handler = createTurnDaemonCommandHandler({ world });
const result = await handler.handle(
{ type: 'vacation', requestId: 'vacation', userId: 'old-owner', generalId: 7 },
{
db: {
inputEvent: { findUnique: vi.fn(async () => event) },
} as never,
}
);
expect(result).toMatchObject({ type: 'commandRejected', ok: false, commandType: 'vacation' });
expect(getGeneralById).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
it('preserves direct in-memory invocation when no command database is supplied', async () => {
const updateGeneral = vi.fn();
const world = {
getGeneralById: vi.fn(() => ({ id: 7, userId: 'current-owner', meta: { killturn: 12 } })),
getState: vi.fn(() => ({ meta: { killturn: 24, autorun_user: {} } })),
updateGeneral,
} as unknown as InMemoryTurnWorld;
const handler = createTurnDaemonCommandHandler({ world });
await expect(handler.handle({ type: 'vacation', userId: 'different-owner', generalId: 7 })).resolves.toEqual({
type: 'vacation',
ok: true,
generalId: 7,
});
expect(updateGeneral).toHaveBeenCalledWith(7, { meta: { killturn: 72 } });
});
});
@@ -1,8 +1,12 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { TurnDaemonCommand } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
@@ -35,7 +39,8 @@ integration('database command queue', () => {
requestId,
target: 'ENGINE',
eventType: 'vacation',
payload: { type: 'vacation', requestId, generalId: 7 } as GamePrisma.InputJsonValue,
actorUserId: 'user-7',
payload: { type: 'vacation', requestId, userId: 'user-7', generalId: 7 } as GamePrisma.InputJsonValue,
},
});
@@ -44,7 +49,7 @@ integration('database command queue', () => {
const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]);
const commands = firstCommands.concat(secondCommands);
expect(commands).toEqual([{ type: 'vacation', requestId, generalId: 7 }]);
expect(commands).toEqual([{ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }]);
await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 });
const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
@@ -64,7 +69,13 @@ integration('database command queue', () => {
requestId: expiredId,
target: 'ENGINE',
eventType: 'vacation',
payload: { type: 'vacation', requestId: expiredId, generalId: 8 } as GamePrisma.InputJsonValue,
actorUserId: 'user-8',
payload: {
type: 'vacation',
requestId: expiredId,
userId: 'user-8',
generalId: 8,
} as GamePrisma.InputJsonValue,
status: 'PROCESSING',
processingAt: new Date(Date.now() - 120_000),
lockedBy: 'dead-worker',
@@ -74,7 +85,13 @@ integration('database command queue', () => {
requestId: activeId,
target: 'ENGINE',
eventType: 'vacation',
payload: { type: 'vacation', requestId: activeId, generalId: 9 } as GamePrisma.InputJsonValue,
actorUserId: 'user-9',
payload: {
type: 'vacation',
requestId: activeId,
userId: 'user-9',
generalId: 9,
} as GamePrisma.InputJsonValue,
status: 'PROCESSING',
processingAt: new Date(),
lockedBy: 'active-worker',
@@ -87,7 +104,7 @@ integration('database command queue', () => {
await queue.initialize();
const commands = await queue.drain();
expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, generalId: 8 }]);
expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, userId: 'user-8', generalId: 8 }]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({
status: 'PROCESSING',
lockedBy: 'active-worker',
@@ -101,9 +118,11 @@ integration('database command queue', () => {
requestId,
target: 'ENGINE',
eventType: 'vacation',
actorUserId: 'user-10',
payload: {
type: 'vacation',
requestId,
userId: 'user-10',
generalId: 10,
} as GamePrisma.InputJsonValue,
},
@@ -113,20 +132,16 @@ integration('database command queue', () => {
const stale = new DatabaseTurnDaemonCommandQueue(db);
for (const attempt of [1, 2, 3]) {
await expect(owner.drain()).resolves.toEqual([
{ type: 'vacation', requestId, generalId: 10 },
{ type: 'vacation', requestId, userId: 'user-10', generalId: 10 },
]);
await stale.publishCommandError(requestId, new Error('stale worker failure'));
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
).resolves.toMatchObject({
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'PROCESSING',
attempts: attempt,
});
await owner.publishCommandError(requestId, new Error('injected command failure'));
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
).resolves.toMatchObject({
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: attempt < 3 ? 'PENDING' : 'FAILED',
attempts: attempt,
error: 'injected command failure',
@@ -137,4 +152,81 @@ integration('database command queue', () => {
await expect(owner.drain()).resolves.toEqual([]);
});
it('fails an actor-bound payload that omits userId instead of dispatching it', async () => {
const requestId = 'integration:engine:missing-user-id';
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'vacation',
payload: { type: 'vacation', requestId, generalId: 7 } as GamePrisma.InputJsonValue,
},
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
await expect(queue.drain()).resolves.toEqual([]);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'FAILED',
error: 'Invalid command payload for vacation',
});
});
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => {
const requestId = 'integration:engine:stale-owner-replay';
const command: TurnDaemonCommand = {
type: 'vacation',
requestId,
userId: 'old-owner',
generalId: 7,
};
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
actorUserId: command.userId,
payload: command as GamePrisma.InputJsonValue,
},
});
const mutation = vi.fn();
const world = {
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
updateGeneral: mutation,
updateNation: mutation,
createTroop: mutation,
updateTroop: mutation,
removeTroop: mutation,
pushLog: mutation,
queueMessage: mutation,
} as unknown as InMemoryTurnWorld;
const handler = createTurnDaemonCommandHandler({ world });
const handle = vi.spyOn(handler, 'handle');
const owner = new DatabaseTurnDaemonCommandQueue(db);
const claimed = await owner.drain();
expect(claimed).toEqual([command]);
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
expect(result).toMatchObject({
type: 'commandRejected',
ok: false,
commandType: command.type,
});
await owner.publishCommandResult(requestId, result!);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
actorUserId: command.userId,
eventType: command.type,
payload: command,
result,
lockedBy: null,
leaseUntil: null,
});
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
expect(handle).toHaveBeenCalledOnce();
expect(mutation).not.toHaveBeenCalled();
});
});
@@ -176,6 +176,7 @@ describe('input event atomicity', () => {
queue.enqueue({
type: 'auctionBid',
requestId: 'event-1',
userId: 'user-7',
auctionId: 3,
generalId: 7,
amount: 1000,
@@ -239,7 +240,7 @@ describe('input event atomicity', () => {
}
);
queue.enqueue({ type: 'vacation', requestId: 'event-uow', generalId: 7 });
queue.enqueue({ type: 'vacation', requestId: 'event-uow', userId: 'user-7', generalId: 7 });
const loop = lifecycle.start();
await responded;
@@ -304,7 +305,7 @@ describe('input event atomicity', () => {
}
);
queue.enqueue({ type: 'vacation', requestId: 'event-2', generalId: 7 });
queue.enqueue({ type: 'vacation', requestId: 'event-2', userId: 'user-7', generalId: 7 });
const loop = lifecycle.start();
await errorObserved;
@@ -374,7 +375,7 @@ describe('input event atomicity', () => {
}
);
queue.enqueue({ type: 'vacation', requestId: 'event-3', generalId: 7 });
queue.enqueue({ type: 'vacation', requestId: 'event-3', userId: 'user-7', generalId: 7 });
const loop = lifecycle.start();
await errorObserved;
@@ -55,7 +55,7 @@ const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
turnTime: new Date('0185-01-01T00:00:00Z'),
recentWarTime: null,
role: {
items: { horse: 'che_명마', weapon: null, book: null, item: null },
items: { horse: 'che_명마_02_조랑', weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
@@ -103,7 +103,21 @@ const buildWorld = (
const snapshot: TurnWorldSnapshot = {
generals: [general],
cities: [],
nations: [],
nations: [
{
id: 1,
name: '테스트국',
color: '#111111',
typeCode: 'che_중립',
level: 1,
capitalCityId: null,
chiefGeneralId: 7,
gold: 0,
rice: 0,
power: 0,
meta: {},
},
],
troops: [],
diplomacy: [],
events: [],
@@ -216,6 +230,7 @@ describe('my information world commands', () => {
await expect(
fixture.handler.handle({
type: 'setMySetting',
userId: 'user-7',
generalId: 7,
settings: {
tnmt: 9,
@@ -250,6 +265,7 @@ describe('my information world commands', () => {
await fixture.handler.handle({
type: 'setMySetting',
userId: 'user-7',
generalId: 7,
settings: { tnmt: 0, defence_train: 999, use_treatment: 1 },
});
@@ -270,6 +286,7 @@ describe('my information world commands', () => {
const fixture = buildWorld(buildGeneral(), { scenarioEffect });
await fixture.handler.handle({
type: 'setMySetting',
userId: 'user-7',
generalId: 7,
settings: { defence_train: 999 },
});
@@ -279,26 +296,110 @@ describe('my information world commands', () => {
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
const allowed = buildWorld();
await expect(allowed.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ ok: true });
await expect(
allowed.handler.handle({ type: 'vacation', userId: 'user-7', generalId: 7 })
).resolves.toMatchObject({ ok: true });
expect(allowed.world.getGeneralById(7)?.meta.killturn).toBe(72);
const blocked = buildWorld(buildGeneral(), { autorunLimit: true });
await expect(blocked.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({
await expect(
blocked.handler.handle({ type: 'vacation', userId: 'user-7', generalId: 7 })
).resolves.toMatchObject({
ok: false,
reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.',
});
expect(blocked.world.getGeneralById(7)?.meta.killturn).toBe(12);
});
it('drops only the authenticated command target slot and rejects an empty slot', async () => {
it('drops a buyable item with only the Ref personal action log', async () => {
const fixture = buildWorld();
await expect(
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'weapon' })
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' })
).resolves.toMatchObject({ ok: false });
await expect(
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'horse' })
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'horse' })
).resolves.toMatchObject({ ok: true });
expect(fixture.world.getGeneralById(7)?.role.items.horse).toBeNull();
expect(fixture.world.peekDirtyState().logs).toEqual([
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
text: '<C>조랑(+2)</>을 버렸습니다.',
generalId: 7,
meta: {},
},
]);
});
it('adds Ref global loss logs when dropping a non-buyable item', async () => {
const fixture = buildWorld(
buildGeneral({
role: {
items: { horse: null, weapon: null, book: 'che_서적_14_한비자', item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
})
);
await expect(
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'book' })
).resolves.toMatchObject({ ok: true });
expect(fixture.world.getGeneralById(7)?.role.items.book).toBeNull();
expect(fixture.world.peekDirtyState().logs).toEqual([
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
text: '<C>한비자(+14)</>를 버렸습니다.',
generalId: 7,
meta: {},
},
{
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
text: '<Y>테스트장수</>가 <C>한비자(+14)</>를 잃었습니다!',
meta: {},
},
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
text: '<R><b>【망실】</b></><D><b>테스트국</b></>의 <Y>테스트장수</>가 <C>한비자(+14)</>를 잃었습니다!',
meta: {},
},
]);
});
it('drops and logs an item stored under a mismatched equipment slot like Ref', async () => {
const fixture = buildWorld(
buildGeneral({
role: {
items: { horse: null, weapon: 'che_명마_02_조랑', book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
})
);
await expect(
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' })
).resolves.toMatchObject({ type: 'dropItem', ok: true, generalId: 7 });
expect(fixture.world.getGeneralById(7)?.role.items.weapon).toBeNull();
expect(fixture.world.peekDirtyState().logs).toEqual([
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
text: '<C>조랑(+2)</>을 버렸습니다.',
generalId: 7,
meta: {},
},
]);
});
it('executes pre-open uprising through the action stack without advancing the turn clock', async () => {
@@ -499,6 +600,7 @@ describe('my information world commands', () => {
});
it('loads the internal recruitment acceptance action outside the selectable command profile', async () => {
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
const recipient = buildGeneral({
id: 8,
userId: 'user-8',
@@ -506,6 +608,7 @@ describe('my information world commands', () => {
nationId: 0,
cityId: 1,
officerLevel: 0,
lastTurn: originalLastTurn,
});
const recruiter = buildGeneral({
id: 9,
@@ -566,6 +669,7 @@ describe('my information world commands', () => {
nationId: 2,
cityId: 2,
officerLevel: 1,
lastTurn: originalLastTurn,
});
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
experience: recruiter.experience + 100,
@@ -592,6 +696,75 @@ describe('my information world commands', () => {
]);
});
it('accepts a recruitment letter after the recruiter was deleted and preserves the reserved command', async () => {
const deletedRecruiterId = 99;
const originalLastTurn = { command: '내정 특기 초기화', arg: { phase: 2 } };
const recipient = buildGeneral({
id: 8,
userId: 'user-8',
name: '재야장수',
nationId: 0,
cityId: 1,
officerLevel: 0,
lastTurn: originalLastTurn,
});
const map = {
id: 'test',
name: 'test',
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
};
const fixture = buildImmediateActionWorld({
general: recipient,
cities: [
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
] as TurnWorldSnapshot['cities'],
nations: [
{
id: 2,
name: '등용국',
color: '#222222',
typeCode: 'che_중립',
level: 1,
capitalCityId: 2,
chiefGeneralId: deletedRecruiterId,
gold: 0,
rice: 0,
power: 0,
meta: { gennum: 1 },
},
] as TurnWorldSnapshot['nations'],
map,
});
const executor = await createImmediateGeneralActionExecutor({
world: fixture.world,
reservedTurns: fixture.reservedTurns,
scenarioMeta: fixture.scenarioMeta,
map,
commandProfile: { general: ['che_등용'], nation: [] },
});
await expect(
executor.execute({
actionKey: 'che_등용수락',
generalId: recipient.id,
rng: new RandUtil(new LiteHashDRBG('accept-deleted-recruiter-letter')),
args: { destNationId: 2, destGeneralId: deletedRecruiterId },
})
).resolves.toEqual({ ok: true });
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
nationId: 2,
cityId: 2,
officerLevel: 1,
lastTurn: originalLastTurn,
});
expect(fixture.world.getNationById(2)?.meta.gennum).toBe(2);
expect(fixture.world.peekDirtyState().logs).not.toEqual(
expect.arrayContaining([expect.objectContaining({ generalId: deletedRecruiterId })])
);
});
it('preserves the Ref uprising precheck order and messages after the game starts', async () => {
const general = buildGeneral({ nationId: 1, cityId: 1 });
const fixture = buildImmediateActionWorld({
@@ -1,10 +1,17 @@
import { describe, expect, it } from 'vitest';
import type { TriggerValue, TurnSchedule } from '@sammo-ts/logic';
import {
loadActionModuleBundle,
LogFormat,
type GeneralActionModule,
type TriggerValue,
type TurnSchedule,
} from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
@@ -25,7 +32,7 @@ const buildGeneral = (id: number, overrides: Partial<TurnGeneral> = {}): TurnGen
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 12, belong: 5, permission: 'normal' },
meta: { killturn: 12, belong: 5, permission: 'normal', explevel: 10, dedlevel: 5 },
penalty: {},
officerLevel: 1,
experience: 1_000,
@@ -48,6 +55,11 @@ const buildWorld = (options: {
cityMeta?: Record<string, TriggerValue>;
currentYear?: number;
scenarioConst?: Record<string, unknown>;
generalActionModules?: ReadonlyArray<GeneralActionModule>;
clock?: Pick<
TurnWorldState,
'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'
>;
}) => {
const state: TurnWorldState = {
id: 1,
@@ -56,6 +68,7 @@ const buildWorld = (options: {
tickSeconds: 600,
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
meta: { killturn: 24, scenarioMeta: { startYear: 180 } },
...options.clock,
};
const snapshot: TurnWorldSnapshot = {
generals: options.generals ?? [
@@ -129,14 +142,24 @@ const buildWorld = (options: {
},
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
return { world, handler: createTurnDaemonCommandHandler({ world }) };
return {
world,
handler: createTurnDaemonCommandHandler({ world, generalActionModules: options.generalActionModules }),
};
};
describe('nation personnel world commands', () => {
it('allows any unlocked head officer to appoint and preserves legacy officer state', async () => {
const { world, handler } = buildWorld({});
await expect(
handler.handle({ type: 'appoint', generalId: 2, destGeneralId: 3, destCityId: 0, officerLevel: 9 })
handler.handle({
type: 'appoint',
userId: 'user-2',
generalId: 2,
destGeneralId: 3,
destCityId: 0,
officerLevel: 9,
})
).resolves.toMatchObject({ ok: true });
expect(world.getGeneralById(3)).toMatchObject({
officerLevel: 9,
@@ -154,6 +177,7 @@ describe('nation personnel world commands', () => {
await expect(
fixture.handler.handle({
type: 'appoint',
userId: 'user-3',
generalId: 3,
destGeneralId: 2,
destCityId: 0,
@@ -163,6 +187,7 @@ describe('nation personnel world commands', () => {
await expect(
fixture.handler.handle({
type: 'appoint',
userId: 'user-2',
generalId: 2,
destGeneralId: 3,
destCityId: 0,
@@ -175,6 +200,7 @@ describe('nation personnel world commands', () => {
await expect(
locked.handler.handle({
type: 'appoint',
userId: 'user-2',
generalId: 2,
destGeneralId: 3,
destCityId: 0,
@@ -196,6 +222,7 @@ describe('nation personnel world commands', () => {
await expect(
fixture.handler.handle({
type: 'appoint',
userId: 'user-2',
generalId: 2,
destGeneralId: 3,
destCityId: 1,
@@ -215,6 +242,7 @@ describe('nation personnel world commands', () => {
await expect(
locked.handler.handle({
type: 'appoint',
userId: 'user-2',
generalId: 2,
destGeneralId: 3,
destCityId: 1,
@@ -237,6 +265,7 @@ describe('nation personnel world commands', () => {
await expect(
fixture.handler.handle({
type: 'changePermission',
userId: 'user-2',
generalId: 2,
isAmbassador: true,
targetGeneralIds: [3],
@@ -245,6 +274,7 @@ describe('nation personnel world commands', () => {
await expect(
fixture.handler.handle({
type: 'changePermission',
userId: 'user-1',
generalId: 1,
isAmbassador: true,
targetGeneralIds: [3, 5, 6],
@@ -254,6 +284,7 @@ describe('nation personnel world commands', () => {
await expect(
fixture.handler.handle({
type: 'changePermission',
userId: 'user-1',
generalId: 1,
isAmbassador: true,
targetGeneralIds: [2, 3, 4],
@@ -263,6 +294,7 @@ describe('nation personnel world commands', () => {
await expect(
fixture.handler.handle({
type: 'changePermission',
userId: 'user-1',
generalId: 1,
isAmbassador: true,
targetGeneralIds: [2, 3],
@@ -271,6 +303,22 @@ describe('nation personnel world commands', () => {
expect(fixture.world.getGeneralById(2)?.meta.permission).toBe('ambassador');
expect(fixture.world.getGeneralById(3)?.meta.permission).toBe('ambassador');
expect(fixture.world.getGeneralById(4)?.meta.permission).toBe('normal');
const clearCommand = normalizeTurnDaemonCommand({
requestId: 'clear-ambassadors',
sentAt: '2026-01-01T00:00:00.000Z',
command: {
type: 'changePermission',
userId: 'user-1',
generalId: 1,
isAmbassador: true,
targetGeneralIds: [],
},
});
expect(clearCommand).toMatchObject({ type: 'changePermission', targetGeneralIds: [] });
await expect(fixture.handler.handle(clearCommand!)).resolves.toMatchObject({ ok: true });
expect(fixture.world.getGeneralById(2)?.meta.permission).toBe('normal');
expect(fixture.world.getGeneralById(3)?.meta.permission).toBe('normal');
});
it('kicks for an unlocked head officer with resource, troop, permission, and log side effects', async () => {
@@ -280,7 +328,14 @@ describe('nation personnel world commands', () => {
rice: 3_000,
experience: 1_000,
dedication: 2_000,
meta: { killturn: 12, permission: 'normal', belong: 8, betray: 1 },
meta: {
killturn: 12,
permission: 'normal',
belong: 8,
betray: 1,
explevel: 10,
dedlevel: 5,
},
});
const member = buildGeneral(4, { troopId: 3 });
const fixture = buildWorld({
@@ -289,7 +344,9 @@ describe('nation personnel world commands', () => {
});
fixture.world.createTroop({ id: 3, nationId: 1, name: '추방대' });
await expect(fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 })).resolves.toMatchObject({
await expect(
fixture.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 })
).resolves.toMatchObject({
ok: true,
});
expect(fixture.world.getGeneralById(3)).toMatchObject({
@@ -309,7 +366,59 @@ describe('nation personnel world commands', () => {
rice: 22_000,
meta: expect.objectContaining({ gennum: 3 }),
});
expect(fixture.world.peekDirtyState().logs).toHaveLength(2);
expect(fixture.world.peekDirtyState().logs).toEqual([
expect.objectContaining({ scope: 'SYSTEM', category: 'SUMMARY' }),
expect.objectContaining({
scope: 'GENERAL',
category: 'ACTION',
format: LogFormat.PLAIN,
generalId: 3,
text: '<D><b>위</b></>에서 <R>추방</>당했습니다.',
}),
expect.objectContaining({
scope: 'GENERAL',
category: 'ACTION',
text: expect.stringContaining('레벨다운'),
}),
expect.objectContaining({ scope: 'GENERAL', category: 'HISTORY' }),
]);
});
it('applies Ref-ordered personality and item modifiers before legacy INT rounding on kick', async () => {
const modules = (await loadActionModuleBundle()).general;
const target = buildGeneral(3, {
experience: 1_001,
dedication: 2_001,
role: {
items: { horse: null, weapon: null, book: null, item: 'che_명성_구석' },
personality: 'che_대의',
specialDomestic: null,
specialWar: null,
},
meta: {
killturn: 12,
permission: 'normal',
belong: 8,
betray: 1,
explevel: 10,
dedlevel: 5,
},
});
const fixture = buildWorld({
generals: [buildGeneral(1, { officerLevel: 12 }), buildGeneral(2, { officerLevel: 5 }), target],
generalActionModules: modules,
});
await expect(
fixture.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 })
).resolves.toMatchObject({
ok: true,
});
expect(fixture.world.getGeneralById(3)).toMatchObject({
experience: 803,
dedication: 1_701,
meta: expect.objectContaining({ explevel: 8, dedlevel: 5, betray: 2 }),
});
});
it('rejects self, ruler, head officer, and ambassador targets without partial mutation', async () => {
@@ -336,7 +445,12 @@ describe('nation personnel world commands', () => {
const originalTarget = fixture.world.getGeneralById(testCase.targetId);
await expect(
fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: testCase.targetId })
fixture.handler.handle({
type: 'kick',
userId: 'user-2',
generalId: 2,
destGeneralId: testCase.targetId,
})
).resolves.toMatchObject({ ok: false, reason: testCase.reason });
expect(fixture.world.getGeneralById(testCase.targetId), testCase.label).toEqual(originalTarget);
expect(fixture.world.getGeneralById(2)?.meta.killturn, testCase.label).toBe(12);
@@ -354,7 +468,7 @@ describe('nation personnel world commands', () => {
buildGeneral(3, { meta: { killturn: 12, belong: 8, permission: 'normal', betray: 1 } }),
],
});
await early.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 });
await early.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 });
expect(early.world.getGeneralById(3)).toMatchObject({
experience: 850,
dedication: 1_700,
@@ -372,12 +486,54 @@ describe('nation personnel world commands', () => {
buildGeneral(3, { npcState: 2 }),
],
});
await npc.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 });
await npc.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 });
expect(npc.world.peekDirtyState().messages).toHaveLength(1);
expect(npc.world.peekDirtyState().messages[0]).toMatchObject({
msgType: 'public',
src: { generalId: 3, nationId: 1 },
dest: { generalId: 3, nationId: 1 },
src: { generalId: 3, nationId: 1, icon: 'https://sam-image.hided.net/icons/default.jpg' },
dest: { generalId: 3, nationId: 1, icon: 'https://sam-image.hided.net/icons/default.jpg' },
});
});
it('timestamps a queued NPC kick message from the durable accepted instant', async () => {
const acceptedAt = new Date('2026-01-01T00:10:00.000Z');
const fixture = buildWorld({
currentYear: 185,
scenarioConst: { npcBanMessageProb: 1 },
clock: {
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
lastTurnTick: 0,
},
generals: [
buildGeneral(1, { officerLevel: 12 }),
buildGeneral(2, { officerLevel: 5 }),
buildGeneral(3, { npcState: 2 }),
],
});
const command = {
type: 'kick' as const,
requestId: 'kick-accepted-time',
userId: 'user-2',
generalId: 2,
destGeneralId: 3,
};
const db = {
inputEvent: {
findUnique: async () => ({
createdAt: acceptedAt,
actorUserId: command.userId,
target: 'ENGINE',
eventType: command.type,
}),
},
};
await expect(fixture.handler.handle(command, { db: db as never })).resolves.toMatchObject({ ok: true });
expect(fixture.world.peekDirtyState().messages[0]?.time).toEqual(
new Date('0185-01-01T00:10:00.000Z')
);
});
});
@@ -0,0 +1,424 @@
import { describe, expect, it } from 'vitest';
import type { TurnDaemonCommand } from '@sammo-ts/common';
import type { TurnSchedule } from '@sammo-ts/logic';
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { applyNationSettingMutation } from '../src/turn/nationSettingMutation.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
type SetNationSettingCommand = Extract<TurnDaemonCommand, { type: 'setNationSetting' }>;
type NationSettingMutation = SetNationSettingCommand['mutation'];
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const acceptedAt = new Date('2026-02-03T04:05:06.000Z');
const general: TurnGeneral = {
id: 1,
userId: 'owner-1',
name: '테스트군주',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 75, strength: 40, intelligence: 70 },
turnTime: new Date('0185-01-01T00:00:00.000Z'),
recentWarTime: null,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
penalty: {},
officerLevel: 12,
experience: 0,
dedication: 0,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 1100,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
};
const snapshot: TurnWorldSnapshot = {
generals: [general],
cities: [
{
id: 1,
name: '허창',
nationId: 1,
level: 7,
state: 0,
population: 100_000,
populationMax: 200_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: {},
},
],
nations: [
{
id: 1,
name: '위',
color: '#777777',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 10_000,
rice: 20_000,
power: 0,
level: 3,
typeCode: 'che_법가',
meta: { tech: 3_000, preserved: 'yes' },
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig: {
stat: { total: 300, min: 10, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'basic' },
},
scenarioMeta: {
title: 'test',
startYear: 180,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
};
const state: TurnWorldState = {
id: 1,
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0185-01-01T00:00:00.000Z'),
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
clockTick: 0,
clockMode: 'manual',
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
lastTurnTick: 0,
meta: { killturn: 24 },
};
const createWorld = (options?: {
general?: Partial<TurnGeneral>;
nationMeta?: TurnWorldSnapshot['nations'][number]['meta'];
worldMeta?: Record<string, unknown>;
}): InMemoryTurnWorld => {
const nextSnapshot = structuredClone(snapshot);
nextSnapshot.generals[0] = { ...nextSnapshot.generals[0]!, ...options?.general };
nextSnapshot.nations[0]!.meta = {
...nextSnapshot.nations[0]!.meta,
...options?.nationMeta,
};
return new InMemoryTurnWorld(
{
...state,
meta: { ...state.meta, ...options?.worldMeta },
},
nextSnapshot,
{ schedule }
);
};
const command = (
mutation: NationSettingMutation,
overrides?: Partial<Omit<SetNationSettingCommand, 'type' | 'mutation'>>
): SetNationSettingCommand => ({
type: 'setNationSetting',
requestId: 'nation-setting-test',
userId: 'owner-1',
generalId: 1,
nationId: 1,
mutation,
...overrides,
});
describe('nation setting mutation', () => {
it('keeps raw required validation at the API boundary and only enforces code-point length durably', () => {
const normalizeNotice = (message: string) =>
normalizeTurnDaemonCommand({
requestId: 'nation-setting-text-boundary',
sentAt: acceptedAt.toISOString(),
command: command({ kind: 'notice', message }),
});
expect(normalizeNotice('')).toMatchObject({
type: 'setNationSetting',
mutation: { kind: 'notice', message: '' },
});
expect(normalizeNotice(' \t\n\v\0')).toMatchObject({
type: 'setNationSetting',
mutation: { kind: 'notice', message: ' \t\n\v\0' },
});
expect(normalizeNotice(' ')).toMatchObject({
type: 'setNationSetting',
mutation: { kind: 'notice', message: ' ' },
});
expect(normalizeNotice('😀'.repeat(16_384))).not.toBeNull();
expect(normalizeNotice('😀'.repeat(16_385))).toBeNull();
expect(
normalizeTurnDaemonCommand({
requestId: 'nation-setting-scout-text-boundary',
sentAt: acceptedAt.toISOString(),
command: command({ kind: 'scoutMessage', message: '😀'.repeat(1_000) }),
})
).not.toBeNull();
expect(
normalizeTurnDaemonCommand({
requestId: 'nation-setting-scout-text-overflow',
sentAt: acceptedAt.toISOString(),
command: command({ kind: 'scoutMessage', message: '😀'.repeat(1_001) }),
})
).toBeNull();
});
it.each([
['notice', 'notice', 'nationNotice'],
['scoutMessage', 'infoText', null],
] as const)('stores an empty sanitized %s string like Ref', (kind, metaKey, structuredMetaKey) => {
const normalized = normalizeTurnDaemonCommand({
requestId: `nation-setting-empty-${kind}`,
sentAt: acceptedAt.toISOString(),
command: command({ kind, message: '' }),
});
expect(normalized).not.toBeNull();
if (!normalized || normalized.type !== 'setNationSetting') {
throw new Error('setNationSetting normalization failed');
}
const world = createWorld();
expect(applyNationSettingMutation({ world, command: normalized, acceptedAt })).toMatchObject({
type: 'setNationSetting',
ok: true,
});
expect(world.getNationById(1)?.meta[metaKey]).toBe('');
if (structuredMetaKey) {
expect(world.getNationById(1)?.meta[structuredMetaKey]).toMatchObject({ msg: '' });
}
});
it('rechecks owner, nation, and permission at execution time without mutating nation metadata on rejection', () => {
const cases: Array<{
name: string;
world: InMemoryTurnWorld;
command: SetNationSettingCommand;
code: 'FORBIDDEN' | 'PRECONDITION_FAILED';
}> = [
{
name: 'owner changed',
world: createWorld(),
command: command({ kind: 'rate', amount: 20 }, { userId: 'other-owner' }),
code: 'FORBIDDEN',
},
{
name: 'nation changed',
world: createWorld({ general: { nationId: 2 } }),
command: command({ kind: 'rate', amount: 20 }),
code: 'PRECONDITION_FAILED',
},
{
name: 'permission revoked',
world: createWorld({ general: { officerLevel: 2 } }),
command: command({ kind: 'rate', amount: 20 }),
code: 'FORBIDDEN',
},
];
for (const testCase of cases) {
const before = structuredClone(testCase.world.getNationById(1)?.meta);
expect(
applyNationSettingMutation({
world: testCase.world,
command: testCase.command,
acceptedAt,
}),
testCase.name
).toMatchObject({ type: 'setNationSetting', ok: false, code: testCase.code });
expect(testCase.world.getNationById(1)?.meta, testCase.name).toEqual(before);
}
});
it('preserves the special editable-permission rules for high officers and low ambassadors', () => {
const highOfficer = createWorld({
general: { officerLevel: 5, penalty: { noChief: true } },
});
expect(
applyNationSettingMutation({
world: highOfficer,
command: command({ kind: 'rate', amount: 20 }, { requestId: 'high-officer' }),
acceptedAt,
})
).toMatchObject({ type: 'setNationSetting', ok: true });
expect(highOfficer.getNationById(1)?.meta).toMatchObject({ rate: 20, preserved: 'yes' });
const lowAmbassador = createWorld({
general: { officerLevel: 2, meta: { killturn: 24, permission: 'ambassador' } },
});
expect(
applyNationSettingMutation({
world: lowAmbassador,
command: command({ kind: 'bill', amount: 100 }, { requestId: 'low-ambassador' }),
acceptedAt,
})
).toMatchObject({ type: 'setNationSetting', ok: true });
expect(lowAmbassador.getNationById(1)?.meta).toMatchObject({ bill: 100, preserved: 'yes' });
});
it('stores the notice text and author snapshot at logical game time', () => {
const world = createWorld();
const result = applyNationSettingMutation({
world,
command: command({ kind: 'notice', message: '새 국가 방침' }, { requestId: 'notice-logical-time' }),
acceptedAt,
});
expect(result).toMatchObject({
type: 'setNationSetting',
ok: true,
nationId: 1,
updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
});
expect(world.getNationById(1)?.meta).toMatchObject({
preserved: 'yes',
notice: '새 국가 방침',
nationNotice: {
date: '0185-01-01 09:00:00',
msg: '새 국가 방침',
author: '테스트군주',
authorID: 1,
},
_updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
});
});
it('treats an absent war-setting counter as zero and leaves metadata unchanged', () => {
const world = createWorld();
const before = structuredClone(world.getNationById(1)?.meta);
expect(
applyNationSettingMutation({
world,
command: command({ kind: 'blockWar', value: true }),
acceptedAt,
})
).toEqual({
type: 'setNationSetting',
ok: false,
code: 'BAD_REQUEST',
reason: '잔여 횟수가 부족합니다.',
nationId: 1,
});
expect(world.getNationById(1)?.meta).toEqual(before);
});
it('consumes the current war-setting counter sequentially and rejects after exhaustion', () => {
const world = createWorld({ nationMeta: { available_war_setting_cnt: 2 } });
expect(
applyNationSettingMutation({
world,
command: command({ kind: 'blockWar', value: true }, { requestId: 'block-war-1' }),
acceptedAt,
})
).toMatchObject({ type: 'setNationSetting', ok: true, availableCnt: 1 });
expect(world.getNationById(1)?.meta).toMatchObject({ war: 1, available_war_setting_cnt: 1 });
expect(
applyNationSettingMutation({
world,
command: command({ kind: 'blockWar', value: false }, { requestId: 'block-war-2' }),
acceptedAt,
})
).toMatchObject({ type: 'setNationSetting', ok: true, availableCnt: 0 });
expect(world.getNationById(1)?.meta).toMatchObject({ war: 0, available_war_setting_cnt: 0 });
const beforeRejected = structuredClone(world.getNationById(1)?.meta);
expect(
applyNationSettingMutation({
world,
command: command({ kind: 'blockWar', value: true }, { requestId: 'block-war-3' }),
acceptedAt,
})
).toMatchObject({ type: 'setNationSetting', ok: false, code: 'BAD_REQUEST' });
expect(world.getNationById(1)?.meta).toEqual(beforeRejected);
});
it.each([
['missing', undefined],
['null', null],
['false', false],
['zero', 0],
['empty string', ''],
['string zero', '0'],
['empty array', []],
])('allows scout changes when the legacy lock value is falsey: %s', (_name, lockValue) => {
const world = createWorld({
worldMeta: lockValue === undefined ? {} : { block_change_scout: lockValue },
});
expect(
applyNationSettingMutation({
world,
command: command({ kind: 'blockScout', value: true }),
acceptedAt,
})
).toMatchObject({ type: 'setNationSetting', ok: true });
expect(world.getNationById(1)?.meta).toMatchObject({ scout: 1, preserved: 'yes' });
});
it.each([
['true', true],
['one', 1],
['string one', '1'],
['non-empty array', [0]],
['object', {}],
])('rejects scout changes without mutation when the legacy lock value is truthy: %s', (_name, lockValue) => {
const world = createWorld({ worldMeta: { block_change_scout: lockValue } });
const before = structuredClone(world.getNationById(1)?.meta);
expect(
applyNationSettingMutation({
world,
command: command({ kind: 'blockScout', value: true }),
acceptedAt,
})
).toEqual({
type: 'setNationSetting',
ok: false,
code: 'FORBIDDEN',
reason: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
nationId: 1,
});
expect(world.getNationById(1)?.meta).toEqual(before);
});
});
+266 -26
View File
@@ -6,7 +6,7 @@ import { asRecord } from '@sammo-ts/common';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { AutorunNationPolicy } from '../src/turn/ai/policies.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
import { applyNpcPolicyMutation } from '../src/turn/npcPolicyMutation.js';
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const general: TurnGeneral = {
@@ -83,7 +83,7 @@ const snapshot: TurnWorldSnapshot = {
meta: { tech: 3_000, preserved: 'yes', _updatedAt: '2026-01-01T00:00:00.000Z' },
},
],
troops: [],
troops: [{ id: 101, nationId: 1, name: '선봉부대' }],
diplomacy: [],
events: [],
initialEvents: [],
@@ -176,28 +176,71 @@ const unitSet: UnitSetDefinition = {
};
describe('NPC policy lifecycle', () => {
it('applies one CAS-protected metadata command and the next AI instance consumes it without scheduler changes', async () => {
it('applies CAS-protected semantic policy changes and the next AI instance consumes them without scheduler changes', () => {
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const handler = createTurnDaemonCommandHandler({ world });
const updates = {
const first = applyNpcPolicyMutation({
world,
acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
command: {
type: 'setNpcPolicy',
userId: 'owner-1',
generalId: 1,
nationId: 1,
requestId: 'npc-policy-values',
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
mutation: { kind: 'nationPolicy', values: { reqNationGold: 4_321 } },
},
});
expect(first).toMatchObject({
type: 'setNpcPolicy',
ok: true,
nationId: 1,
updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
});
if (!first.ok) {
throw new Error(first.reason);
}
world.updateNation(1, {
meta: {
...world.getNationById(1)!.meta,
_updatedAt: '2026-02-03T04:05:06.500Z#unrelated-setting',
},
});
const second = applyNpcPolicyMutation({
world,
acceptedAt: new Date('2026-02-03T04:05:07.000Z'),
command: {
type: 'setNpcPolicy',
userId: 'owner-1',
generalId: 1,
nationId: 1,
requestId: 'npc-policy-priority',
expectedUpdatedAt: first.updatedAt,
mutation: { kind: 'nationPriority', priority: ['천도'] },
},
});
expect(second).toMatchObject({
type: 'setNpcPolicy',
ok: true,
nationId: 1,
updatedAt: expect.stringMatching(/^2026-02-03T04:05:07\.000Z#[0-9a-f]{16}$/),
});
if (!second.ok) {
throw new Error(second.reason);
}
const nation = world.getNationById(1)!;
expect(nation.meta).toMatchObject({
preserved: 'yes',
npc_nation_policy: {
values: { reqNationGold: 4_321 },
priority: ['천도'],
valueSetter: '정책담당',
valueSetter: 'NPC군주',
prioritySetter: 'NPC군주',
},
};
await expect(
handler.handle({
type: 'setNationMeta',
nationId: 1,
updates,
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
})
).resolves.toMatchObject({ type: 'setNationMeta', ok: true, nationId: 1 });
const nation = world.getNationById(1)!;
expect(nation.meta).toMatchObject({ preserved: 'yes', npc_nation_policy: updates.npc_nation_policy });
});
const policy = new AutorunNationPolicy({
general: world.getGeneralById(1)!,
aiOptions: null,
@@ -214,17 +257,214 @@ describe('NPC policy lifecycle', () => {
expect(policy.reqNpcWarGold).toBe(3_900);
expect(policy.reqNpcWarRice).toBe(3_900);
await expect(
handler.handle({
type: 'setNationMeta',
nationId: 1,
updates: { npc_nation_policy: { values: { reqNationGold: 9_999 } } },
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
const beforeConflict = structuredClone(world.getNationById(1)?.meta);
expect(
applyNpcPolicyMutation({
world,
acceptedAt: new Date('2026-02-03T04:05:08.000Z'),
command: {
type: 'setNpcPolicy',
userId: 'owner-1',
generalId: 1,
nationId: 1,
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
mutation: { kind: 'nationPolicy', values: { reqNationGold: 9_999 } },
},
})
).resolves.toMatchObject({ type: 'setNationMeta', ok: false, reason: 'CONFLICT' });
).toMatchObject({
type: 'setNpcPolicy',
ok: false,
code: 'CONFLICT',
currentUpdatedAt: second.updatedAt,
});
expect(world.getNationById(1)?.meta).toEqual(beforeConflict);
expect(asRecord(asRecord(world.getNationById(1)?.meta).npc_nation_policy).values).toEqual({
reqNationGold: 4_321,
});
expect(world.getState()).toMatchObject({ currentYear: 185, currentMonth: 1, tickSeconds: 600 });
});
it('requires an exact nullable revision when policy metadata has never been versioned', () => {
const noRevisionSnapshot = structuredClone(snapshot);
delete noRevisionSnapshot.nations[0]!.meta._npcPolicyUpdatedAt;
delete noRevisionSnapshot.nations[0]!.meta._updatedAt;
const world = new InMemoryTurnWorld(state, noRevisionSnapshot, { schedule });
const initialMeta = structuredClone(world.getNationById(1)?.meta);
expect(
applyNpcPolicyMutation({
world,
acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
command: {
type: 'setNpcPolicy',
userId: 'owner-1',
generalId: 1,
nationId: 1,
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
mutation: { kind: 'nationPriority', priority: ['천도'] },
},
})
).toMatchObject({ type: 'setNpcPolicy', ok: false, code: 'CONFLICT', currentUpdatedAt: null });
expect(world.getNationById(1)?.meta).toEqual(initialMeta);
const accepted = applyNpcPolicyMutation({
world,
acceptedAt: new Date('2026-02-03T04:05:07.000Z'),
command: {
type: 'setNpcPolicy',
requestId: 'initial-null-revision',
userId: 'owner-1',
generalId: 1,
nationId: 1,
expectedUpdatedAt: null,
mutation: { kind: 'nationPriority', priority: ['천도'] },
},
});
expect(accepted).toMatchObject({
type: 'setNpcPolicy',
ok: true,
nationId: 1,
updatedAt: expect.stringMatching(/^2026-02-03T04:05:07\.000Z#[0-9a-f]{16}$/),
});
if (!accepted.ok) {
throw new Error(accepted.reason);
}
expect(world.getNationById(1)?.meta).toMatchObject({
preserved: 'yes',
_npcPolicyUpdatedAt: accepted.updatedAt,
npc_nation_policy: { priority: ['천도'] },
});
});
it('validates and merges policy intent against current ENGINE state without materialising defaults', () => {
const world = new InMemoryTurnWorld(
{
...state,
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
clockTick: 0,
clockMode: 'manual',
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
lastTurnTick: 0,
},
snapshot,
{ schedule }
);
world.updateNation(1, {
meta: {
...world.getNationById(1)!.meta,
npc_nation_policy: { values: { reqNationRice: 456 }, preserved: 'root' },
},
});
const result = applyNpcPolicyMutation({
world,
acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
command: {
type: 'setNpcPolicy',
userId: 'owner-1',
generalId: 1,
nationId: 1,
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
mutation: {
kind: 'nationPolicy',
values: {
reqNationGold: -100,
safeRecruitCityPopulationRatio: -0.5,
CombatForce: {},
SupportForce: [101],
},
},
},
});
expect(result).toMatchObject({
type: 'setNpcPolicy',
ok: true,
nationId: 1,
updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
});
expect(asRecord(world.getNationById(1)?.meta).npc_nation_policy).toEqual({
values: {
reqNationRice: 456,
reqNationGold: 0,
safeRecruitCityPopulationRatio: -0.5,
CombatForce: {},
SupportForce: [101],
},
preserved: 'root',
valueSetter: 'NPC군주',
valueSetTime: '0185-01-01 09:00:00',
});
});
it('rejects stale authority, empty input, malformed combat targets, and lost CAS inside ENGINE', () => {
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const baseCommand = {
type: 'setNpcPolicy' as const,
userId: 'owner-1',
generalId: 1,
nationId: 1,
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
};
const acceptedAt = new Date('2026-02-03T04:05:06.000Z');
expect(
applyNpcPolicyMutation({
world,
acceptedAt,
command: { ...baseCommand, mutation: { kind: 'nationPolicy', values: {} } },
})
).toMatchObject({ ok: false, code: 'BAD_REQUEST' });
expect(
applyNpcPolicyMutation({
world,
acceptedAt,
command: {
...baseCommand,
mutation: { kind: 'nationPolicy', values: { CombatForce: { 101: [1, 1, 1] } } },
},
})
).toMatchObject({ ok: false, code: 'BAD_REQUEST', reason: '101의 입력양식이 올바르지 않습니다.' });
expect(
applyNpcPolicyMutation({
world,
acceptedAt,
command: {
...baseCommand,
mutation: { kind: 'nationPolicy', values: { CombatForce: { 101: [1, 1] } } },
},
})
).toMatchObject({
ok: false,
code: 'BAD_REQUEST',
reason: '101의 도시 , 가 올바른 도시 번호가 아닙니다.',
});
expect(
applyNpcPolicyMutation({
world,
acceptedAt,
command: { ...baseCommand, mutation: { kind: 'nationPriority', priority: [] } },
})
).toMatchObject({ ok: false, code: 'BAD_REQUEST' });
expect(
applyNpcPolicyMutation({
world,
acceptedAt,
command: {
...baseCommand,
expectedUpdatedAt: '1999-01-01T00:00:00.000Z',
mutation: { kind: 'nationPriority', priority: ['천도'] },
},
})
).toMatchObject({ ok: false, code: 'CONFLICT' });
world.updateGeneral(1, { officerLevel: 2 });
expect(
applyNpcPolicyMutation({
world,
acceptedAt,
command: { ...baseCommand, mutation: { kind: 'nationPriority', priority: ['천도'] } },
})
).toMatchObject({ ok: false, code: 'FORBIDDEN' });
});
});
@@ -36,6 +36,67 @@ describe('old nation archive data', () => {
aux: { legacy: 'preserved', maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] },
generals: [7, 8],
history: ['위가 멸망'],
msg: '',
scout_msg: null,
});
});
it('prefers current notice fields and preserves empty strings', () => {
const nation: Nation = {
id: 2,
name: '위',
color: '#0000ff',
capitalCityId: 3,
chiefGeneralId: 7,
gold: 1_000,
rice: 2_000,
power: 8_000,
level: 5,
typeCode: 'che_법가',
meta: {
notice: '',
infoText: '',
nationNotice: { msg: 'legacy notice' },
msg: 'legacy flat notice',
scout_msg: 'legacy scout message',
},
};
expect(buildOldNationArchiveData({ nation, generalIds: [], history: [] })).toMatchObject({
msg: '',
scout_msg: '',
});
});
it('falls back to both legacy notice shapes and legacy scout text', () => {
const baseNation: Nation = {
id: 2,
name: '위',
color: '#0000ff',
capitalCityId: 3,
chiefGeneralId: 7,
gold: 1_000,
rice: 2_000,
power: 8_000,
level: 5,
typeCode: 'che_법가',
meta: {
nationNotice: { msg: 'legacy notice' },
msg: 'legacy flat notice',
scout_msg: 'legacy scout message',
},
};
expect(buildOldNationArchiveData({ nation: baseNation, generalIds: [], history: [] })).toMatchObject({
msg: 'legacy notice',
scout_msg: 'legacy scout message',
});
expect(
buildOldNationArchiveData({
nation: { ...baseNation, meta: { msg: 'legacy flat notice' } },
generalIds: [],
history: [],
})
).toMatchObject({ msg: 'legacy flat notice', scout_msg: null });
});
});
+45 -10
View File
@@ -10,6 +10,7 @@ const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }]
const buildGeneral = (id: number, overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
id,
userId: `user-${id}`,
name: `장수${id}`,
nationId: 1,
cityId: 1,
@@ -132,7 +133,9 @@ describe('troop management world commands', () => {
});
const handler = createTurnDaemonCommandHandler({ world });
await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toEqual({
await expect(
handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 })
).resolves.toEqual({
type: 'troopJoin',
ok: true,
generalId: 1,
@@ -159,7 +162,9 @@ describe('troop management world commands', () => {
});
const handler = createTurnDaemonCommandHandler({ world });
await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({
await expect(
handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 })
).resolves.toMatchObject({
ok: true,
});
expect(world.getGeneralById(1)).toMatchObject({ troopId: 2, cityId: 1 });
@@ -177,7 +182,9 @@ describe('troop management world commands', () => {
});
const handler = createTurnDaemonCommandHandler({ world });
await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({
await expect(
handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 })
).resolves.toMatchObject({
ok: true,
});
expect(world.getGeneralById(1)).toMatchObject({ troopId: 2, cityId: 2 });
@@ -188,7 +195,9 @@ describe('troop management world commands', () => {
const world = buildWorld({});
const handler = createTurnDaemonCommandHandler({ world });
await expect(handler.handle({ type: 'troopCreate', generalId: 1, troopName: ' 백마대 ' })).resolves.toEqual({
await expect(
handler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: ' 백마대 ' })
).resolves.toEqual({
type: 'troopCreate',
ok: true,
generalId: 1,
@@ -202,7 +211,12 @@ describe('troop management world commands', () => {
const escapedWorld = buildWorld({ generals: [buildGeneral(2)] });
const escapedHandler = createTurnDaemonCommandHandler({ world: escapedWorld });
await expect(
escapedHandler.handle({ type: 'troopCreate', generalId: 2, troopName: '<백마대>' })
escapedHandler.handle({
type: 'troopCreate',
userId: 'user-2',
generalId: 2,
troopName: '<백마대>',
})
).resolves.toMatchObject({ ok: true, troopName: '&lt;백마대&gt;' });
});
@@ -213,13 +227,13 @@ describe('troop management world commands', () => {
});
const assignedHandler = createTurnDaemonCommandHandler({ world: assigned });
await expect(
assignedHandler.handle({ type: 'troopCreate', generalId: 1, troopName: '신규대' })
assignedHandler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: '신규대' })
).resolves.toMatchObject({ ok: false, reason: '이미 부대에 소속되어 있습니다.' });
const blank = buildWorld({});
const blankHandler = createTurnDaemonCommandHandler({ world: blank });
await expect(
blankHandler.handle({ type: 'troopCreate', generalId: 1, troopName: ' ' })
blankHandler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: ' ' })
).resolves.toMatchObject({
ok: false,
reason: '부대 이름이 없습니다.',
@@ -244,6 +258,7 @@ describe('troop management world commands', () => {
await expect(
forbiddenHandler.handle({
type: 'troopKick',
userId: 'user-2',
generalId: 2,
troopId: 1,
targetGeneralId: 3,
@@ -256,6 +271,7 @@ describe('troop management world commands', () => {
await expect(
allowedHandler.handle({
type: 'troopKick',
userId: 'user-1',
generalId: 1,
troopId: 1,
targetGeneralId: 3,
@@ -266,6 +282,7 @@ describe('troop management world commands', () => {
await expect(
allowedHandler.handle({
type: 'troopKick',
userId: 'user-1',
generalId: 1,
troopId: 1,
targetGeneralId: 1,
@@ -280,7 +297,13 @@ describe('troop management world commands', () => {
});
const leaderHandler = createTurnDaemonCommandHandler({ world: leaderWorld });
await expect(
leaderHandler.handle({ type: 'troopRename', generalId: 1, troopId: 1, troopName: '신대' })
leaderHandler.handle({
type: 'troopRename',
userId: 'user-1',
generalId: 1,
troopId: 1,
troopName: '신대',
})
).resolves.toMatchObject({ ok: true, troopName: '신대' });
const managerWorld = buildWorld({
@@ -292,7 +315,13 @@ describe('troop management world commands', () => {
});
const managerHandler = createTurnDaemonCommandHandler({ world: managerWorld });
await expect(
managerHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' })
managerHandler.handle({
type: 'troopRename',
userId: 'user-2',
generalId: 2,
troopId: 1,
troopName: '신대',
})
).resolves.toMatchObject({ ok: true, troopName: '신대' });
const penalizedWorld = buildWorld({
@@ -307,7 +336,13 @@ describe('troop management world commands', () => {
});
const penalizedHandler = createTurnDaemonCommandHandler({ world: penalizedWorld });
await expect(
penalizedHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' })
penalizedHandler.handle({
type: 'troopRename',
userId: 'user-2',
generalId: 2,
troopId: 1,
troopName: '신대',
})
).resolves.toMatchObject({ ok: false, reason: '권한이 부족합니다.' });
expect(penalizedWorld.getTroopById(1)?.name).toBe('구대');
});
@@ -80,6 +80,8 @@ integration('unification finalization transaction', () => {
meta: {
power: 3_000,
max_power: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] },
notice: '통일 공지',
infoText: '통일 임관 안내',
},
},
});
@@ -268,6 +270,7 @@ integration('unification finalization transaction', () => {
await expect(
bidder.bid({
type: 'auctionBid',
userId,
auctionId: uniqueAuction.id,
generalId: fixtureId,
amount: 30,
@@ -277,6 +280,7 @@ integration('unification finalization transaction', () => {
await expect(
bidder.bid({
type: 'auctionBid',
userId,
auctionId: uniqueAuction.id,
generalId: fixtureId,
amount: 50,
@@ -404,6 +408,8 @@ integration('unification finalization transaction', () => {
maxCities: ['원자도시'],
aux: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] },
generals: [fixtureId],
msg: '통일 공지',
scout_msg: '통일 임관 안내',
});
expect(legacyOfficerPicture.length).toBeGreaterThan(32);
expect(await db.emperor.findFirstOrThrow({ where: { serverId } })).toMatchObject({
@@ -245,10 +245,16 @@ describe('unification handler', () => {
auctionBidder: { bid },
});
await expect(
commands.handle({ type: 'auctionBid', auctionId: 77, generalId: 1, amount: 100 })
commands.handle({ type: 'auctionBid', userId: 'user-1', auctionId: 77, generalId: 1, amount: 100 })
).resolves.toMatchObject({ ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' });
await expect(
commands.handle({ type: 'auctionOpen', auctionType: 'UNIQUE_ITEM', generalId: 1, amount: 100 })
commands.handle({
type: 'auctionOpen',
userId: 'user-1',
auctionType: 'UNIQUE_ITEM',
generalId: 1,
amount: 100,
})
).resolves.toMatchObject({ ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' });
expect(bid).not.toHaveBeenCalled();
});
@@ -63,7 +63,7 @@ const buildWorld = (): InMemoryTurnWorld => {
power: 3000,
level: 1,
typeCode: 'test',
meta: {},
meta: { notice: '통일 공지', infoText: '통일 임관 안내' },
};
const city: City = {
id: 1,
@@ -184,6 +184,7 @@ describe('persistUnificationFinalization', () => {
const gameHistoryUpdate = vi.fn().mockResolvedValue({});
const emperorCreate = vi.fn().mockResolvedValue({});
const oldGeneralUpsert = vi.fn().mockResolvedValue({});
const oldNationUpsert = vi.fn().mockResolvedValue({});
const transaction = Object.assign({} as GamePrisma.TransactionClient, {
$executeRaw: vi.fn().mockResolvedValue(1),
$queryRaw: vi.fn().mockResolvedValue([]),
@@ -233,7 +234,7 @@ describe('persistUnificationFinalization', () => {
),
},
oldNation: {
upsert: vi.fn().mockResolvedValue({}),
upsert: oldNationUpsert,
findMany: vi.fn().mockResolvedValue([]),
},
oldGeneral: { upsert: oldGeneralUpsert },
@@ -282,6 +283,14 @@ describe('persistUnificationFinalization', () => {
expect(gameHistoryUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) })
);
expect(oldNationUpsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
nation: 1,
data: expect.objectContaining({ msg: '통일 공지', scout_msg: '통일 임관 안내' }),
}),
})
);
expect(emperorCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
+18 -3
View File
@@ -18,6 +18,7 @@ import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../sr
const buildGeneral = (id: number): TurnGeneral => ({
id,
userId: `user-${id}`,
name: `General_${id}`,
nationId: 1,
cityId: 1,
@@ -46,6 +47,12 @@ const buildGeneral = (id: number): TurnGeneral => ({
npcState: 0,
});
const actorBindingDb = (userId = 'user-1') => ({
inputEvent: {
findUnique: async () => ({ actorUserId: userId, target: 'ENGINE', eventType: 'voteReward' }),
},
});
describe('voteReward command', () => {
it('keeps the wall-time fallback open at exact deadline equality', () => {
const deadline = new Date('0180-01-01T00:00:00.000Z');
@@ -67,6 +74,7 @@ describe('voteReward command', () => {
sentAt: '2026-08-23T00:00:00.000Z',
command: {
type: 'voteReward',
userId: 'user-1',
voteId: 1,
generalId: 1,
selection: [0],
@@ -224,6 +232,7 @@ describe('voteReward command', () => {
let voteQueryCount = 0;
let voteInsertQuery: { strings: readonly string[]; values: readonly unknown[] } | undefined;
const commandDb = {
...actorBindingDb(),
auction: {
findMany: async () => [],
},
@@ -249,6 +258,8 @@ describe('voteReward command', () => {
const handler = createTurnDaemonCommandHandler({ world });
const command = {
type: 'voteReward' as const,
requestId: 'vote-reward-1',
userId: 'user-1',
voteId: 1,
generalId: 1,
selection: [0],
@@ -269,9 +280,7 @@ describe('voteReward command', () => {
expect(
voteInsertQuery?.values.filter(
(value) =>
value instanceof Date &&
value.getTime() >= writerWindowStart &&
value.getTime() <= writerWindowEnd
value instanceof Date && value.getTime() >= writerWindowStart && value.getTime() <= writerWindowEnd
)
).toHaveLength(1);
@@ -311,6 +320,7 @@ describe('voteReward command', () => {
const duplicateHandler = createTurnDaemonCommandHandler({ world: duplicateWorld });
const duplicateResult = await duplicateHandler.handle(command, {
db: {
...actorBindingDb(),
auction: { findMany: async () => [] },
$queryRaw: async (query: { strings: readonly string[] }) => {
const text = query.strings.join(' ');
@@ -346,6 +356,7 @@ describe('voteReward command', () => {
const mismatchHandler = createTurnDaemonCommandHandler({ world: mismatchWorld });
const mismatchResult = await mismatchHandler.handle(command, {
db: {
...actorBindingDb(),
auction: { findMany: async () => [] },
$queryRaw: async (query: { strings: readonly string[] }) => {
const text = query.strings.join(' ');
@@ -383,6 +394,7 @@ describe('voteReward command', () => {
const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command;
const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, {
db: {
...actorBindingDb(),
$queryRaw: async (query: { strings: readonly string[] }) =>
query.strings.join(' ').includes('SELECT options')
? [
@@ -456,6 +468,7 @@ describe('voteReward command', () => {
});
const handler = createTurnDaemonCommandHandler({ world });
const commandDb = {
...actorBindingDb(),
auction: {
findMany: async () => [{ targetCode: 'che_무기_12_칠성검' }],
},
@@ -476,6 +489,8 @@ describe('voteReward command', () => {
const result = await handler.handle(
{
type: 'voteReward',
requestId: 'vote-reward-occupied',
userId: 'user-1',
voteId: 1,
generalId: 1,
selection: [0],
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
'turn/monthlyDisasterAction': 'src/turn/monthlyDisasterAction.ts',
'turn/monthlyEventHandler': 'src/turn/monthlyEventHandler.ts',
'turn/monthlyNationBettingAction': 'src/turn/monthlyNationBettingAction.ts',
'turn/npcPolicyMutation': 'src/turn/npcPolicyMutation.ts',
'turn/npcPossessionService': 'src/turn/npcPossessionService.ts',
'turn/rankData': 'src/turn/rankData.ts',
'turn/reservedTurnHandler': 'src/turn/reservedTurnHandler.ts',