fix: 예약 명령의 Ref 인자 검증 순서를 복원한다

This commit is contained in:
2026-08-24 19:48:43 +00:00
parent 97b7c49070
commit 0e10b64fb7
5 changed files with 359 additions and 25 deletions
+45 -10
View File
@@ -14,6 +14,8 @@ import {
} from '../../turns/commandTable.js';
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
import {
assertReservedTurnActionAvailable,
assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions,
parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS,
@@ -85,6 +87,27 @@ const parseCommandArgs = async (
}
};
const preflightCommandArgs = async (
scope: 'general' | 'nation',
action: string,
args: unknown,
worldState: WorldStateRow
): Promise<void> => {
try {
// Ref checks scenario availability and common argument types before
// officer/penalty gates. Core keeps actor ownership first, then leaves
// required command-specific fields for the later parser.
await assertReservedTurnActionAvailable(scope, action, asRecord(worldState.config).const);
assertReservedTurnArgsPassLegacyBasicValidation(args);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: error instanceof Error ? error.message : 'Invalid turn command arguments.',
cause: error,
});
}
};
const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> => {
try {
return await mutation();
@@ -484,6 +507,7 @@ export const turnsRouter = router({
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const worldState = await getReservationWorldState(ctx);
await preflightCommandArgs('general', input.action, input.args, worldState);
const args = await parseCommandArgs('general', input.action, input.args, worldState);
await assertReservedTurnPermission(worldState, general, 'general', input.action, args);
@@ -540,15 +564,20 @@ export const turnsRouter = router({
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const worldState = await getReservationWorldState(ctx);
const updates = await Promise.all(
input.entries.map(async (entry) => ({
const firstEntry = input.entries[0];
await preflightCommandArgs('general', firstEntry.action, firstEntry.args, worldState);
const updates: ReservedTurnUpdate[] = [];
for (const [index, entry] of input.entries.entries()) {
if (index > 0) {
await preflightCommandArgs('general', entry.action, entry.args, worldState);
}
const update = {
turnIndices: expandGeneralTurnIndices(entry.turnList),
action: entry.action,
args: await parseCommandArgs('general', entry.action, entry.args, worldState),
}))
);
for (const update of updates) {
};
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
updates.push(update);
}
const snapshot = await mutateReservedTurns(() =>
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
@@ -573,6 +602,8 @@ export const turnsRouter = router({
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const worldState = await getReservationWorldState(ctx);
await preflightCommandArgs('nation', input.action, input.args, worldState);
if (general.nationId <= 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
@@ -585,9 +616,8 @@ export const turnsRouter = router({
message: 'General is not an officer.',
});
}
const worldState = await getReservationWorldState(ctx);
const args = await parseCommandArgs('nation', input.action, input.args, worldState);
assertNationTurnInputAllowed(general);
const args = await parseCommandArgs('nation', input.action, input.args, worldState);
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
const snapshot = await mutateReservedTurns(() =>
@@ -684,6 +714,9 @@ export const turnsRouter = router({
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const worldState = await getReservationWorldState(ctx);
const firstEntry = input.entries[0];
await preflightCommandArgs('nation', firstEntry.action, firstEntry.args, worldState);
if (general.nationId <= 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
@@ -696,15 +729,17 @@ export const turnsRouter = router({
message: 'General is not an officer.',
});
}
const worldState = await getReservationWorldState(ctx);
const updates: ReservedTurnUpdate[] = [];
for (const entry of input.entries) {
for (const [index, entry] of input.entries.entries()) {
if (index > 0) {
await preflightCommandArgs('nation', entry.action, entry.args, worldState);
}
assertNationTurnInputAllowed(general);
const update = {
turnIndices: entry.turnList,
action: entry.action,
args: await parseCommandArgs('nation', entry.action, entry.args, worldState),
};
assertNationTurnInputAllowed(general);
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
updates.push(update);
}
+184 -1
View File
@@ -1,6 +1,7 @@
import {
isGeneralTurnCommandKey,
isNationTurnCommandKey,
getLegacyStringWidth,
loadGeneralTurnCommandSpecs,
loadNationTurnCommandSpecs,
type GeneralTurnCommandSpec,
@@ -385,6 +386,188 @@ const parseRegisteredTurnArgs = async (
return spec.argsSchema.parse(rawArgs);
};
const LEGACY_REMOVED_TURN_ARG_CHARACTERS = new Set([
'"',
"'",
'ⓝ',
'ⓜ',
'ⓖ',
'ⓞ',
'ⓧ',
'㉥',
'\\',
'/',
'`',
'#',
'-',
'|',
]);
const sanitizeLegacyTurnArgString = (value: string): string => {
// Ref StringUtil::neutralize() treats the string "0" as empty because of
// PHP truthiness, both before and after removeSpecialCharacter().
if (value === '' || value === '0') {
return '';
}
const stripped = Array.from(value)
.filter((character) => !LEGACY_REMOVED_TURN_ARG_CHARACTERS.has(character))
.join('');
if (stripped === '' || stripped === '0') {
return '';
}
return stripped
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replace(/^[\p{Z}\p{C}]+|[\p{Z}\p{C}]+$/gu, '');
};
export const sanitizeReservedTurnArgs = (value: unknown): unknown => {
if (typeof value === 'string') {
return sanitizeLegacyTurnArgString(value);
}
if (Array.isArray(value)) {
return value.map((entry) => sanitizeReservedTurnArgs(entry));
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeReservedTurnArgs(entry)]));
}
return value;
};
const LEGACY_INTEGER_TURN_ARG_KEYS = new Set([
'crewType',
'destGeneralId',
'destGeneralID',
'destCityId',
'destCityID',
'destNationId',
'destNationID',
'amount',
'colorType',
'srcArmType',
'destArmType',
]);
const LEGACY_BOOLEAN_TURN_ARG_KEYS = new Set(['isGold', 'buyRice']);
const LEGACY_INTEGER_ARRAY_TURN_ARG_KEYS = new Set([
'destNationIdList',
'destNationIDList',
'destGeneralIdList',
'destGeneralIDList',
'amountList',
]);
const LEGACY_NUMERIC_PATTERN = /^[\t\n\r\f\v ]*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[\t\n\r\f\v ]*$/;
const skipsLegacyOptionalValidation = (value: unknown): boolean => value === null || value === '';
const isLegacyNumeric = (value: unknown): boolean => {
if (typeof value === 'number') {
return Number.isFinite(value);
}
if (typeof value !== 'string' || !LEGACY_NUMERIC_PATTERN.test(value)) {
return false;
}
return Number.isFinite(Number(value));
};
const throwLegacyBasicTurnArgError = (): never => {
throw new Error('턴이 입력되지 않았습니다.');
};
/**
* Ref checkCommandArg() only validates common fields that are present. Missing
* command-specific fields are deliberately left for command construction, so
* nation penalties and officer checks retain their legacy error priority.
*/
export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown): void => {
const sanitizedArgs = sanitizeReservedTurnArgs(rawArgs);
if (sanitizedArgs === null || sanitizedArgs === undefined) {
return;
}
if (typeof sanitizedArgs !== 'object') {
throwLegacyBasicTurnArgError();
}
const args = sanitizedArgs as Record<string, unknown>;
for (const key of LEGACY_INTEGER_TURN_ARG_KEYS) {
if (
Object.prototype.hasOwnProperty.call(args, key) &&
!skipsLegacyOptionalValidation(args[key]) &&
!Number.isInteger(args[key])
) {
throwLegacyBasicTurnArgError();
}
}
for (const key of LEGACY_BOOLEAN_TURN_ARG_KEYS) {
if (
Object.prototype.hasOwnProperty.call(args, key) &&
!skipsLegacyOptionalValidation(args[key]) &&
typeof args[key] !== 'boolean'
) {
throwLegacyBasicTurnArgError();
}
}
for (const key of LEGACY_INTEGER_ARRAY_TURN_ARG_KEYS) {
if (!Object.prototype.hasOwnProperty.call(args, key)) {
continue;
}
const value = args[key];
if (skipsLegacyOptionalValidation(value)) {
continue;
}
if (!Array.isArray(value) || value.some((entry) => !Number.isInteger(entry))) {
throwLegacyBasicTurnArgError();
}
}
const month = args.month;
if (
Object.prototype.hasOwnProperty.call(args, 'month') &&
!skipsLegacyOptionalValidation(month) &&
(!isLegacyNumeric(month) || Number(month) < 1 || Number(month) > 12)
) {
throwLegacyBasicTurnArgError();
}
const year = args.year;
if (
Object.prototype.hasOwnProperty.call(args, 'year') &&
!skipsLegacyOptionalValidation(year) &&
(!isLegacyNumeric(year) || Number(year) < 0)
) {
throwLegacyBasicTurnArgError();
}
for (const [key, minimum] of [
['destGeneralId', 1],
['destGeneralID', 1],
['destCityId', 1],
['destCityID', 1],
['destNationId', 1],
['destNationID', 1],
['amount', 1],
['crewType', 0],
] as const) {
if (
Object.prototype.hasOwnProperty.call(args, key) &&
!skipsLegacyOptionalValidation(args[key]) &&
Number(args[key]) < minimum
) {
throwLegacyBasicTurnArgError();
}
}
if (Object.prototype.hasOwnProperty.call(args, 'nationName')) {
const nationName = args.nationName;
if (
!skipsLegacyOptionalValidation(nationName) &&
(typeof nationName !== 'string' ||
getLegacyStringWidth(nationName) < 1 ||
getLegacyStringWidth(nationName) > 18)
) {
throwLegacyBasicTurnArgError();
}
}
};
export const assertReservedTurnActionAvailable = async (
scope: 'general' | 'nation',
action: string,
@@ -403,5 +586,5 @@ export const parseReservedTurnArgs = async (
scenarioConst?: unknown
): Promise<Record<string, unknown>> => {
await assertReservedTurnActionAvailable(scope, action, scenarioConst);
return parseRegisteredTurnArgs(scope, action, rawArgs);
return parseRegisteredTurnArgs(scope, action, sanitizeReservedTurnArgs(rawArgs));
};