From f84efafc9ab35d2798b991b2fa69ba26c714c4de Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 09:04:48 +0000 Subject: [PATCH] feat: validate and describe reserved command arguments --- app/game-api/src/battleSim/unitSetLoader.ts | 7 +- app/game-api/src/paths.ts | 18 ++ app/game-api/src/router/turns/index.ts | 91 ++++++- app/game-api/src/turns/commandInput.ts | 247 +++++++++++++++++++ app/game-api/src/turns/commandTable.ts | 31 ++- app/game-api/src/turns/turnCommandProfile.ts | 7 +- app/game-api/test/commandInput.test.ts | 57 +++++ app/game-api/test/router.test.ts | 65 ++++- 8 files changed, 503 insertions(+), 20 deletions(-) create mode 100644 app/game-api/src/paths.ts create mode 100644 app/game-api/src/turns/commandInput.ts create mode 100644 app/game-api/test/commandInput.test.ts diff --git a/app/game-api/src/battleSim/unitSetLoader.ts b/app/game-api/src/battleSim/unitSetLoader.ts index edc86d5..b7e2706 100644 --- a/app/game-api/src/battleSim/unitSetLoader.ts +++ b/app/game-api/src/battleSim/unitSetLoader.ts @@ -1,12 +1,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); +import { resolveWorkspaceRoot } from '../paths.js'; + +const REPO_ROOT = resolveWorkspaceRoot(); const DEFAULT_UNIT_SET_ROOT = path.resolve(REPO_ROOT, 'resources', 'unitset'); export interface UnitSetLoaderOptions { diff --git a/app/game-api/src/paths.ts b/app/game-api/src/paths.ts new file mode 100644 index 0000000..4f17cea --- /dev/null +++ b/app/game-api/src/paths.ts @@ -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); +}; diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index bd353bf..ee48607 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -1,8 +1,16 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { ITEM_KEYS, loadItemModules } from '@sammo-ts/logic'; 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 { + parseReservedTurnArgs, + TURN_COMMAND_NATION_COLORS, + type TurnCommandInputOptions, +} from '../../turns/commandInput.js'; import { MAX_GENERAL_TURNS, MAX_NATION_TURNS, @@ -25,6 +33,18 @@ const buildShiftAmountSchema = (maxTurns: number) => 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({ getCommandTable: authedProcedure .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 ? ctx.db.city.findUnique({ where: { id: general.cityId }, @@ -61,14 +82,76 @@ export const turnsRouter = router({ where: { nationId: general.nationId }, }) : 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({ worldState, general, city, nation, nationGenerals, + inputOptions, }); }), reserved: router({ @@ -121,13 +204,14 @@ export const turnsRouter = router({ ) .mutation(async ({ ctx, input }) => { await getOwnedGeneral(ctx, input.generalId); + const args = await parseCommandArgs('general', input.action, input.args); const turns = await setGeneralTurn( ctx.db, input.generalId, input.turnIndex, input.action, - input.args + args ); return { ok: true, turns }; }), @@ -171,6 +255,7 @@ export const turnsRouter = router({ message: 'General is not an officer.', }); } + const args = await parseCommandArgs('nation', input.action, input.args); const turns = await setNationTurn( ctx.db, @@ -178,7 +263,7 @@ export const turnsRouter = router({ general.officerLevel, input.turnIndex, input.action, - input.args + args ); return { ok: true, turns }; }), diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts new file mode 100644 index 0000000..88614c0 --- /dev/null +++ b/app/game-api/src/turns/commandInput.ts @@ -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; +} + +// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다. +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 = { + 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 = { + 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> = { + itemType: { + horse: '명마', + weapon: '무기', + book: '서적', + item: '도구', + }, + commandType: { + che_선전포고: '선전포고', + che_불가침제의: '불가침 제의', + che_불가침파기제의: '불가침 파기 제의', + che_종전제의: '종전 제의', + }, +}; + +type JsonSchema = Record; + +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> => { + 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); +}; diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 2e5c85b..8d3453b 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -13,12 +13,17 @@ import type { TurnCommandEnv, TriggerValue, } 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 { asRecord, isRecord } from '@sammo-ts/common'; 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'; @@ -29,6 +34,7 @@ export interface TurnCommandAvailability { possible: boolean; status: AvailabilityStatus; reason?: string; + inputFields: TurnCommandInputField[]; } export interface TurnCommandGroup { @@ -39,6 +45,7 @@ export interface TurnCommandGroup { export interface TurnCommandTable { general: TurnCommandGroup[]; nation: TurnCommandGroup[]; + inputOptions: TurnCommandInputOptions; } type CommandEnv = TurnCommandEnv; @@ -54,6 +61,7 @@ interface CommandEntry { definition: GeneralActionDefinition; reqArg: boolean; availabilityArgs: Readonly>; + inputFields: TurnCommandInputField[]; evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore; } @@ -410,6 +418,7 @@ const buildEntries = (env: CommandEnv, specs: TurnCommandSpec[]): CommandEntry[] definition, reqArg: spec.reqArg, availabilityArgs: spec.reqArg ? spec.availabilityArgs : {}, + inputFields: buildTurnCommandInputFields(spec), }; if (spec.key === 'che_포상') { @@ -445,6 +454,7 @@ const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: Stat key: entry.definition.key, name: entry.definition.name, reqArg: entry.reqArg, + inputFields: entry.inputFields, ...availability, }; @@ -468,6 +478,7 @@ export const buildTurnCommandTable = async (options: { city: CityRow | null; nation: NationRow | null; nationGenerals: GeneralRow[] | null; + inputOptions?: TurnCommandInputOptions; }): Promise => { // 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다. const general = mapGeneralRow(options.general); @@ -486,16 +497,22 @@ export const buildTurnCommandTable = async (options: { }; const env = buildCommandEnv(options.worldState); - const commandProfile = await loadTurnCommandProfile(); - const [generalSpecs, nationSpecs] = await Promise.all([ - loadGeneralTurnCommandSpecs(commandProfile.general), - loadNationTurnCommandSpecs(commandProfile.nation), - ]); + const { general: generalSpecs, nation: nationSpecs } = await loadTurnCommandSpecs(); const generalEntries = buildEntries(env, generalSpecs); const nationEntries = buildEntries(env, nationSpecs); return { general: buildGroups(generalEntries, ctx, view), nation: buildGroups(nationEntries, ctx, view), + inputOptions: options.inputOptions ?? { + cities: [], + nations: [], + generals: [], + crewTypes: [], + armTypes: [], + nationTypes: [], + colors: [], + items: {}, + }, }; }; diff --git a/app/game-api/src/turns/turnCommandProfile.ts b/app/game-api/src/turns/turnCommandProfile.ts index 554fa0d..91136b6 100644 --- a/app/game-api/src/turns/turnCommandProfile.ts +++ b/app/game-api/src/turns/turnCommandProfile.ts @@ -1,12 +1,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); +import { resolveWorkspaceRoot } from '../paths.js'; + +const REPO_ROOT = resolveWorkspaceRoot(); const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands', 'default.json'); export interface TurnCommandProfileOptions { diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts new file mode 100644 index 0000000..97d5c0c --- /dev/null +++ b/app/game-api/test/commandInput.test.ts @@ -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' + ); + }); +}); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index c826c9b..d68311b 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -77,6 +77,8 @@ const buildContext = (options?: { general?: GeneralRow | null; generalTurns?: GeneralTurnRow[]; nationTurns?: NationTurnRow[]; + generalTurnWrites?: unknown[]; + nationTurnWrites?: unknown[]; auth?: GameSessionTokenPayload | null; }): GameApiContext => { const transport = options?.transport ?? new InMemoryTurnDaemonTransport(); @@ -105,7 +107,10 @@ const buildContext = (options?: { findMany: async ({ where }: { where: { generalId: number } }) => generalTurns.filter((row) => row.generalId === where.generalId), deleteMany: async () => ({}), - createMany: async () => ({}), + createMany: async (args: unknown) => { + options?.generalTurnWrites?.push(args); + return {}; + }, }, nationTurn: { findMany: async ({ where }: { where: { nationId: number; officerLevel: number } }) => @@ -113,7 +118,10 @@ const buildContext = (options?: { (row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel ), deleteMany: async () => ({}), - createMany: async () => ({}), + createMany: async (args: unknown) => { + options?.nationTurnWrites?.push(args); + return {}; + }, }, }; const accessTokenStore = new RedisAccessTokenStore( @@ -281,6 +289,59 @@ describe('appRouter', () => { 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 () => { const general = buildGeneralRow({ id: 15, userId: 'user-2' }); const caller = appRouter.createCaller(buildContext({ general }));