diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index cc6e24f..ec76fa3 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -5,7 +5,7 @@ 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 { buildTurnCommandTable, evaluateReservedTurnPermission } from '../../turns/commandTable.js'; import { parseReservedTurnArgs, TURN_COMMAND_NATION_COLORS, @@ -28,6 +28,7 @@ import { shiftNationTurns, } from '../../turns/reservedTurns.js'; import { getOwnedGeneral } from '../shared/general.js'; +import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js'; const zPushAmount = z .number() @@ -80,6 +81,43 @@ const mutateReservedTurns = async (mutation: () => Promise): Promise => } }; +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 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 turnsRouter = router({ getCommandTable: authedProcedure .input( @@ -239,8 +277,10 @@ export const turnsRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - await getOwnedGeneral(ctx, input.generalId); + const general = await getOwnedGeneral(ctx, input.generalId); const args = await parseCommandArgs('general', input.action, input.args); + const worldState = await getReservationWorldState(ctx); + await assertReservedTurnPermission(worldState, general, 'general', input.action, args); const snapshot = await mutateReservedTurns(() => setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision) @@ -293,7 +333,7 @@ export const turnsRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - await getOwnedGeneral(ctx, input.generalId); + const general = await getOwnedGeneral(ctx, input.generalId); const updates = await Promise.all( input.entries.map(async (entry) => ({ turnIndices: expandGeneralTurnIndices(entry.turnList), @@ -301,6 +341,16 @@ export const turnsRouter = router({ args: await parseCommandArgs('general', entry.action, entry.args), })) ); + const worldState = await getReservationWorldState(ctx); + for (const update of updates) { + await assertReservedTurnPermission( + worldState, + general, + 'general', + update.action, + update.args + ); + } const snapshot = await mutateReservedTurns(() => setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision) ); @@ -335,6 +385,8 @@ export const turnsRouter = router({ }); } const args = await parseCommandArgs('nation', input.action, input.args); + const worldState = await getReservationWorldState(ctx); + await assertReservedTurnPermission(worldState, general, 'nation', input.action, args); const snapshot = await mutateReservedTurns(() => setNationTurn( @@ -451,6 +503,16 @@ export const turnsRouter = router({ args: await parseCommandArgs('nation', entry.action, entry.args), })) ); + const worldState = await getReservationWorldState(ctx); + for (const update of updates) { + await assertReservedTurnPermission( + worldState, + general, + 'nation', + update.action, + update.args + ); + } const snapshot = await mutateReservedTurns(() => setNationTurns( ctx.db, diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 8d3453b..1949382 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -2,6 +2,7 @@ import type { City, Constraint, ConstraintContext, + ConstraintResult, General, GeneralItemSlots, GeneralActionDefinition, @@ -225,6 +226,8 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record const scenarioMeta = asRecord(meta.scenarioMeta); const startYear = typeof scenarioMeta.startYear === 'number' ? scenarioMeta.startYear : undefined; const relYear = typeof startYear === 'number' ? worldState.currentYear - startYear : undefined; + const joinModeRaw = config.join_mode ?? config.joinMode; + const joinMode = typeof joinModeRaw === 'string' ? joinModeRaw : 'full'; return { currentYear: worldState.currentYear, @@ -233,18 +236,23 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record month: worldState.currentMonth, startYear, relYear, + join_mode: joinMode, openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0), }; }; -const mapGeneralRow = (row: GeneralRow): General => { +const mapGeneralRow = (row: GeneralRow, missingKillturnFallback?: number): General => { const legacySlots: GeneralItemSlots = { horse: normalizeCode(row.horseCode), weapon: normalizeCode(row.weaponCode), book: normalizeCode(row.bookCode), item: normalizeCode(row.itemCode), }; - const meta = ensureGeneralMeta(asTriggerRecord(row.meta), row.id); + const rawMeta = asTriggerRecord(row.meta); + const meta = + readMetaNumber(rawMeta, 'killturn') === null && missingKillturnFallback !== undefined + ? ({ ...rawMeta, killturn: missingKillturnFallback } as General['meta']) + : ensureGeneralMeta(rawMeta, row.id); const itemInventory = readItemInventoryFromMeta(meta, legacySlots); return { id: row.id, @@ -339,10 +347,16 @@ const buildStateView = ( general: General, city: City | null, nation: Nation | null, - generalList: General[] | null + generalList: General[] | null, + env: Record ): StateView => { const view = new MemoryStateView(); view.set({ kind: 'general', id: general.id }, general); + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + view.set({ kind: 'env', key }, value); + } + } if (city) { view.set({ kind: 'city', id: city.id }, city); } @@ -485,14 +499,15 @@ export const buildTurnCommandTable = async (options: { const city = options.city ? mapCityRow(options.city) : null; const nation = options.nation ? mapNationRow(options.nation) : null; const generalList = options.nationGenerals ? options.nationGenerals.map(mapGeneralRow) : null; - const view = buildStateView(general, city, nation, generalList); + const constraintEnv = buildConstraintEnv(options.worldState); + const view = buildStateView(general, city, nation, generalList, constraintEnv); const ctx: ConstraintContext = { actorId: general.id, cityId: city?.id, nationId: nation?.id, args: {}, - env: buildConstraintEnv(options.worldState), + env: constraintEnv, mode: 'precheck', }; @@ -516,3 +531,35 @@ export const buildTurnCommandTable = async (options: { }, }; }; + +export const evaluateReservedTurnPermission = async (options: { + worldState: WorldStateRow; + general: GeneralRow; + scope: 'general' | 'nation'; + action: string; + args: Record; +}): Promise => { + const specs = await loadTurnCommandSpecs(); + const spec = specs[options.scope].find((entry) => entry.key === options.action); + if (!spec) { + throw new Error(`Unknown ${options.scope} turn command: ${options.action}`); + } + + const definition = spec.createDefinition(buildCommandEnv(options.worldState)); + if (!definition.buildPermissionConstraints) { + return { kind: 'allow' }; + } + + const general = mapGeneralRow(options.general, 0); + const env = buildConstraintEnv(options.worldState); + const ctx: ConstraintContext = { + actorId: general.id, + cityId: general.cityId > 0 ? general.cityId : undefined, + nationId: general.nationId > 0 ? general.nationId : undefined, + args: options.args, + env, + mode: 'full', + }; + const view = buildStateView(general, null, null, null, env); + return evaluateConstraints(definition.buildPermissionConstraints(ctx, options.args), ctx, view); +}; diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 7c64112..2239ab7 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../src/context.js'; import { buildTurnCommandTable } from '../src/turns/commandTable.js'; -const buildWorldState = (): WorldStateRow => +const buildWorldState = (joinMode = 'full'): WorldStateRow => ({ id: 1, scenarioCode: 'default', @@ -11,6 +11,7 @@ const buildWorldState = (): WorldStateRow => currentMonth: 1, tickSeconds: 600, config: { + joinMode, const: { baseGold: 1000, baseRice: 1000, @@ -115,4 +116,24 @@ describe('buildTurnCommandTable', () => { expect(nationCommand?.possible).toBe(true); expect(nationCommand?.status).not.toBe('blocked'); }); + + it('provides join_mode to reservation availability constraints', async () => { + const table = await buildTurnCommandTable({ + worldState: buildWorldState('onlyRandom'), + general: buildGeneral(), + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + + const appointment = table.general + .flatMap((group) => group.values) + .find((command) => command.key === 'che_임관'); + + expect(appointment).toMatchObject({ + possible: false, + status: 'blocked', + reason: '랜덤 임관만 가능합니다', + }); + }); }); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index c30ea5f..ff00a58 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -23,6 +23,25 @@ const profile: GameProfile = { name: 'che:default', }; +const buildWorldState = (joinMode = 'full'): WorldStateRow => + ({ + id: 1, + scenarioCode: 'default', + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + config: { + joinMode, + const: {}, + }, + meta: { + scenarioMeta: { + startYear: 180, + }, + }, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }) as unknown as WorldStateRow; + const buildAuth = (sanctions: GameSessionTokenPayload['sanctions'] = {}): GameSessionTokenPayload => ({ version: 1, profile: profile.name, @@ -444,7 +463,9 @@ describe('appRouter', () => { 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 caller = appRouter.createCaller( + buildContext({ state: buildWorldState(), general, generalTurnWrites: writes }) + ); const response = await caller.turns.reserved.setGeneral({ generalId: 13, @@ -479,7 +500,9 @@ describe('appRouter', () => { it('applies legacy general sentinel bulk entries and repeats the leading pattern', async () => { const general = buildGeneralRow({ id: 16 }); const bulkWrites: unknown[] = []; - const bulkCaller = appRouter.createCaller(buildContext({ general, generalTurnWrites: bulkWrites })); + const bulkCaller = appRouter.createCaller( + buildContext({ state: buildWorldState(), general, generalTurnWrites: bulkWrites }) + ); const bulk = await bulkCaller.turns.reserved.setGeneralBulk({ generalId: general.id, @@ -538,7 +561,9 @@ describe('appRouter', () => { it('accepts the legacy nation amount-12 no-op without incrementing revision', async () => { const general = buildGeneralRow({ id: 17, nationId: 3, officerLevel: 12 }); const nationWrites: unknown[] = []; - const caller = appRouter.createCaller(buildContext({ general, nationTurnWrites: nationWrites })); + const caller = appRouter.createCaller( + buildContext({ state: buildWorldState(), general, nationTurnWrites: nationWrites }) + ); const shifted = await caller.turns.reserved.shiftNation({ generalId: general.id, @@ -559,7 +584,9 @@ describe('appRouter', () => { it('applies nation bulk entries sequentially so later entries overwrite overlaps', async () => { const general = buildGeneralRow({ id: 18, nationId: 3, officerLevel: 12 }); const nationWrites: unknown[] = []; - const caller = appRouter.createCaller(buildContext({ general, nationTurnWrites: nationWrites })); + const caller = appRouter.createCaller( + buildContext({ state: buildWorldState(), general, nationTurnWrites: nationWrites }) + ); const response = await caller.turns.reserved.setNationBulk({ generalId: general.id, @@ -583,6 +610,122 @@ describe('appRouter', () => { expect(nationWrites).toHaveLength(1); }); + it('enforces only legacy reservation permissions without applying full execution constraints', async () => { + const general = buildGeneralRow({ id: 19 }); + const allowedWrites: unknown[] = []; + const allowedCaller = appRouter.createCaller( + buildContext({ + state: buildWorldState('full'), + general, + generalTurnWrites: allowedWrites, + }) + ); + await expect( + allowedCaller.turns.reserved.setGeneral({ + generalId: general.id, + turnIndex: 0, + action: 'che_임관', + args: { destNationId: 2 }, + expectedRevision: 0, + }) + ).resolves.toMatchObject({ ok: true }); + expect(allowedWrites).toHaveLength(1); + + const randomOnlyWrites: unknown[] = []; + const randomOnlyCaller = appRouter.createCaller( + buildContext({ + state: buildWorldState('onlyRandom'), + general, + generalTurnWrites: randomOnlyWrites, + }) + ); + await expect( + randomOnlyCaller.turns.reserved.setGeneral({ + generalId: general.id, + turnIndex: 0, + action: 'che_임관', + args: { destNationId: 2 }, + expectedRevision: 0, + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: expect.stringContaining('랜덤 임관만 가능합니다'), + }); + expect(randomOnlyWrites).toHaveLength(0); + }); + + it('checks nation treaty-term reservation permission but not full diplomacy state', async () => { + const general = buildGeneralRow({ id: 20, nationId: 3, officerLevel: 12 }); + const shortTermWrites: unknown[] = []; + const shortTermCaller = appRouter.createCaller( + buildContext({ + state: buildWorldState(), + general, + nationTurnWrites: shortTermWrites, + }) + ); + await expect( + shortTermCaller.turns.reserved.setNation({ + generalId: general.id, + turnIndex: 0, + action: 'che_불가침제의', + args: { destNationId: 2, year: 190, month: 6 }, + expectedRevision: 0, + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: expect.stringContaining('기한은 6개월 이상'), + }); + expect(shortTermWrites).toHaveLength(0); + + const validTermWrites: unknown[] = []; + const validTermCaller = appRouter.createCaller( + buildContext({ + state: buildWorldState(), + general, + nationTurnWrites: validTermWrites, + }) + ); + await expect( + validTermCaller.turns.reserved.setNation({ + generalId: general.id, + turnIndex: 0, + action: 'che_불가침제의', + args: { destNationId: 2, year: 190, month: 7 }, + expectedRevision: 0, + }) + ).resolves.toMatchObject({ ok: true }); + expect(validTermWrites).toHaveLength(1); + }); + + it('validates every bulk reservation permission before writing any turn', async () => { + const general = buildGeneralRow({ id: 21 }); + const writes: unknown[] = []; + const caller = appRouter.createCaller( + buildContext({ + state: buildWorldState('onlyRandom'), + general, + generalTurnWrites: writes, + }) + ); + + await expect( + caller.turns.reserved.setGeneralBulk({ + generalId: general.id, + entries: [ + { turnList: [0], action: 'che_훈련' }, + { + turnList: [1], + action: 'che_임관', + args: { destNationId: 2 }, + }, + ], + expectedRevision: 0, + }) + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + expect(writes).toHaveLength(0); + }); + it('rejects malformed and cross-scope arguments without writing turns', async () => { const general = buildGeneralRow({ id: 14, nationId: 3, officerLevel: 5 }); const generalWrites: unknown[] = []; diff --git a/packages/logic/src/actions/definition.ts b/packages/logic/src/actions/definition.ts index f228a0d..582bc27 100644 --- a/packages/logic/src/actions/definition.ts +++ b/packages/logic/src/actions/definition.ts @@ -7,10 +7,12 @@ export interface GeneralActionDefinition< Args = unknown, Context extends GeneralActionResolveContext = GeneralActionResolveContext, > { - // TODO: legacy permissionConstraints(예약 권한) 모델링 필요. key: string; name: string; parseArgs(raw: unknown): Args | null; + // 레거시 testPermissionToReserve()와 같은 예약 입력 전용 제약이다. + // 정의하지 않은 명령은 레거시처럼 인자 검증 외 상태 제약 없이 예약한다. + buildPermissionConstraints?(ctx: ConstraintContext, args: Args): Constraint[]; // 커맨드 입력 단계에서 최소 조건만 평가할 때 사용한다. buildMinConstraints?(ctx: ConstraintContext, args: Args): Constraint[]; buildConstraints(ctx: ConstraintContext, args: Args): Constraint[]; diff --git a/packages/logic/src/actions/turn/general/che_NPC능동.ts b/packages/logic/src/actions/turn/general/che_NPC능동.ts index 061e97f..7844b5b 100644 --- a/packages/logic/src/actions/turn/general/che_NPC능동.ts +++ b/packages/logic/src/actions/turn/general/che_NPC능동.ts @@ -15,6 +15,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import type { GeneralTurnCommandSpec } from './index.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { mustBeNPC } from '@sammo-ts/logic/constraints/presets.js'; export type NPCSelfResolveContext = GeneralActionResolveContext & { @@ -99,6 +100,10 @@ export class ActionDefinition< return parseArgsWithSchema(ARGS_SCHEMA, raw); } + buildPermissionConstraints(_ctx: ConstraintContext, _args: NPCSelfArgs): Constraint[] { + return [mustBeNPC()]; + } + buildConstraints(_ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] { void _ctx; void args; diff --git a/packages/logic/src/actions/turn/general/che_등용.ts b/packages/logic/src/actions/turn/general/che_등용.ts index fa79526..7a7069f 100644 --- a/packages/logic/src/actions/turn/general/che_등용.ts +++ b/packages/logic/src/actions/turn/general/che_등용.ts @@ -161,6 +161,10 @@ export class ActionDefinition< return parseArgsWithSchema(ARGS_SCHEMA, raw); } + buildPermissionConstraints(_ctx: ConstraintContext, _args: EmployArgs): Constraint[] { + return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다')]; + } + buildMinConstraints(_ctx: ConstraintContext, _args: EmployArgs): Constraint[] { return [ reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index 9f40fbb..e74e67f 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -9,6 +9,7 @@ import { reqGeneralValue, reqEnvValue, readMetaNumberFromUnknown, + alwaysFail, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { @@ -313,6 +314,10 @@ export class ActionDefinition< return parseArgsWithSchema(ARGS_SCHEMA, raw); } + buildPermissionConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] { + return [alwaysFail('예약 불가능 커맨드')]; + } + buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] { const env = _ctx.env; const year = typeof env.year === 'number' ? env.year : 0; diff --git a/packages/logic/src/actions/turn/general/che_임관.ts b/packages/logic/src/actions/turn/general/che_임관.ts index 6c4e98c..a92640f 100644 --- a/packages/logic/src/actions/turn/general/che_임관.ts +++ b/packages/logic/src/actions/turn/general/che_임관.ts @@ -53,6 +53,10 @@ export class ActionDefinition< return parseArgsWithSchema(ARGS_SCHEMA, raw); } + buildPermissionConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] { + return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다')]; + } + buildMinConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] { return [ reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), diff --git a/packages/logic/src/actions/turn/general/che_장수대상임관.ts b/packages/logic/src/actions/turn/general/che_장수대상임관.ts index 651f3d1..956e48c 100644 --- a/packages/logic/src/actions/turn/general/che_장수대상임관.ts +++ b/packages/logic/src/actions/turn/general/che_장수대상임관.ts @@ -138,6 +138,10 @@ export class ActionDefinition< return parseArgsWithSchema(ARGS_SCHEMA, raw); } + buildPermissionConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] { + return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다')]; + } + buildMinConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] { return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), beNeutral(), allowJoinAction()]; } diff --git a/packages/logic/src/actions/turn/nation/che_불가침제의.ts b/packages/logic/src/actions/turn/nation/che_불가침제의.ts index de9f554..39f8e89 100644 --- a/packages/logic/src/actions/turn/nation/che_불가침제의.ts +++ b/packages/logic/src/actions/turn/nation/che_불가침제의.ts @@ -116,6 +116,10 @@ export class ActionDefinition< return parseArgsWithSchema(ARGS_SCHEMA, raw); } + buildPermissionConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] { + return [reqMinimumTreatyTerm(MIN_TERM_MONTHS)]; + } + buildMinConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] { return [beChief(), notBeNeutral()]; } diff --git a/packages/logic/src/constraints/general.ts b/packages/logic/src/constraints/general.ts index 51f1268..4240861 100644 --- a/packages/logic/src/constraints/general.ts +++ b/packages/logic/src/constraints/general.ts @@ -596,13 +596,10 @@ export const mustBeNPC = (): Constraint => ({ if (!general) { return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); } - // Assuming npcState >= 2 means NPC. Need to verify exact logic if possible, - // but typically 0=human, 1=?, 2=NPC. - // Legacy: $general->getNPC() where 0:User, 1:Virtual User(unused?), 2:NPC ... if (general.npcState >= 2) { return allow(); } - return { kind: 'deny', reason: 'NPC가 아닙니다.' }; + return { kind: 'deny', reason: 'NPC여야 합니다.' }; }, }); diff --git a/packages/logic/test/actions/turn/reservationPermission.test.ts b/packages/logic/test/actions/turn/reservationPermission.test.ts new file mode 100644 index 0000000..f23d2b0 --- /dev/null +++ b/packages/logic/test/actions/turn/reservationPermission.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; + +import { evaluateConstraints } from '../../../src/constraints/evaluate.js'; +import type { Constraint, ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js'; +import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js'; +import { ActionDefinition as EmployAction } from '../../../src/actions/turn/general/che_등용.js'; +import { ActionDefinition as AcceptScoutAction } from '../../../src/actions/turn/general/che_등용수락.js'; +import { ActionDefinition as AppointmentAction } from '../../../src/actions/turn/general/che_임관.js'; +import { ActionDefinition as FollowAppointmentAction } from '../../../src/actions/turn/general/che_장수대상임관.js'; +import { ActionDefinition as NpcSelfAction } from '../../../src/actions/turn/general/che_NPC능동.js'; +import { ActionDefinition as NonAggressionProposalAction } from '../../../src/actions/turn/nation/che_불가침제의.js'; + +const commandEnv: TurnCommandEnv = { + develCost: 100, + trainDelta: 35, + atmosDelta: 35, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + sabotageDefaultProb: 0.5, + sabotageProbCoefByStat: 0.1, + sabotageDefenceCoefByGeneralCount: 0.1, + sabotageDamageMin: 10, + sabotageDamageMax: 30, + openingPartYear: 0, + maxGeneral: 10, + defaultNpcGold: 1000, + defaultNpcRice: 1000, + defaultCrewTypeId: 1100, + defaultSpecialDomestic: null, + defaultSpecialWar: null, + initialNationGenLimit: 10, + maxTechLevel: 10, + baseGold: 1000, + baseRice: 1000, + maxResourceActionAmount: 1000, +}; + +class PermissionStateView implements StateView { + constructor( + private readonly actor: { npcState: number }, + private readonly env: Record + ) {} + + has(req: RequirementKey): boolean { + if (req.kind === 'general') { + return req.id === 1; + } + if (req.kind === 'env') { + return Object.hasOwn(this.env, req.key); + } + return false; + } + + get(req: RequirementKey): unknown | null { + if (req.kind === 'general' && req.id === 1) { + return this.actor; + } + if (req.kind === 'env') { + return this.env[req.key] ?? null; + } + return null; + } +} + +const evaluatePermission = ( + constraints: Constraint[], + args: Record, + options?: { joinMode?: string; npcState?: number } +) => { + const env = { + join_mode: options?.joinMode ?? 'full', + year: 190, + month: 1, + startYear: 180, + }; + const ctx: ConstraintContext = { + actorId: 1, + args, + env, + mode: 'full', + }; + return evaluateConstraints(constraints, ctx, new PermissionStateView({ npcState: options?.npcState ?? 0 }, env)); +}; + +const permissionContext: ConstraintContext = { + actorId: 1, + args: {}, + env: {}, + mode: 'full', +}; + +describe('legacy reservation permission constraints', () => { + it('applies only join_mode to the three reservable join commands', () => { + const cases = [ + { + constraints: new EmployAction(commandEnv).buildPermissionConstraints(permissionContext, { + destGeneralId: 2, + }), + args: { destGeneralId: 2 }, + }, + { + constraints: new AppointmentAction(commandEnv).buildPermissionConstraints(permissionContext, { + destNationId: 2, + }), + args: { destNationId: 2 }, + }, + { + constraints: new FollowAppointmentAction().buildPermissionConstraints(permissionContext, { + destGeneralID: 2, + }), + args: { destGeneralID: 2 }, + }, + ]; + + for (const { constraints, args } of cases) { + const randomOnly = evaluatePermission(constraints, args, { joinMode: 'onlyRandom' }); + expect(randomOnly).toMatchObject({ kind: 'deny', reason: '랜덤 임관만 가능합니다' }); + + const full = evaluatePermission(constraints, args); + expect(full).toEqual({ kind: 'allow' }); + } + }); + + it('blocks scout acceptance reservations and restricts NPC self-action to NPC actors', () => { + const accept = new AcceptScoutAction(commandEnv); + const acceptArgs = { destNationId: 2, destGeneralId: 7 }; + expect( + evaluatePermission(accept.buildPermissionConstraints(permissionContext, acceptArgs), acceptArgs) + ).toMatchObject({ + kind: 'deny', + reason: '예약 불가능 커맨드', + }); + + const npcAction = new NpcSelfAction(); + const npcArgs = { optionText: '순간이동' as const, destCityId: 2 }; + expect( + evaluatePermission(npcAction.buildPermissionConstraints(permissionContext, npcArgs), npcArgs) + ).toMatchObject({ + kind: 'deny', + reason: 'NPC여야 합니다.', + }); + expect( + evaluatePermission(npcAction.buildPermissionConstraints(permissionContext, npcArgs), npcArgs, { + npcState: 2, + }) + ).toEqual({ kind: 'allow' }); + }); + + it('checks only the six-month minimum when reserving a non-aggression proposal', () => { + const definition = new NonAggressionProposalAction(); + const shortArgs = { destNationId: 2, year: 190, month: 6 }; + expect( + evaluatePermission(definition.buildPermissionConstraints(permissionContext, shortArgs), shortArgs) + ).toMatchObject({ + kind: 'deny', + reason: '기한은 6개월 이상이어야 합니다.', + }); + + const validArgs = { destNationId: 2, year: 190, month: 7 }; + expect( + evaluatePermission(definition.buildPermissionConstraints(permissionContext, validArgs), validArgs) + ).toEqual({ + kind: 'allow', + }); + }); +});