diff --git a/packages/logic/src/actions/turn/nation/che_불가침제의.ts b/packages/logic/src/actions/turn/nation/che_불가침제의.ts index 0a0ce08..de9f554 100644 --- a/packages/logic/src/actions/turn/nation/che_불가침제의.ts +++ b/packages/logic/src/actions/turn/nation/che_불가침제의.ts @@ -20,15 +20,9 @@ import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; const ARGS_SCHEMA = z.object({ - destNationId: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().positive() - ), - year: z.preprocess((value) => (typeof value === 'number' ? Math.floor(value) : value), z.number().int().min(0)), - month: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().min(1).max(12) - ), + destNationId: z.number().int().positive(), + year: z.number().int().min(0), + month: z.number().int().min(1).max(12), }); export type NonAggressionProposalArgs = z.infer; @@ -52,12 +46,14 @@ const reqMinimumTreatyTerm = (minMonths: number): Constraint => ({ { kind: 'arg', key: 'month' }, { kind: 'env', key: 'year' }, { kind: 'env', key: 'month' }, + { kind: 'env', key: 'startYear' }, ], test: (ctx) => { const yearValue = typeof ctx.args.year === 'number' ? ctx.args.year : null; const monthValue = typeof ctx.args.month === 'number' ? ctx.args.month : null; const envYearValue = typeof ctx.env.year === 'number' ? ctx.env.year : null; const envMonthValue = typeof ctx.env.month === 'number' ? ctx.env.month : null; + const startYearValue = typeof ctx.env.startYear === 'number' ? ctx.env.startYear : null; const missing = []; if (yearValue === null) { @@ -72,17 +68,27 @@ const reqMinimumTreatyTerm = (minMonths: number): Constraint => ({ if (envMonthValue === null) { missing.push({ kind: 'env', key: 'month' } as const); } + if (startYearValue === null) { + missing.push({ kind: 'env', key: 'startYear' } as const); + } if ( missing.length > 0 || yearValue === null || monthValue === null || envYearValue === null || - envMonthValue === null + envMonthValue === null || + startYearValue === null ) { return unknownOrDeny(ctx, missing, '기한 정보가 없습니다.'); } + if (yearValue < startYearValue) { + return { + kind: 'deny', + reason: '시작 연도보다 이전의 기한은 지정할 수 없습니다.', + }; + } const currentMonth = resolveMonthIndex(envYearValue, envMonthValue); const targetMonth = resolveMonthIndex(yearValue, monthValue); if (targetMonth < currentMonth + minMonths) { @@ -137,7 +143,7 @@ export class ActionDefinition< return { effects: [createLogEffect('국가 정보가 없습니다.')] }; } const destNationName = destNation.name; - const josaRo = JosaUtil.pick(destNationName, '로'); + const josaRo = JosaUtil.pick(nation.name, '로'); const josaWa = JosaUtil.pick(nation.name, '와'); const validUntil = new Date(context.messageTime.getTime() + context.messageValidMinutes * 60_000); return { diff --git a/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts b/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts index 9df10dc..caabf84 100644 --- a/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts +++ b/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts @@ -20,10 +20,7 @@ import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; const ARGS_SCHEMA = z.object({ - destNationId: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().positive() - ), + destNationId: z.number().int().positive(), }); export type NonAggressionCancelProposalArgs = z.infer; diff --git a/packages/logic/src/actions/turn/nation/che_선전포고.ts b/packages/logic/src/actions/turn/nation/che_선전포고.ts index d1c994d..768097a 100644 --- a/packages/logic/src/actions/turn/nation/che_선전포고.ts +++ b/packages/logic/src/actions/turn/nation/che_선전포고.ts @@ -15,7 +15,7 @@ import type { GeneralActionOutcome, GeneralActionResolveContext, } from '@sammo-ts/logic/actions/engine.js'; -import { createDiplomacyPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { createDiplomacyPatchEffect, createLogEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; @@ -25,14 +25,18 @@ import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; const ARGS_SCHEMA = z.object({ - destNationId: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().positive() - ), + destNationId: z.number().int().positive(), }); export type DeclareWarArgs = z.infer; -// DeclareWarResolveContext is not used anymore as it was replaced by inline type in ActionDefinition +interface DeclareWarResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destNation: Nation; + currentYear: number; + currentMonth: number; + messageTime: Date; +} const ACTION_NAME = '선전포고'; // legacy 규칙: 선전포고 상태는 24턴 유지. @@ -41,11 +45,7 @@ const DECLARE_TERM = 24; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition< - TriggerState, - DeclareWarArgs, - GeneralActionResolveContext & { destNation: Nation } -> { +> implements GeneralActionDefinition> { public readonly key = 'che_선전포고'; public readonly name = ACTION_NAME; @@ -98,10 +98,7 @@ export class ActionDefinition< ]; } - resolve( - context: GeneralActionResolveContext & { destNation: Nation }, - args: DeclareWarArgs - ): GeneralActionOutcome { + resolve(context: DeclareWarResolveContext, args: DeclareWarArgs): GeneralActionOutcome { const nationId = context.nation?.id; if (nationId === undefined || nationId <= 0 || !context.destNation) { return { @@ -157,27 +154,46 @@ export class ActionDefinition< format: LogFormat.YEAR_MONTH, }), // Global Action Log - createLogEffect(`${generalName}${josaYiGeneral} ${destNationName}선전 포고 하였습니다.`, { - scope: LogScope.SYSTEM, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - }), - // Global History Log - createLogEffect(`【선포】${nationName}${josaYiNation} ${destNationName}에 선전 포고 하였습니다.`, { - scope: LogScope.SYSTEM, - category: LogCategory.HISTORY, - format: LogFormat.YEAR_MONTH, - }), - // National Message (국메) createLogEffect( - `【국메】${generalName}${josaYiGeneral} ${destNationName}에게 ${ACTION_NAME}하였습니다!`, + `${generalName}${josaYiGeneral} ${destNationName}선전 포고 하였습니다.`, { - scope: LogScope.NATION, - nationId: nationId, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, } ), + // Global History Log + createLogEffect( + `【선포】${nationName}${josaYiNation} ${destNationName}에 선전 포고 하였습니다.`, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ), + createMessageEffect({ + msgType: 'national', + src: { + generalId: context.general.id, + generalName, + nationId, + nationName, + color: context.nation?.color ?? '', + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: args.destNationId, + nationName: destNationName, + color: context.destNation.color, + icon: '', + }, + text: `【외교】${context.currentYear}년 ${context.currentMonth}월:${nationName}에서 ${destNationName}에 선전포고`, + time: context.messageTime, + validUntil: new Date('9999-12-31T00:00:00.000Z'), + option: {}, + }), ]; return { effects }; @@ -201,6 +217,9 @@ export const actionContextBuilder: ActionContextBuilder = (base, return { ...base, destNation, + currentYear: options.world.currentYear, + currentMonth: options.world.currentMonth, + messageTime: base.general.turnTime, }; }; diff --git a/packages/logic/src/actions/turn/nation/che_종전제의.ts b/packages/logic/src/actions/turn/nation/che_종전제의.ts index aae3df0..1cf4d4d 100644 --- a/packages/logic/src/actions/turn/nation/che_종전제의.ts +++ b/packages/logic/src/actions/turn/nation/che_종전제의.ts @@ -20,10 +20,7 @@ import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; const ARGS_SCHEMA = z.object({ - destNationId: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().positive() - ), + destNationId: z.number().int().positive(), }); export type StopWarProposalArgs = z.infer; diff --git a/packages/logic/test/actions/turn/nation.test.ts b/packages/logic/test/actions/turn/nation.test.ts index c0483c6..471feca 100644 --- a/packages/logic/test/actions/turn/nation.test.ts +++ b/packages/logic/test/actions/turn/nation.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest'; import type { City, General, Nation } from '../../../src/domain/entities.js'; import { resolveGeneralAction } from '../../../src/actions/engine.js'; import { ActionDefinition as DeclareWarAction } from '../../../src/actions/turn/nation/che_선전포고.js'; +import { ActionDefinition as NonAggressionProposalAction } from '../../../src/actions/turn/nation/che_불가침제의.js'; +import { ActionDefinition as StopWarProposalAction } from '../../../src/actions/turn/nation/che_종전제의.js'; +import { ActionDefinition as NonAggressionCancelProposalAction } from '../../../src/actions/turn/nation/che_불가침파기제의.js'; import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn/nation/che_천도.js'; import { ActionDefinition as ChangeNationNameAction, @@ -125,6 +128,9 @@ describe('Nation Actions', () => { destNation: nation2, cities: [city1, city2], nations: [nation1, nation2], + currentYear: 190, + currentMonth: 1, + messageTime: new Date('2026-01-01T00:00:00Z'), rng: {} as any, addLog: () => {}, }; @@ -144,10 +150,19 @@ describe('Nation Actions', () => { patch: expect.objectContaining({ state: 1 }), }) ); - expect(resolution.logs.some((l) => l.scope === LogScope.SYSTEM && l.category === LogCategory.ACTION)).toBe( + expect(resolution.logs.some((l) => l.scope === LogScope.SYSTEM && l.category === LogCategory.SUMMARY)).toBe( true ); - expect(resolution.logs.some((l) => l.text.includes('【국메】'))).toBe(true); + expect(resolution.effects).toContainEqual( + expect.objectContaining({ + type: 'message:add', + draft: expect.objectContaining({ + msgType: 'national', + text: '【외교】190년 1월:Nation1에서 Nation2에 선전포고', + option: {}, + }), + }) + ); }); it('fails if not neighbor', () => { @@ -234,6 +249,23 @@ describe('Nation Actions', () => { }); }); + describe('diplomacy proposal argument boundaries', () => { + it.each([ + ['che_선전포고', new DeclareWarAction(), { destNationId: 2.9 }], + [ + 'che_불가침제의 destination', + new NonAggressionProposalAction(), + { destNationId: 2.9, year: 190, month: 7 }, + ], + ['che_불가침제의 year', new NonAggressionProposalAction(), { destNationId: 2, year: 190.9, month: 7 }], + ['che_불가침제의 month', new NonAggressionProposalAction(), { destNationId: 2, year: 190, month: 7.9 }], + ['che_종전제의', new StopWarProposalAction(), { destNationId: 2.9 }], + ['che_불가침파기제의', new NonAggressionCancelProposalAction(), { destNationId: 2.9 }], + ])('%s rejects fractional numeric arguments', (_name, definition, args) => { + expect(definition.parseArgs(args)).toBeNull(); + }); + }); + describe('che_필사즉생 (Last Stand)', () => { it('applies the legacy three-turn gains and global delay', () => { const nation = buildNation(1); diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index d57aa1d..a4e0e9b 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -30,7 +30,7 @@ const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 const normalizeStoredLogText = (value: unknown): string => String(value) .replace(/^(?:●<\/>|◆<\/>|★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '') - .replace(/ <1>\d{2}:\d{2}<\/>$/, ''); + .replace(/ ?<1>\d{2}:\d{2}<\/>$/, ''); const semanticLogSignatures = (logs: Array>): string[] => logs @@ -875,6 +875,309 @@ integration('nation flag boundary parity', () => { ); }); +type DiplomacyProposalAction = 'che_선전포고' | 'che_불가침제의' | 'che_종전제의' | 'che_불가침파기제의'; + +interface DiplomacyProposalBoundaryCase { + name: string; + action: DiplomacyProposalAction; + args?: Record; + fixturePatches?: FixturePatches; + completed: boolean; +} + +const diplomacyProposalBoundaryCases: DiplomacyProposalBoundaryCase[] = [ + ...(['che_선전포고', 'che_불가침제의', 'che_종전제의', 'che_불가침파기제의'] as const).flatMap((action) => [ + { + name: `${action} rejects a numeric-string destination`, + action, + args: action === 'che_불가침제의' ? { destNationID: '2', year: 190, month: 7 } : { destNationID: '2' }, + completed: false, + }, + { + name: `${action} rejects a fractional destination instead of truncating it`, + action, + args: action === 'che_불가침제의' ? { destNationID: 2.9, year: 190, month: 7 } : { destNationID: 2.9 }, + completed: false, + }, + { + name: `${action} rejects a missing destination nation`, + action, + args: action === 'che_불가침제의' ? { destNationID: 9, year: 190, month: 7 } : { destNationID: 9 }, + completed: false, + }, + { + name: `${action} rejects a neutral actor`, + action, + args: action === 'che_불가침제의' ? { destNationID: 2, year: 190, month: 7 } : { destNationID: 2 }, + fixturePatches: { + generals: { 1: { nationId: 0 } }, + cities: { 3: { nationId: 0 } }, + }, + completed: false, + }, + { + name: `${action} rejects an actor below chief`, + action, + args: action === 'che_불가침제의' ? { destNationID: 2, year: 190, month: 7 } : { destNationID: 2 }, + fixturePatches: { generals: { 1: { officerLevel: 4 } } }, + completed: false, + }, + ]), + { + name: 'declare-war rejects the opening year', + action: 'che_선전포고', + args: { destNationID: 2 }, + fixturePatches: { world: { year: 180 } }, + completed: false, + }, + { + name: 'declare-war accepts the exact start-year plus one boundary', + action: 'che_선전포고', + args: { destNationID: 2 }, + fixturePatches: { world: { year: 181 } }, + completed: true, + }, + { + name: 'declare-war rejects an actor city occupied by another nation', + action: 'che_선전포고', + args: { destNationID: 2 }, + fixturePatches: { cities: { 3: { nationId: 2 } } }, + completed: false, + }, + { + name: 'declare-war rejects an unsupplied actor city', + action: 'che_선전포고', + args: { destNationID: 2 }, + fixturePatches: { cities: { 3: { supplyState: 0 } } }, + completed: false, + }, + { + name: 'declare-war rejects forward war state', + action: 'che_선전포고', + args: { destNationID: 2 }, + fixturePatches: { diplomacy: { '1:2': { state: 0 }, '2:1': { state: 3 } } }, + completed: false, + }, + { + name: 'declare-war ignores the reverse diplomacy state', + action: 'che_선전포고', + args: { destNationID: 2 }, + fixturePatches: { diplomacy: { '1:2': { state: 3 }, '2:1': { state: 0 } } }, + completed: true, + }, + { + name: 'non-aggression rejects a fractional year', + action: 'che_불가침제의', + args: { destNationID: 2, year: 190.9, month: 7 }, + completed: false, + }, + { + name: 'non-aggression rejects a fractional month', + action: 'che_불가침제의', + args: { destNationID: 2, year: 190, month: 7.9 }, + completed: false, + }, + { + name: 'non-aggression rejects a year before the scenario start even when six months ahead', + action: 'che_불가침제의', + args: { destNationID: 2, year: 179, month: 7 }, + fixturePatches: { world: { year: 179, month: 1 } }, + completed: false, + }, + { + name: 'non-aggression rejects five months ahead', + action: 'che_불가침제의', + args: { destNationID: 2, year: 190, month: 6 }, + completed: false, + }, + { + name: 'non-aggression accepts exactly six months ahead without city ownership or supply', + action: 'che_불가침제의', + args: { destNationID: 2, year: 190, month: 7 }, + fixturePatches: { + nations: { 1: { name: '위' }, 2: { name: '탑' } }, + cities: { 3: { nationId: 2, supplyState: 0 } }, + }, + completed: true, + }, + { + name: 'non-aggression rejects a forward war state', + action: 'che_불가침제의', + args: { destNationID: 2, year: 190, month: 7 }, + fixturePatches: { diplomacy: { '1:2': { state: 0 }, '2:1': { state: 3 } } }, + completed: false, + }, + { + name: 'non-aggression ignores the reverse war state', + action: 'che_불가침제의', + args: { destNationID: 2, year: 190, month: 7 }, + fixturePatches: { diplomacy: { '1:2': { state: 3 }, '2:1': { state: 0 } } }, + completed: true, + }, + { + name: 'stop-war accepts forward declaration state zero', + action: 'che_종전제의', + args: { destNationID: 2 }, + fixturePatches: { diplomacy: { '1:2': { state: 0 }, '2:1': { state: 3 } } }, + completed: true, + }, + { + name: 'stop-war accepts forward war state one', + action: 'che_종전제의', + args: { destNationID: 2 }, + fixturePatches: { diplomacy: { '1:2': { state: 1 }, '2:1': { state: 3 } } }, + completed: true, + }, + { + name: 'stop-war rejects an unrelated forward state', + action: 'che_종전제의', + args: { destNationID: 2 }, + completed: false, + }, + { + name: 'stop-war rejects an unsupplied actor city', + action: 'che_종전제의', + args: { destNationID: 2 }, + fixturePatches: { cities: { 3: { supplyState: 0 } }, diplomacy: { '1:2': { state: 0 } } }, + completed: false, + }, + { + name: 'non-aggression cancellation accepts forward state seven', + action: 'che_불가침파기제의', + args: { destNationID: 2 }, + fixturePatches: { diplomacy: { '1:2': { state: 7 }, '2:1': { state: 3 } } }, + completed: true, + }, + { + name: 'non-aggression cancellation rejects an unrelated forward state', + action: 'che_불가침파기제의', + args: { destNationID: 2 }, + completed: false, + }, + { + name: 'non-aggression cancellation ignores a reverse-only state seven', + action: 'che_불가침파기제의', + args: { destNationID: 2 }, + fixturePatches: { diplomacy: { '1:2': { state: 3 }, '2:1': { state: 7 } } }, + completed: false, + }, +]; + +const expectedDiplomacyMessage = ( + action: DiplomacyProposalAction, + sourceName: string, + destinationName: string, + year: number, + month: number +): { type: 'national' | 'diplomacy'; text: string; option: Record } => { + switch (action) { + case 'che_선전포고': + return { + type: 'national', + text: `【외교】${year}년 ${month}월:${sourceName}에서 ${destinationName}에 선전포고`, + option: {}, + }; + case 'che_불가침제의': + return { + type: 'diplomacy', + text: `${sourceName}${sourceName === '위' ? '와' : '과'} ${year}년 ${month + 6}월까지 불가침 제의 서신`, + option: { action: 'noAggression', year, month: month + 6 }, + }; + case 'che_종전제의': + return { + type: 'diplomacy', + text: `${sourceName}의 종전 제의 서신`, + option: { action: 'stopWar', deletable: false }, + }; + case 'che_불가침파기제의': + return { + type: 'diplomacy', + text: `${sourceName}의 불가침 파기 제의 서신`, + option: { action: 'cancelNA', deletable: false }, + }; + } +}; + +integration('nation diplomacy proposal boundary and message parity', () => { + it.each(diplomacyProposalBoundaryCases)( + '$name', + async ({ action, args, fixturePatches, completed }) => { + const request = buildRequest(action, args, fixturePatches); + request.observe!.includeNationHistoryLogs = true; + request.observe!.includeGlobalHistoryLogs = true; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed }); + if ( + fixturePatches?.generals?.[1]?.nationId === 0 || + (typeof fixturePatches?.generals?.[1]?.officerLevel === 'number' && + fixturePatches.generals[1].officerLevel < 5) + ) { + expect(core.execution.outcome).toBeUndefined(); + } else { + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: completed ? action : '휴식', + usedFallback: !completed, + }); + } + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + + const referenceMessages = reference.after.messages.slice(reference.before.messages.length); + if (!completed) { + expect(referenceMessages).toEqual([]); + expect(core.after.messages).toEqual([]); + return; + } + + const sourceNation = reference.after.nations.find((entry) => entry.id === 1); + const destinationNation = reference.after.nations.find((entry) => entry.id === 2); + const expected = expectedDiplomacyMessage( + action, + String(sourceNation?.name), + String(destinationNation?.name), + Number(reference.after.world.year), + Number(reference.after.world.month) + ); + expect(referenceMessages).toHaveLength(2); + expect(referenceMessages.find((entry) => entry.mailbox === 9002)).toMatchObject({ + type: expected.type, + sourceId: 9001, + destinationId: 9002, + payload: { + src: { nation_id: 1, nation: sourceNation?.name }, + dest: { nation_id: 2, nation: destinationNation?.name }, + text: expected.text, + option: expected.option, + }, + }); + expect(core.after.messages).toHaveLength(1); + expect(core.after.messages[0]).toMatchObject({ + payload: { + msgType: expected.type, + src: { nationId: 1, nationName: sourceNation?.name }, + dest: { nationId: 2, nationName: destinationNation?.name }, + text: expected.text, + option: expected.option, + }, + }); + expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + }, + 120_000 + ); +}); + type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed'; const volunteerRecruitBoundaryCases: Array<{