import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { loadActionModuleBundle } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; import { GamePrisma } from '@sammo-ts/infra'; import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { buildBattleSimEnvironment } from '../../battleSim/environment.js'; import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js'; import { buildRecruitmentCommandInfo, buildTurnCommandTable, evaluateReservedTurnPermission, } from '../../turns/commandTable.js'; import { loadMapDefinitionByName } from '../../maps/mapDefinition.js'; import { assertReservedTurnActionAvailable, assertReservedTurnArgsPassLegacyBasicValidation, buildEquipmentTradeItemOptions, parseReservedTurnArgs, TURN_COMMAND_NATION_COLORS, type TurnCommandInputOptions, } from '../../turns/commandInput.js'; import { MAX_GENERAL_TURNS, MAX_NATION_TURNS, ReservedTurnRevisionConflictError, type ReservedTurnUpdate, expandGeneralTurnIndices, getGeneralTurnSnapshot, getNationTurnSnapshot, repeatGeneralTurns, repeatNationTurns, setGeneralTurn, setGeneralTurns, setNationTurnAtCurrentPosition, setNationTurnsAtCurrentPositions, shiftGeneralTurns, shiftNationTurns, } from '../../turns/reservedTurns.js'; import { getOwnedGeneral } from '../shared/general.js'; import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js'; import { buildRefAmountPresets, buildRefGeneralTargetOptions, buildRefNationTargetOptions, } from '../../turns/commandTargets.js'; const zPushAmount = z .number() .int() .min(-12) .max(12) .refine((value) => value !== 0, { message: 'Amount must be non-zero.', }); const zRepeatAmount = z.number().int().min(1).max(12); const buildTurnListSchema = (minimum: number, maximum: number) => z.array(z.number().int().min(minimum).max(maximum)).min(1); const buildBulkEntrySchema = (turnList: z.ZodType) => z.object({ turnList, action: z.string().min(1), args: z.unknown().optional(), }); const parseCommandArgs = async ( scope: 'general' | 'nation', action: string, args: unknown, worldState: WorldStateRow ) => { try { // 사용자 입력은 action별 argument schema보다 먼저 현재 scenario의 // 선택 가능 profile을 통과해야 한다. 내부 전용 명령의 parser를 외부 // 요청이 직접 호출하지 못하게 하는 첫 경계다. return await parseReservedTurnArgs(scope, action, args, asRecord(worldState.config).const); } catch (error) { throw new TRPCError({ code: 'BAD_REQUEST', message: error instanceof Error ? error.message : 'Invalid turn command arguments.', cause: error, }); } }; const preflightCommandArgs = async ( scope: 'general' | 'nation', action: string, args: unknown, worldState: WorldStateRow ): Promise => { 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 (mutation: () => Promise): Promise => { try { return await mutation(); } catch (error) { if (error instanceof ReservedTurnRevisionConflictError) { throw new TRPCError({ code: 'CONFLICT', message: 'Reserved turn queue changed. Reload and retry.', cause: error, }); } throw error; } }; const getReservationWorldState = async (ctx: GameApiContext): Promise => { const worldState = await ctx.db.worldState.findFirst(); if (!worldState) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.', }); } return worldState; }; const resolveMapName = (worldState: WorldStateRow, fallback: string): string => { const config = asRecord(worldState.config); const environment = asRecord(config.environment ?? config.map); const mapName = environment.mapName; return typeof mapName === 'string' && mapName.trim().length > 0 ? mapName : fallback; }; const plainLegacyInfo = (value: string): string => value .replace(//giu, ' · ') .replace(/<[^>]+>/gu, '') .replace(/\s+/gu, ' ') .trim(); const readGeneralMetaNumber = (meta: unknown, key: string): number | null => { if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null; const value = (meta as Record)[key]; if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string') { const parsed = Number(value); if (Number.isFinite(parsed)) return parsed; } return null; }; const assertNationTurnInputAllowed = (general: GeneralRow): void => { if (!Object.prototype.hasOwnProperty.call(asRecord(general.penalty), 'noChiefTurnInput')) { return; } throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '수뇌 턴 입력 불가능', }); }; const refillNationTurnInputKillturn = async ( ctx: GameApiContext, general: GeneralRow, worldState: WorldStateRow ): Promise => { if (general.npcState >= 2) { return false; } const worldKillturn = readGeneralMetaNumber(worldState.meta, 'killturn'); if (worldKillturn === null) { return false; } const currentKillturn = readGeneralMetaNumber(general.meta, 'killturn'); const nextKillturn = Math.max(currentKillturn ?? 0, worldKillturn); if (currentKillturn !== null && nextKillturn === currentKillturn) { return false; } const updated = await ctx.db.$queryRaw>( GamePrisma.sql` UPDATE general SET meta = jsonb_set( CASE WHEN jsonb_typeof(meta) = 'object' THEN meta ELSE '{}'::jsonb END, '{killturn}', to_jsonb( GREATEST( CASE WHEN jsonb_typeof(meta->'killturn') = 'number' THEN (meta->>'killturn')::double precision ELSE 0::double precision END, ${nextKillturn}::double precision ) ), true ) WHERE id = ${general.id} AND npc_state < 2 AND ( jsonb_typeof(meta->'killturn') IS DISTINCT FROM 'number' OR (meta->>'killturn')::double precision < ${nextKillturn} ) RETURNING id ` ); return updated.length > 0; }; const assertReservedTurnPermission = async ( worldState: WorldStateRow, general: GeneralRow, scope: 'general' | 'nation', action: string, args: Record ): Promise => { const result = await evaluateReservedTurnPermission({ worldState, general, scope, action, args, }); if (result.kind === 'allow') { return; } throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.kind === 'deny' ? `예약 불가능한 커맨드 :${result.reason}` : '예약 권한을 확인할 정보가 부족합니다.', }); }; export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number) => { const [worldState, general] = await Promise.all([ctx.db.worldState.findFirst(), getOwnedGeneral(ctx, generalId)]); if (!worldState) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.', }); } const environmentPromise = buildBattleSimEnvironment(worldState, ctx.profile.id); const moduleBundlePromise = environmentPromise.then((environment) => loadActionModuleBundle(environment.unitSet, environment.scenarioEffect) ); const [ city, nation, nationGenerals, cities, nations, generals, diplomacy, troops, environment, traits, moduleBundle, map, ] = await Promise.all([ general.cityId > 0 ? ctx.db.city.findUnique({ where: { id: general.cityId }, }) : null, general.nationId > 0 ? ctx.db.nation.findUnique({ where: { id: general.nationId }, }) : null, general.nationId > 0 ? ctx.db.general.findMany({ where: { nationId: general.nationId }, }) : Promise.resolve(null), ctx.db.city.findMany({ orderBy: { id: 'asc' } }), ctx.db.nation.findMany({ select: { id: true, name: true, color: true, capitalCityId: true, level: true, meta: true, }, orderBy: { id: 'asc' }, }), ctx.db.general.findMany({ select: { id: true, name: true, nationId: true, cityId: true, npcState: true, officerLevel: true, gold: true, rice: true, crew: true, train: true, atmos: true, troopId: true, }, orderBy: [{ npcState: 'asc' }, { name: 'asc' }, { id: 'asc' }], }), general.nationId > 0 ? ctx.db.diplomacy.findMany({ where: { srcNationId: general.nationId } }) : Promise.resolve([]), general.nationId > 0 ? ctx.db.troop.findMany({ where: { nationId: general.nationId }, orderBy: { troopLeaderId: 'asc' } }) : Promise.resolve([]), environmentPromise, loadBattleSimTraitOptions(), moduleBundlePromise, loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)), ]); const nationById = new Map(nations.map((entry) => [entry.id, entry])); const cityById = new Map(cities.map((entry) => [entry.id, entry])); const generalCountByNation = new Map(); for (const entry of generals) { generalCountByNation.set(entry.nationId, (generalCountByNation.get(entry.nationId) ?? 0) + 1); } const cityCountByNation = new Map(); for (const entry of cities) { cityCountByNation.set(entry.nationId, (cityCountByNation.get(entry.nationId) ?? 0) + 1); } const diplomacyByNation = new Map(diplomacy.map((entry) => [entry.destNationId, entry])); const adjacentNationIds = new Set(); const actorCityIds = new Set( cities.filter((entry) => entry.nationId === general.nationId).map((entry) => entry.id) ); for (const mapCity of map.cities) { if (!actorCityIds.has(mapCity.id)) continue; for (const adjacentId of mapCity.connections) { const adjacentNationId = cityById.get(adjacentId)?.nationId; if (adjacentNationId && adjacentNationId !== general.nationId) adjacentNationIds.add(adjacentNationId); } } const nationTargetOptions = buildRefNationTargetOptions({ actorNationId: general.nationId, nations: nations.map((entry) => { const relation = diplomacyByNation.get(entry.id); return { id: entry.id, name: entry.name, color: entry.color, capitalName: entry.capitalCityId ? (cityById.get(entry.capitalCityId)?.name ?? '-') : '-', level: entry.level, power: readGeneralMetaNumber(entry.meta, 'power') ?? 0, generalCount: generalCountByNation.get(entry.id) ?? 0, cityCount: cityCountByNation.get(entry.id) ?? 0, diplomacyState: relation?.stateCode ?? 2, diplomacyTerm: relation?.term ?? 0, adjacent: adjacentNationIds.has(entry.id), diplomacyRestricted: (readGeneralMetaNumber(entry.meta, 'surlimit') ?? 0) !== 0, }; }), }); const generalTargetOptions = buildRefGeneralTargetOptions({ actorId: general.id, actorNationId: general.nationId, generals, nationNames: new Map(nations.map((entry) => [entry.id, entry.name])), cityNames: new Map(cities.map((entry) => [entry.id, entry.name])), troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])), }); const items = buildEquipmentTradeItemOptions({ configConst: asRecord(asRecord(worldState.config).const), itemModules: moduleBundle.itemModules, currentSecurity: city?.security ?? 0, generalGold: general.gold, }); const inputOptions: TurnCommandInputOptions = { cities: cities.map((entry) => ({ value: entry.id, label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`, })), nations: nationTargetOptions.nations, nationTargets: nationTargetOptions.nationTargets, generals: generalTargetOptions.generals, generalTargets: generalTargetOptions.generalTargets, 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]) => { const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`); return { value: Number(value), label, ...(dexterity === null ? {} : { description: `현재 숙련 ${dexterity.toLocaleString()}` }), }; }), nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name, description: plainLegacyInfo(entry.info), })), colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({ value: index, label: `색상 ${index + 1}`, color, })), items, recruitment: buildRecruitmentCommandInfo({ worldState, general, city, nation, cities, map, unitSet: environment.unitSet, generalActionModules: moduleBundle.general, }), amountPresets: buildRefAmountPresets( nation?.level ?? 0, readGeneralMetaNumber(asRecord(asRecord(worldState.config).const), 'maxResourceActionAmount') ?? 10_000 ), context: { actorGold: general.gold, actorRice: general.rice, ...(city ? { citySecurity: city.security } : {}), ...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}), }, }; return buildTurnCommandTable({ worldState, general, city, nation, nationGenerals, realNationCount: nations.filter((entry) => entry.id > 0).length, inputOptions, generalActionModules: moduleBundle.general, }); }; export const turnsRouter = router({ getCommandTable: accessAuthedInputProcedure( z.object({ generalId: z.number().int().positive(), }) ).query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)), reserved: router({ getGeneral: authedProcedure .input( z.object({ generalId: z.number().int().positive(), }) ) .query(async ({ ctx, input }) => { await getOwnedGeneral(ctx, input.generalId); return getGeneralTurnSnapshot(ctx.db, input.generalId); }), getNation: authedProcedure .input( z.object({ generalId: z.number().int().positive(), }) ) .query(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); if (general.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'General is not part of a nation.', }); } if (general.officerLevel < 5) { throw new TRPCError({ code: 'FORBIDDEN', message: 'General is not an officer.', }); } return getNationTurnSnapshot(ctx.db, general.nationId, general.officerLevel); }), setGeneral: authedProcedure .input( z.object({ generalId: z.number().int().positive(), turnIndex: z .number() .int() .min(0) .max(MAX_GENERAL_TURNS - 1), action: z.string().min(1), args: z.unknown().optional(), expectedRevision: z.number().int().nonnegative(), }) ) .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); const snapshot = await mutateReservedTurns(() => setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision) ); ctx.changeJournal?.mark('reserved.general', input.generalId); ctx.changeJournal?.mark('dashboard.global'); return { ok: true, ...snapshot }; }), shiftGeneral: authedProcedure .input( z.object({ generalId: z.number().int().positive(), amount: zPushAmount, expectedRevision: z.number().int().nonnegative(), }) ) .mutation(async ({ ctx, input }) => { await getOwnedGeneral(ctx, input.generalId); const snapshot = await mutateReservedTurns(() => shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision) ); ctx.changeJournal?.mark('reserved.general', input.generalId); ctx.changeJournal?.mark('dashboard.global'); return { ok: true, ...snapshot }; }), repeatGeneral: authedProcedure .input( z.object({ generalId: z.number().int().positive(), amount: zRepeatAmount, expectedRevision: z.number().int().nonnegative(), }) ) .mutation(async ({ ctx, input }) => { await getOwnedGeneral(ctx, input.generalId); const snapshot = await mutateReservedTurns(() => repeatGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision) ); ctx.changeJournal?.mark('reserved.general', input.generalId); ctx.changeJournal?.mark('dashboard.global'); return { ok: true, ...snapshot }; }), setGeneralBulk: authedProcedure .input( z.object({ generalId: z.number().int().positive(), entries: z.array(buildBulkEntrySchema(buildTurnListSchema(-3, MAX_GENERAL_TURNS - 1))).min(1), expectedRevision: z.number().int().nonnegative(), }) ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); const worldState = await getReservationWorldState(ctx); 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), }; await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args); updates.push(update); } const snapshot = await mutateReservedTurns(() => setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision) ); ctx.changeJournal?.mark('reserved.general', input.generalId); ctx.changeJournal?.mark('dashboard.global'); return { ok: true, ...snapshot }; }), setNation: authedProcedure .input( z.object({ generalId: z.number().int().positive(), turnIndex: z .number() .int() .min(0) .max(MAX_NATION_TURNS - 1), action: z.string().min(1), args: z.unknown().optional(), expectedRevision: z.number().int().nonnegative(), }) ) .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', message: 'General is not part of a nation.', }); } if (general.officerLevel < 5) { throw new TRPCError({ code: 'FORBIDDEN', message: 'General is not an officer.', }); } assertNationTurnInputAllowed(general); const args = await parseCommandArgs('nation', input.action, input.args, worldState); await assertReservedTurnPermission(worldState, general, 'nation', input.action, args); const snapshot = await mutateReservedTurns(() => setNationTurnAtCurrentPosition( ctx.db, general.nationId, general.officerLevel, input.turnIndex, input.action, args, input.expectedRevision ) ); if (await refillNationTurnInputKillturn(ctx, general, worldState)) { ctx.changeJournal?.mark('general.content', general.id); ctx.changeJournal?.mark('dashboard.global'); } return { ok: true, ...snapshot }; }), shiftNation: authedProcedure .input( z.object({ generalId: z.number().int().positive(), amount: zPushAmount, expectedRevision: z.number().int().nonnegative(), }) ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); if (general.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'General is not part of a nation.', }); } if (general.officerLevel < 5) { throw new TRPCError({ code: 'FORBIDDEN', message: 'General is not an officer.', }); } const snapshot = await mutateReservedTurns(() => shiftNationTurns( ctx.db, general.nationId, general.officerLevel, input.amount, input.expectedRevision ) ); return { ok: true, ...snapshot }; }), repeatNation: authedProcedure .input( z.object({ generalId: z.number().int().positive(), amount: zRepeatAmount, expectedRevision: z.number().int().nonnegative(), }) ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); if (general.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'General is not part of a nation.', }); } if (general.officerLevel < 5) { throw new TRPCError({ code: 'FORBIDDEN', message: 'General is not an officer.', }); } const snapshot = await mutateReservedTurns(() => repeatNationTurns( ctx.db, general.nationId, general.officerLevel, input.amount, input.expectedRevision ) ); return { ok: true, ...snapshot }; }), setNationBulk: authedProcedure .input( z.object({ generalId: z.number().int().positive(), entries: z.array(buildBulkEntrySchema(buildTurnListSchema(0, MAX_NATION_TURNS - 1))).min(1), expectedRevision: z.number().int().nonnegative(), }) ) .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', message: 'General is not part of a nation.', }); } if (general.officerLevel < 5) { throw new TRPCError({ code: 'FORBIDDEN', message: 'General is not an officer.', }); } const updates: ReservedTurnUpdate[] = []; 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), }; await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args); updates.push(update); } const snapshot = await mutateReservedTurns(() => setNationTurnsAtCurrentPositions( ctx.db, general.nationId, general.officerLevel, updates, input.expectedRevision ) ); if (await refillNationTurnInputKillturn(ctx, general, worldState)) { ctx.changeJournal?.mark('general.content', general.id); ctx.changeJournal?.mark('dashboard.global'); } return { ok: true, ...snapshot }; }), }), });