feat: validate and describe reserved command arguments
This commit is contained in:
@@ -1,12 +1,11 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic';
|
import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
import { resolveWorkspaceRoot } from '../paths.js';
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
const REPO_ROOT = resolveWorkspaceRoot();
|
||||||
const DEFAULT_UNIT_SET_ROOT = path.resolve(REPO_ROOT, 'resources', 'unitset');
|
const DEFAULT_UNIT_SET_ROOT = path.resolve(REPO_ROOT, 'resources', 'unitset');
|
||||||
|
|
||||||
export interface UnitSetLoaderOptions {
|
export interface UnitSetLoaderOptions {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const hasWorkspaceMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'));
|
||||||
|
|
||||||
|
export const resolveWorkspaceRoot = (
|
||||||
|
startDir: string = process.env.GAME_WORKSPACE_ROOT ?? process.env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
|
||||||
|
maxDepth = 6
|
||||||
|
): string => {
|
||||||
|
let current = path.resolve(startDir);
|
||||||
|
for (let depth = 0; depth <= maxDepth; depth += 1) {
|
||||||
|
if (hasWorkspaceMarker(current)) return current;
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return path.resolve(startDir);
|
||||||
|
};
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { ITEM_KEYS, loadItemModules } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
|
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
|
||||||
|
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
|
||||||
import { buildTurnCommandTable } from '../../turns/commandTable.js';
|
import { buildTurnCommandTable } from '../../turns/commandTable.js';
|
||||||
|
import {
|
||||||
|
parseReservedTurnArgs,
|
||||||
|
TURN_COMMAND_NATION_COLORS,
|
||||||
|
type TurnCommandInputOptions,
|
||||||
|
} from '../../turns/commandInput.js';
|
||||||
import {
|
import {
|
||||||
MAX_GENERAL_TURNS,
|
MAX_GENERAL_TURNS,
|
||||||
MAX_NATION_TURNS,
|
MAX_NATION_TURNS,
|
||||||
@@ -25,6 +33,18 @@ const buildShiftAmountSchema = (maxTurns: number) =>
|
|||||||
message: 'Amount must be non-zero.',
|
message: 'Amount must be non-zero.',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
|
||||||
|
try {
|
||||||
|
return await parseReservedTurnArgs(scope, action, args);
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: error instanceof Error ? error.message : 'Invalid turn command arguments.',
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const turnsRouter = router({
|
export const turnsRouter = router({
|
||||||
getCommandTable: authedProcedure
|
getCommandTable: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -45,7 +65,8 @@ export const turnsRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [city, nation, nationGenerals] = await Promise.all([
|
const [city, nation, nationGenerals, cities, nations, generals, environment, traits, itemModules] =
|
||||||
|
await Promise.all([
|
||||||
general.cityId > 0
|
general.cityId > 0
|
||||||
? ctx.db.city.findUnique({
|
? ctx.db.city.findUnique({
|
||||||
where: { id: general.cityId },
|
where: { id: general.cityId },
|
||||||
@@ -61,14 +82,76 @@ export const turnsRouter = router({
|
|||||||
where: { nationId: general.nationId },
|
where: { nationId: general.nationId },
|
||||||
})
|
})
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
|
ctx.db.city.findMany({
|
||||||
|
select: { id: true, name: true, nationId: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
ctx.db.nation.findMany({
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
ctx.db.general.findMany({
|
||||||
|
where: { npcState: { lt: 2 } },
|
||||||
|
select: { id: true, name: true, nationId: true, cityId: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
buildBattleSimEnvironment(worldState, ctx.profile.id),
|
||||||
|
loadBattleSimTraitOptions(),
|
||||||
|
loadItemModules([...ITEM_KEYS]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
||||||
|
const cityById = new Map(cities.map((entry) => [entry.id, entry]));
|
||||||
|
const items: TurnCommandInputOptions['items'] = {
|
||||||
|
horse: [{ value: 'None', label: '판매/해제' }],
|
||||||
|
weapon: [{ value: 'None', label: '판매/해제' }],
|
||||||
|
book: [{ value: 'None', label: '판매/해제' }],
|
||||||
|
item: [{ value: 'None', label: '판매/해제' }],
|
||||||
|
};
|
||||||
|
for (const item of itemModules) {
|
||||||
|
if (item.buyable) {
|
||||||
|
items[item.slot].push({ value: item.key, label: item.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inputOptions: TurnCommandInputOptions = {
|
||||||
|
cities: cities.map((entry) => ({
|
||||||
|
value: entry.id,
|
||||||
|
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`,
|
||||||
|
})),
|
||||||
|
nations: nations.map((entry) => ({
|
||||||
|
value: entry.id,
|
||||||
|
label: entry.name,
|
||||||
|
color: entry.color,
|
||||||
|
})),
|
||||||
|
generals: generals.map((entry) => ({
|
||||||
|
value: entry.id,
|
||||||
|
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${
|
||||||
|
cityById.get(entry.cityId)?.name ?? '재야'
|
||||||
|
})`,
|
||||||
|
})),
|
||||||
|
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||||
|
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
|
||||||
|
.map((entry) => ({ value: entry.id, label: entry.name })),
|
||||||
|
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({
|
||||||
|
value: Number(value),
|
||||||
|
label,
|
||||||
|
})),
|
||||||
|
nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })),
|
||||||
|
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
|
||||||
|
value: index,
|
||||||
|
label: `색상 ${index + 1}`,
|
||||||
|
color,
|
||||||
|
})),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
|
||||||
return buildTurnCommandTable({
|
return buildTurnCommandTable({
|
||||||
worldState,
|
worldState,
|
||||||
general,
|
general,
|
||||||
city,
|
city,
|
||||||
nation,
|
nation,
|
||||||
nationGenerals,
|
nationGenerals,
|
||||||
|
inputOptions,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
reserved: router({
|
reserved: router({
|
||||||
@@ -121,13 +204,14 @@ export const turnsRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await getOwnedGeneral(ctx, input.generalId);
|
await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
const args = await parseCommandArgs('general', input.action, input.args);
|
||||||
|
|
||||||
const turns = await setGeneralTurn(
|
const turns = await setGeneralTurn(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.generalId,
|
input.generalId,
|
||||||
input.turnIndex,
|
input.turnIndex,
|
||||||
input.action,
|
input.action,
|
||||||
input.args
|
args
|
||||||
);
|
);
|
||||||
return { ok: true, turns };
|
return { ok: true, turns };
|
||||||
}),
|
}),
|
||||||
@@ -171,6 +255,7 @@ export const turnsRouter = router({
|
|||||||
message: 'General is not an officer.',
|
message: 'General is not an officer.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const args = await parseCommandArgs('nation', input.action, input.args);
|
||||||
|
|
||||||
const turns = await setNationTurn(
|
const turns = await setNationTurn(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
@@ -178,7 +263,7 @@ export const turnsRouter = router({
|
|||||||
general.officerLevel,
|
general.officerLevel,
|
||||||
input.turnIndex,
|
input.turnIndex,
|
||||||
input.action,
|
input.action,
|
||||||
input.args
|
args
|
||||||
);
|
);
|
||||||
return { ok: true, turns };
|
return { ok: true, turns };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import {
|
||||||
|
loadGeneralTurnCommandSpecs,
|
||||||
|
loadNationTurnCommandSpecs,
|
||||||
|
type GeneralTurnCommandSpec,
|
||||||
|
type NationTurnCommandSpec,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { loadTurnCommandProfile } from './turnCommandProfile.js';
|
||||||
|
|
||||||
|
export type TurnCommandOptionValue = string | number;
|
||||||
|
|
||||||
|
export interface TurnCommandOption {
|
||||||
|
value: TurnCommandOptionValue;
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TurnCommandOptionSource =
|
||||||
|
| 'cities'
|
||||||
|
| 'nations'
|
||||||
|
| 'generals'
|
||||||
|
| 'crewTypes'
|
||||||
|
| 'armTypes'
|
||||||
|
| 'nationTypes'
|
||||||
|
| 'colors'
|
||||||
|
| 'items';
|
||||||
|
|
||||||
|
export interface TurnCommandInputField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden';
|
||||||
|
required: boolean;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
constValue?: TurnCommandOptionValue;
|
||||||
|
options?: TurnCommandOption[];
|
||||||
|
optionSource?: TurnCommandOptionSource;
|
||||||
|
tupleLabels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnCommandInputOptions {
|
||||||
|
cities: TurnCommandOption[];
|
||||||
|
nations: TurnCommandOption[];
|
||||||
|
generals: TurnCommandOption[];
|
||||||
|
crewTypes: TurnCommandOption[];
|
||||||
|
armTypes: TurnCommandOption[];
|
||||||
|
nationTypes: TurnCommandOption[];
|
||||||
|
colors: TurnCommandOption[];
|
||||||
|
items: Record<string, TurnCommandOption[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
|
||||||
|
export const TURN_COMMAND_NATION_COLORS = [
|
||||||
|
'#FF0000', '#800000', '#A0522D', '#FF6347', '#FFA500', '#FFDAB9', '#FFD700', '#FFFF00',
|
||||||
|
'#7CFC00', '#00FF00', '#808000', '#008000', '#2E8B57', '#008080', '#20B2AA', '#6495ED',
|
||||||
|
'#7FFFD4', '#AFEEEE', '#87CEEB', '#00FFFF', '#00BFFF', '#0000FF', '#000080', '#483D8B',
|
||||||
|
'#7B68EE', '#BA55D3', '#800080', '#FF00FF', '#FFC0CB', '#F5F5DC', '#E0FFFF', '#FFFFFF',
|
||||||
|
'#A9A9A9',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
destCityId: '대상 도시',
|
||||||
|
destCityID: '대상 도시',
|
||||||
|
destGeneralId: '대상 장수',
|
||||||
|
destGeneralID: '대상 장수',
|
||||||
|
destNationId: '대상 국가',
|
||||||
|
nationName: '국가명',
|
||||||
|
nationType: '국가 성향',
|
||||||
|
colorType: '국기 색상',
|
||||||
|
srcArmType: '기존 병과',
|
||||||
|
destArmType: '변경 병과',
|
||||||
|
itemType: '장비 종류',
|
||||||
|
itemCode: '장비',
|
||||||
|
crewType: '병종',
|
||||||
|
amount: '수량',
|
||||||
|
buyRice: '거래',
|
||||||
|
isGold: '물자',
|
||||||
|
optionText: '행동',
|
||||||
|
year: '기간(년)',
|
||||||
|
month: '기간(월)',
|
||||||
|
commandType: '대응 명령',
|
||||||
|
amountList: '지원 물자',
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPTION_SOURCES: Record<string, TurnCommandOptionSource> = {
|
||||||
|
destCityId: 'cities',
|
||||||
|
destCityID: 'cities',
|
||||||
|
destGeneralId: 'generals',
|
||||||
|
destGeneralID: 'generals',
|
||||||
|
destNationId: 'nations',
|
||||||
|
nationType: 'nationTypes',
|
||||||
|
colorType: 'colors',
|
||||||
|
srcArmType: 'armTypes',
|
||||||
|
destArmType: 'armTypes',
|
||||||
|
itemCode: 'items',
|
||||||
|
crewType: 'crewTypes',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATIC_LABELS: Record<string, Record<string, string>> = {
|
||||||
|
itemType: {
|
||||||
|
horse: '명마',
|
||||||
|
weapon: '무기',
|
||||||
|
book: '서적',
|
||||||
|
item: '도구',
|
||||||
|
},
|
||||||
|
commandType: {
|
||||||
|
che_선전포고: '선전포고',
|
||||||
|
che_불가침제의: '불가침 제의',
|
||||||
|
che_불가침파기제의: '불가침 파기 제의',
|
||||||
|
che_종전제의: '종전 제의',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonSchema = Record<string, unknown>;
|
||||||
|
|
||||||
|
const collectObjectSchema = (schema: JsonSchema): JsonSchema => {
|
||||||
|
const branches = schema.oneOf ?? schema.anyOf;
|
||||||
|
if (Array.isArray(branches)) {
|
||||||
|
const objectBranches = branches.filter(isRecord).map((branch) => collectObjectSchema(branch));
|
||||||
|
const properties = Object.assign({}, ...objectBranches.map((branch) => asRecord(branch.properties)));
|
||||||
|
const required = Array.from(
|
||||||
|
new Set(
|
||||||
|
objectBranches.flatMap((branch) =>
|
||||||
|
Array.isArray(branch.required)
|
||||||
|
? branch.required.filter((entry): entry is string => typeof entry === 'string')
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { ...schema, properties, required };
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const enumOptions = (fieldKey: string, values: unknown[]): TurnCommandOption[] =>
|
||||||
|
values
|
||||||
|
.filter((value): value is TurnCommandOptionValue => typeof value === 'string' || typeof value === 'number')
|
||||||
|
.filter((value) => fieldKey !== 'commandType' || value !== 'che_피장파장')
|
||||||
|
.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: STATIC_LABELS[fieldKey]?.[String(value)] ?? String(value),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const buildField = (key: string, rawSchema: unknown, required: boolean): TurnCommandInputField => {
|
||||||
|
const schema = asRecord(rawSchema);
|
||||||
|
const label = FIELD_LABELS[key] ?? key;
|
||||||
|
const constValue = schema.const;
|
||||||
|
if (typeof constValue === 'string' || typeof constValue === 'number') {
|
||||||
|
return { key, label, kind: 'hidden', required, constValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'amountList') {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
kind: 'numberTuple',
|
||||||
|
required,
|
||||||
|
tupleLabels: ['금', '쌀'],
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionSource = OPTION_SOURCES[key];
|
||||||
|
const enumValues = Array.isArray(schema.enum) ? enumOptions(key, schema.enum) : undefined;
|
||||||
|
if (optionSource || enumValues) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
kind: 'select',
|
||||||
|
required,
|
||||||
|
optionSource,
|
||||||
|
options: enumValues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === 'boolean') {
|
||||||
|
return { key, label, kind: 'boolean', required };
|
||||||
|
}
|
||||||
|
if (schema.type === 'number' || schema.type === 'integer') {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
kind: 'number',
|
||||||
|
required,
|
||||||
|
min: typeof schema.minimum === 'number' ? schema.minimum : undefined,
|
||||||
|
max: typeof schema.maximum === 'number' ? schema.maximum : undefined,
|
||||||
|
step: schema.type === 'integer' ? 1 : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (schema.type === 'string') {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
kind: 'text',
|
||||||
|
required,
|
||||||
|
min: typeof schema.minLength === 'number' ? schema.minLength : undefined,
|
||||||
|
max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported turn command argument schema: ${key}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildTurnCommandInputFields = (
|
||||||
|
spec: GeneralTurnCommandSpec | NationTurnCommandSpec
|
||||||
|
): TurnCommandInputField[] => {
|
||||||
|
if (!spec.reqArg) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const jsonSchema = collectObjectSchema(asRecord(z.toJSONSchema(spec.argsSchema)));
|
||||||
|
const properties = asRecord(jsonSchema.properties);
|
||||||
|
const required = new Set(
|
||||||
|
Array.isArray(jsonSchema.required)
|
||||||
|
? jsonSchema.required.filter((entry): entry is string => typeof entry === 'string')
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
return Object.entries(properties).map(([key, schema]) => buildField(key, schema, required.has(key)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadTurnCommandSpecs = async () => {
|
||||||
|
const profile = await loadTurnCommandProfile();
|
||||||
|
const [general, nation] = await Promise.all([
|
||||||
|
loadGeneralTurnCommandSpecs(profile.general),
|
||||||
|
loadNationTurnCommandSpecs(profile.nation),
|
||||||
|
]);
|
||||||
|
return { general, nation };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseReservedTurnArgs = async (
|
||||||
|
scope: 'general' | 'nation',
|
||||||
|
action: string,
|
||||||
|
rawArgs: unknown
|
||||||
|
): Promise<Record<string, unknown>> => {
|
||||||
|
const specs = await loadTurnCommandSpecs();
|
||||||
|
const spec = specs[scope].find((entry) => entry.key === action);
|
||||||
|
if (!spec) {
|
||||||
|
throw new Error(`Unknown ${scope} turn command: ${action}`);
|
||||||
|
}
|
||||||
|
if (!spec.reqArg) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return spec.argsSchema.parse(rawArgs);
|
||||||
|
};
|
||||||
@@ -13,12 +13,17 @@ import type {
|
|||||||
TurnCommandEnv,
|
TurnCommandEnv,
|
||||||
TriggerValue,
|
TriggerValue,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { evaluateConstraints, loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs } from '@sammo-ts/logic';
|
import { evaluateConstraints } from '@sammo-ts/logic';
|
||||||
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
||||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
|
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
|
||||||
import { loadTurnCommandProfile } from './turnCommandProfile.js';
|
import {
|
||||||
|
buildTurnCommandInputFields,
|
||||||
|
loadTurnCommandSpecs,
|
||||||
|
type TurnCommandInputField,
|
||||||
|
type TurnCommandInputOptions,
|
||||||
|
} from './commandInput.js';
|
||||||
|
|
||||||
type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown';
|
type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown';
|
||||||
|
|
||||||
@@ -29,6 +34,7 @@ export interface TurnCommandAvailability {
|
|||||||
possible: boolean;
|
possible: boolean;
|
||||||
status: AvailabilityStatus;
|
status: AvailabilityStatus;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
inputFields: TurnCommandInputField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnCommandGroup {
|
export interface TurnCommandGroup {
|
||||||
@@ -39,6 +45,7 @@ export interface TurnCommandGroup {
|
|||||||
export interface TurnCommandTable {
|
export interface TurnCommandTable {
|
||||||
general: TurnCommandGroup[];
|
general: TurnCommandGroup[];
|
||||||
nation: TurnCommandGroup[];
|
nation: TurnCommandGroup[];
|
||||||
|
inputOptions: TurnCommandInputOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommandEnv = TurnCommandEnv;
|
type CommandEnv = TurnCommandEnv;
|
||||||
@@ -54,6 +61,7 @@ interface CommandEntry {
|
|||||||
definition: GeneralActionDefinition;
|
definition: GeneralActionDefinition;
|
||||||
reqArg: boolean;
|
reqArg: boolean;
|
||||||
availabilityArgs: Readonly<Record<string, unknown>>;
|
availabilityArgs: Readonly<Record<string, unknown>>;
|
||||||
|
inputFields: TurnCommandInputField[];
|
||||||
evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore;
|
evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +418,7 @@ const buildEntries = (env: CommandEnv, specs: TurnCommandSpec[]): CommandEntry[]
|
|||||||
definition,
|
definition,
|
||||||
reqArg: spec.reqArg,
|
reqArg: spec.reqArg,
|
||||||
availabilityArgs: spec.reqArg ? spec.availabilityArgs : {},
|
availabilityArgs: spec.reqArg ? spec.availabilityArgs : {},
|
||||||
|
inputFields: buildTurnCommandInputFields(spec),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (spec.key === 'che_포상') {
|
if (spec.key === 'che_포상') {
|
||||||
@@ -445,6 +454,7 @@ const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: Stat
|
|||||||
key: entry.definition.key,
|
key: entry.definition.key,
|
||||||
name: entry.definition.name,
|
name: entry.definition.name,
|
||||||
reqArg: entry.reqArg,
|
reqArg: entry.reqArg,
|
||||||
|
inputFields: entry.inputFields,
|
||||||
...availability,
|
...availability,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -468,6 +478,7 @@ export const buildTurnCommandTable = async (options: {
|
|||||||
city: CityRow | null;
|
city: CityRow | null;
|
||||||
nation: NationRow | null;
|
nation: NationRow | null;
|
||||||
nationGenerals: GeneralRow[] | null;
|
nationGenerals: GeneralRow[] | null;
|
||||||
|
inputOptions?: TurnCommandInputOptions;
|
||||||
}): Promise<TurnCommandTable> => {
|
}): Promise<TurnCommandTable> => {
|
||||||
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
|
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
|
||||||
const general = mapGeneralRow(options.general);
|
const general = mapGeneralRow(options.general);
|
||||||
@@ -486,16 +497,22 @@ export const buildTurnCommandTable = async (options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const env = buildCommandEnv(options.worldState);
|
const env = buildCommandEnv(options.worldState);
|
||||||
const commandProfile = await loadTurnCommandProfile();
|
const { general: generalSpecs, nation: nationSpecs } = await loadTurnCommandSpecs();
|
||||||
const [generalSpecs, nationSpecs] = await Promise.all([
|
|
||||||
loadGeneralTurnCommandSpecs(commandProfile.general),
|
|
||||||
loadNationTurnCommandSpecs(commandProfile.nation),
|
|
||||||
]);
|
|
||||||
const generalEntries = buildEntries(env, generalSpecs);
|
const generalEntries = buildEntries(env, generalSpecs);
|
||||||
const nationEntries = buildEntries(env, nationSpecs);
|
const nationEntries = buildEntries(env, nationSpecs);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
general: buildGroups(generalEntries, ctx, view),
|
general: buildGroups(generalEntries, ctx, view),
|
||||||
nation: buildGroups(nationEntries, ctx, view),
|
nation: buildGroups(nationEntries, ctx, view),
|
||||||
|
inputOptions: options.inputOptions ?? {
|
||||||
|
cities: [],
|
||||||
|
nations: [],
|
||||||
|
generals: [],
|
||||||
|
crewTypes: [],
|
||||||
|
armTypes: [],
|
||||||
|
nationTypes: [],
|
||||||
|
colors: [],
|
||||||
|
items: {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic';
|
import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
import { resolveWorkspaceRoot } from '../paths.js';
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
const REPO_ROOT = resolveWorkspaceRoot();
|
||||||
const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands', 'default.json');
|
const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands', 'default.json');
|
||||||
|
|
||||||
export interface TurnCommandProfileOptions {
|
export interface TurnCommandProfileOptions {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
GENERAL_TURN_COMMAND_KEYS,
|
||||||
|
NATION_TURN_COMMAND_KEYS,
|
||||||
|
loadGeneralTurnCommandSpecs,
|
||||||
|
loadNationTurnCommandSpecs,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildTurnCommandInputFields,
|
||||||
|
parseReservedTurnArgs,
|
||||||
|
} from '../src/turns/commandInput.js';
|
||||||
|
|
||||||
|
describe('turn command argument input', () => {
|
||||||
|
it('builds supported fields for every argument-bearing command module', async () => {
|
||||||
|
const [general, nation] = await Promise.all([
|
||||||
|
loadGeneralTurnCommandSpecs([...GENERAL_TURN_COMMAND_KEYS]),
|
||||||
|
loadNationTurnCommandSpecs([...NATION_TURN_COMMAND_KEYS]),
|
||||||
|
]);
|
||||||
|
const argumentSpecs = [...general, ...nation].filter((spec) => spec.reqArg);
|
||||||
|
const fields = argumentSpecs.map((spec) => ({
|
||||||
|
key: spec.key,
|
||||||
|
fields: buildTurnCommandInputFields(spec),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(argumentSpecs).toHaveLength(44);
|
||||||
|
expect(fields.every((entry) => entry.fields.length > 0)).toBe(true);
|
||||||
|
expect(fields.find((entry) => entry.key === 'che_화계')?.fields).toMatchObject([
|
||||||
|
{ key: 'destCityId', kind: 'select', optionSource: 'cities' },
|
||||||
|
]);
|
||||||
|
expect(fields.find((entry) => entry.key === 'che_물자원조')?.fields).toMatchObject([
|
||||||
|
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
|
||||||
|
{ key: 'amountList', kind: 'numberTuple' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes valid arguments and rejects malformed or wrong-scope commands', async () => {
|
||||||
|
await expect(parseReservedTurnArgs('general', 'che_화계', { destCityId: 7 })).resolves.toEqual({
|
||||||
|
destCityId: 7,
|
||||||
|
});
|
||||||
|
await expect(parseReservedTurnArgs('general', 'che_화계', { destCityId: '7' })).rejects.toBeDefined();
|
||||||
|
await expect(
|
||||||
|
parseReservedTurnArgs('nation', 'che_포상', {
|
||||||
|
isGold: true,
|
||||||
|
amount: 200,
|
||||||
|
destGeneralId: 7,
|
||||||
|
})
|
||||||
|
).resolves.toEqual({
|
||||||
|
isGold: true,
|
||||||
|
amount: 200,
|
||||||
|
destGeneralId: 7,
|
||||||
|
});
|
||||||
|
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow(
|
||||||
|
'Unknown general turn command'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,6 +77,8 @@ const buildContext = (options?: {
|
|||||||
general?: GeneralRow | null;
|
general?: GeneralRow | null;
|
||||||
generalTurns?: GeneralTurnRow[];
|
generalTurns?: GeneralTurnRow[];
|
||||||
nationTurns?: NationTurnRow[];
|
nationTurns?: NationTurnRow[];
|
||||||
|
generalTurnWrites?: unknown[];
|
||||||
|
nationTurnWrites?: unknown[];
|
||||||
auth?: GameSessionTokenPayload | null;
|
auth?: GameSessionTokenPayload | null;
|
||||||
}): GameApiContext => {
|
}): GameApiContext => {
|
||||||
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
|
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
|
||||||
@@ -105,7 +107,10 @@ const buildContext = (options?: {
|
|||||||
findMany: async ({ where }: { where: { generalId: number } }) =>
|
findMany: async ({ where }: { where: { generalId: number } }) =>
|
||||||
generalTurns.filter((row) => row.generalId === where.generalId),
|
generalTurns.filter((row) => row.generalId === where.generalId),
|
||||||
deleteMany: async () => ({}),
|
deleteMany: async () => ({}),
|
||||||
createMany: async () => ({}),
|
createMany: async (args: unknown) => {
|
||||||
|
options?.generalTurnWrites?.push(args);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
nationTurn: {
|
nationTurn: {
|
||||||
findMany: async ({ where }: { where: { nationId: number; officerLevel: number } }) =>
|
findMany: async ({ where }: { where: { nationId: number; officerLevel: number } }) =>
|
||||||
@@ -113,7 +118,10 @@ const buildContext = (options?: {
|
|||||||
(row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel
|
(row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel
|
||||||
),
|
),
|
||||||
deleteMany: async () => ({}),
|
deleteMany: async () => ({}),
|
||||||
createMany: async () => ({}),
|
createMany: async (args: unknown) => {
|
||||||
|
options?.nationTurnWrites?.push(args);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const accessTokenStore = new RedisAccessTokenStore(
|
const accessTokenStore = new RedisAccessTokenStore(
|
||||||
@@ -281,6 +289,59 @@ describe('appRouter', () => {
|
|||||||
expect(response[0]?.index).toBe(0);
|
expect(response[0]?.index).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('validates and persists general command arguments from the authenticated owner', async () => {
|
||||||
|
const general = buildGeneralRow({ id: 13 });
|
||||||
|
const writes: unknown[] = [];
|
||||||
|
const caller = appRouter.createCaller(buildContext({ general, generalTurnWrites: writes }));
|
||||||
|
|
||||||
|
const response = await caller.turns.reserved.setGeneral({
|
||||||
|
generalId: 13,
|
||||||
|
turnIndex: 0,
|
||||||
|
action: 'che_화계',
|
||||||
|
args: { destCityId: 7 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } });
|
||||||
|
expect(writes).toHaveLength(1);
|
||||||
|
const written = writes[0] as { data: unknown[] };
|
||||||
|
expect(written.data).toHaveLength(30);
|
||||||
|
expect(written.data[0]).toMatchObject({
|
||||||
|
generalId: 13,
|
||||||
|
turnIdx: 0,
|
||||||
|
actionCode: 'che_화계',
|
||||||
|
arg: { destCityId: 7 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed and cross-scope arguments without writing turns', async () => {
|
||||||
|
const general = buildGeneralRow({ id: 14, nationId: 3, officerLevel: 5 });
|
||||||
|
const generalWrites: unknown[] = [];
|
||||||
|
const nationWrites: unknown[] = [];
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
buildContext({ general, generalTurnWrites: generalWrites, nationTurnWrites: nationWrites })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.turns.reserved.setGeneral({
|
||||||
|
generalId: 14,
|
||||||
|
turnIndex: 0,
|
||||||
|
action: 'che_화계',
|
||||||
|
args: { destCityId: '7' },
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
await expect(
|
||||||
|
caller.turns.reserved.setGeneral({
|
||||||
|
generalId: 14,
|
||||||
|
turnIndex: 0,
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: true, amount: 1, destGeneralId: 7 },
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
|
||||||
|
expect(generalWrites).toHaveLength(0);
|
||||||
|
expect(nationWrites).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects another user general across actor-owned routers', async () => {
|
it('rejects another user general across actor-owned routers', async () => {
|
||||||
const general = buildGeneralRow({ id: 15, userId: 'user-2' });
|
const general = buildGeneralRow({ id: 15, userId: 'user-2' });
|
||||||
const caller = appRouter.createCaller(buildContext({ general }));
|
const caller = appRouter.createCaller(buildContext({ general }));
|
||||||
|
|||||||
Reference in New Issue
Block a user