diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index e0bffabe..69d3491a 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -80,6 +80,7 @@ export interface TurnCommandInputField { min?: number; max?: number; step?: number; + defaultValue?: TurnCommandOptionValue | boolean; constValue?: TurnCommandOptionValue; options?: TurnCommandOption[]; optionSource?: TurnCommandOptionSource; diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 4c27abb6..fc419b21 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -18,7 +18,7 @@ import type { TriggerValue, UnitSetDefinition, } from '@sammo-ts/logic'; -import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic'; +import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL, resolveNonAggressionMaxEndYear } from '@sammo-ts/logic'; import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js'; import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; @@ -656,18 +656,33 @@ const FOUNDING_COMMAND_KEYS = new Set(['che_건국', 'cr_건국', 'che_무작위 const buildEntries = ( env: CommandEnv, specs: TurnCommandSpec[], - options: { foundingAvailable?: boolean } = {} + options: { foundingAvailable?: boolean; currentYear?: number; currentMonth?: number } = {} ): CommandEntry[] => { const entries: CommandEntry[] = []; for (const spec of specs) { const definition = spec.createDefinition(env); + const inputFields = buildTurnCommandInputFields(spec).map((field) => { + if (spec.key !== 'che_불가침제의') return field; + if (field.key === 'year' && options.currentYear !== undefined) { + return { + ...field, + min: options.currentYear + 1, + max: resolveNonAggressionMaxEndYear(options.currentYear), + defaultValue: options.currentYear + 1, + }; + } + if (field.key === 'month' && options.currentMonth !== undefined) { + return { ...field, defaultValue: options.currentMonth }; + } + return field; + }); const entry: CommandEntry = { category: spec.category, definition, reqArg: spec.reqArg, availabilityArgs: spec.reqArg ? spec.availabilityArgs : {}, - inputFields: buildTurnCommandInputFields(spec), + inputFields, }; if (spec.key === 'che_포상') { @@ -817,7 +832,10 @@ export const buildTurnCommandTable = async (options: { ? undefined : options.realNationCount < resolveMaxNation(options.worldState), }); - const nationEntries = buildEntries(env, nationSpecs); + const nationEntries = buildEntries(env, nationSpecs, { + currentYear: options.worldState.currentYear, + currentMonth: options.worldState.currentMonth, + }); return { general: buildGroups( diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index c26aa5d9..0236c799 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -337,6 +337,27 @@ describe('buildTurnCommandTable', () => { } }); + it('projects the Ref twenty-year non-aggression end-year window', async () => { + const table = await buildTurnCommandTable({ + worldState: buildWorldState(), + general: buildGeneral(), + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + const proposal = table.nation + .flatMap((group) => group.values) + .find((command) => command.key === 'che_불가침제의'); + + expect(proposal?.inputFields.find((field) => field.key === 'year')).toMatchObject({ + kind: 'number', + min: 4, + max: 23, + defaultValue: 4, + }); + expect(proposal?.inputFields.find((field) => field.key === 'month')).toMatchObject({ defaultValue: 1 }); + }); + it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => { const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) => buildTurnCommandTable({ diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 02d5df3d..93f9761d 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -1462,6 +1462,28 @@ describe('appRouter', () => { }) ).resolves.toMatchObject({ ok: true }); expect(validTermWrites).toHaveLength(1); + + const excessiveTermWrites: unknown[] = []; + const excessiveTermCaller = appRouter.createCaller( + buildContext({ + state: buildWorldState(), + general, + nationTurnWrites: excessiveTermWrites, + }) + ); + await expect( + excessiveTermCaller.turns.reserved.setNation({ + generalId: general.id, + turnIndex: 0, + action: 'che_불가침제의', + args: { destNationId: 2, year: 211, month: 1 }, + expectedRevision: 0, + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: expect.stringContaining('기한은 210년 이하'), + }); + expect(excessiveTermWrites).toHaveLength(0); }); it('validates every bulk reservation permission before writing any turn', async () => { diff --git a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts index 4de18ed5..bb430298 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts @@ -1,6 +1,6 @@ import type { GeneralAI } from '../core.js'; import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.js'; -import { resolveDiplomacyMessageValidUntilTick } from '@sammo-ts/logic'; +import { resolveDiplomacyMessageValidUntilTick, resolveNonAggressionMaxEndYear } from '@sammo-ts/logic'; import { isNeighbor } from '@sammo-ts/logic/world/distance.js'; import { resolveNationIncome } from './helpers.js'; @@ -99,7 +99,8 @@ export const do불가침제의 = (ai: GeneralAI) => { return null; } - const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth)); + const maxEndMonth = joinYearMonth(resolveNonAggressionMaxEndYear(ai.world.currentYear), 12); + const [targetYear, targetMonth] = parseYearMonth(Math.min(Math.floor(yearMonth + diplomatMonth), maxEndMonth)); const result = ai.buildNationCandidate( 'che_불가침제의', { destNationId, year: targetYear, month: targetMonth }, diff --git a/app/game-engine/test/npcAidNonAggressionLifecycle.test.ts b/app/game-engine/test/npcAidNonAggressionLifecycle.test.ts index 232f269a..6417dc2e 100644 --- a/app/game-engine/test/npcAidNonAggressionLifecycle.test.ts +++ b/app/game-engine/test/npcAidNonAggressionLifecycle.test.ts @@ -144,6 +144,7 @@ describe('NPC 원조 기반 불가침 제의 lifecycle', () => { .peekDirtyState() .messages.filter((message) => message.msgType === 'diplomacy' && message.option?.action === 'noAggression'); expect(firstMessages).toHaveLength(1); + expect(firstMessages[0]?.option).toMatchObject({ year: 210, month: 12 }); const tryEntry = (world.getNationById(1)!.meta.resp_assist_try as Record).n2!; expect(tryEntry).toHaveLength(3); const validUntilTick = tryEntry[2]!; diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 5746fcd1..2b50b2d5 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -504,7 +504,32 @@ const commandTable = { }, ], }, - buildNationCommand('che_불가침제의', '불가침 제의'), + { + ...buildNationCommand('che_불가침제의', '불가침 제의'), + inputFields: [ + ...buildNationCommand('che_불가침제의', '불가침 제의').inputFields, + { + key: 'year', + label: '기간(년)', + kind: 'number', + required: true, + min: 191, + max: 210, + step: 1, + defaultValue: 191, + }, + { + key: 'month', + label: '기간(월)', + kind: 'number', + required: true, + min: 1, + max: 12, + step: 1, + defaultValue: 1, + }, + ], + }, buildNationCommand('che_선전포고', '선전포고'), buildNationCommand('che_종전제의', '종전 제의'), buildNationCommand('che_불가침파기제의', '불가침 파기 제의'), @@ -2158,6 +2183,42 @@ test('touch command maps select city and nation on the first tap without changin } }); +test('limits non-aggression end years to the Ref twenty-year window on desktop and mobile', async ({ + browser, +}, testInfo) => { + for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + try { + await install(page); + await page.goto('/che/chief-center'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click(); + await picker.getByRole('button', { name: /불가침 제의/ }).click(); + + const form = picker.getByTestId('command-argument-form'); + const yearInput = form.locator('#command-arg-year'); + await expect(yearInput).toHaveAttribute('min', '191'); + await expect(yearInput).toHaveAttribute('max', '210'); + await expect(yearInput).toHaveValue('191'); + + const submit = picker.getByRole('button', { name: '입력', exact: true }); + await yearInput.fill('211'); + await expect(submit).toBeDisabled(); + await yearInput.fill('210'); + await expect(submit).toBeEnabled(); + + await picker.screenshot({ path: testInfo.outputPath(`non-aggression-year-limit-${viewport.name}.png`) }); + } finally { + await context.close(); + } + } +}); + test('shows a map and target details for every city or nation argument chief command except assignment', async ({ page, }) => { @@ -2544,7 +2605,9 @@ test('keeps the entered command visible and reports a server validation error', await expect(page.getByRole('alert')).toContainText('대상 도시를 선택할 수 없습니다.'); await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2'); await expect(submit).toBeEnabled(); - await expect(page.getByTestId('command-picker').getByRole('button', { name: '저장 중', exact: true })).toHaveCount(0); + await expect(page.getByTestId('command-picker').getByRole('button', { name: '저장 중', exact: true })).toHaveCount( + 0 + ); }); test('keeps Ref command briefs and autonomous-action state after a turn mutation', async ({ page, context }) => { diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index 2f6615f5..343a09fb 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -54,6 +54,7 @@ export type CommandInputField = { min?: number; max?: number; step?: number; + defaultValue?: string | number | boolean; constValue?: string | number; options?: CommandOption[]; optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items'; diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index b1a3e97b..8d041b5f 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -78,6 +78,7 @@ const optionsFor = (field: CommandInputField): CommandOption[] => { const defaultValue = (field: CommandInputField): unknown => { if (field.kind === 'hidden') return field.constValue; + if (field.defaultValue !== undefined) return field.defaultValue; if (field.kind === 'boolean') return true; if (field.kind === 'numberTuple') { const value = amountPreset.value?.defaultValue ?? field.min ?? 0; diff --git a/packages/logic/src/actions/turn/nation/che_불가침제의.ts b/packages/logic/src/actions/turn/nation/che_불가침제의.ts index 2edd4221..af25b50d 100644 --- a/packages/logic/src/actions/turn/nation/che_불가침제의.ts +++ b/packages/logic/src/actions/turn/nation/che_불가침제의.ts @@ -19,6 +19,7 @@ import type { NationTurnCommandSpec } from './index.js'; import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; import { resolveDiplomacyMessageValidMinutes } from '../../../diplomacy/messageValidity.js'; +import { NON_AGGRESSION_MIN_TERM_MONTHS, resolveNonAggressionMaxEndYear } from '../../../diplomacy/treatyTerm.js'; import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js'; const ARGS_SCHEMA = z.object({ @@ -38,12 +39,11 @@ interface NonAggressionProposalContext< } const ACTION_NAME = '불가침 제의'; -const MIN_TERM_MONTHS = 6; const resolveMonthIndex = (year: number, month: number): number => year * 12 + month - 1; -const reqMinimumTreatyTerm = (minMonths: number): Constraint => ({ - name: 'reqMinimumTreatyTerm', +const reqTreatyTermRange = (minMonths: number): Constraint => ({ + name: 'reqTreatyTermRange', requires: () => [ { kind: 'arg', key: 'year' }, { kind: 'arg', key: 'month' }, @@ -100,6 +100,13 @@ const reqMinimumTreatyTerm = (minMonths: number): Constraint => ({ reason: `기한은 ${minMonths}개월 이상이어야 합니다.`, }; } + const maxEndYear = resolveNonAggressionMaxEndYear(envYearValue); + if (yearValue > maxEndYear) { + return { + kind: 'deny', + reason: `기한은 ${maxEndYear}년 이하여야 합니다.`, + }; + } return allow(); }, }); @@ -120,7 +127,7 @@ export class ActionDefinition< } buildPermissionConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] { - return [reqMinimumTreatyTerm(MIN_TERM_MONTHS)]; + return [reqTreatyTermRange(NON_AGGRESSION_MIN_TERM_MONTHS)]; } buildMinConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] { @@ -133,7 +140,7 @@ export class ActionDefinition< notBeNeutral(), existsDestNation(), differentDestNation(), - reqMinimumTreatyTerm(MIN_TERM_MONTHS), + reqTreatyTermRange(NON_AGGRESSION_MIN_TERM_MONTHS), disallowDiplomacyBetweenStatus({ 0: '아국과 이미 교전중입니다.', 1: '아국과 이미 선포중입니다.', diff --git a/packages/logic/src/diplomacy/index.ts b/packages/logic/src/diplomacy/index.ts index 668f91d6..ab928b2b 100644 --- a/packages/logic/src/diplomacy/index.ts +++ b/packages/logic/src/diplomacy/index.ts @@ -149,3 +149,4 @@ export const processDiplomacyMonth = ( export * from './frontState.js'; export * from './instantResponse.js'; export * from './messageValidity.js'; +export * from './treatyTerm.js'; diff --git a/packages/logic/src/diplomacy/treatyTerm.ts b/packages/logic/src/diplomacy/treatyTerm.ts new file mode 100644 index 00000000..d96ec85d --- /dev/null +++ b/packages/logic/src/diplomacy/treatyTerm.ts @@ -0,0 +1,5 @@ +export const NON_AGGRESSION_MIN_TERM_MONTHS = 6; +export const NON_AGGRESSION_MAX_END_YEAR_OFFSET = 20; + +export const resolveNonAggressionMaxEndYear = (currentYear: number): number => + currentYear + NON_AGGRESSION_MAX_END_YEAR_OFFSET; diff --git a/packages/logic/test/actions/turn/reservationPermission.test.ts b/packages/logic/test/actions/turn/reservationPermission.test.ts index f23d2b04..b8b5dfb6 100644 --- a/packages/logic/test/actions/turn/reservationPermission.test.ts +++ b/packages/logic/test/actions/turn/reservationPermission.test.ts @@ -146,7 +146,7 @@ describe('legacy reservation permission constraints', () => { ).toEqual({ kind: 'allow' }); }); - it('checks only the six-month minimum when reserving a non-aggression proposal', () => { + it('keeps non-aggression proposals between the six-month minimum and Ref twenty-year limit', () => { const definition = new NonAggressionProposalAction(); const shortArgs = { destNationId: 2, year: 190, month: 6 }; expect( @@ -162,5 +162,23 @@ describe('legacy reservation permission constraints', () => { ).toEqual({ kind: 'allow', }); + + const maxTermArgs = { destNationId: 2, year: 210, month: 12 }; + expect( + evaluatePermission(definition.buildPermissionConstraints(permissionContext, maxTermArgs), maxTermArgs) + ).toEqual({ + kind: 'allow', + }); + + const excessiveTermArgs = { destNationId: 2, year: 211, month: 1 }; + expect( + evaluatePermission( + definition.buildPermissionConstraints(permissionContext, excessiveTermArgs), + excessiveTermArgs + ) + ).toMatchObject({ + kind: 'deny', + reason: '기한은 210년 이하여야 합니다.', + }); }); });