fix: 불가침 제의 기한을 20년으로 제한

This commit is contained in:
2026-08-30 07:24:33 +00:00
parent d68209879b
commit 24e57ee94c
13 changed files with 174 additions and 14 deletions
+1
View File
@@ -80,6 +80,7 @@ export interface TurnCommandInputField {
min?: number;
max?: number;
step?: number;
defaultValue?: TurnCommandOptionValue | boolean;
constValue?: TurnCommandOptionValue;
options?: TurnCommandOption[];
optionSource?: TurnCommandOptionSource;
+22 -4
View File
@@ -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(
+21
View File
@@ -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({
+22
View File
@@ -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 () => {
@@ -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 },
@@ -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<string, number[]>).n2!;
expect(tryEntry).toHaveLength(3);
const validUntilTick = tryEntry[2]!;
+65 -2
View File
@@ -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 }) => {
@@ -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';
@@ -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;