diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index d2c7675..a6f6e9d 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -62,8 +62,9 @@ normalized to the core `*Id` spelling before command identity comparison. The matrix includes the four-call `전투태세` path, lifecycle and appointment commands, troop assembly, rebellion and all three founding variants. - - `turnCommandNationMatrix.integration.test.ts`: 26 successful nation - command paths, including all nine unit-research commands. + - `turnCommandNationMatrix.integration.test.ts`: 32 successful nation + command paths, including all nine unit-research commands and the + multi-turn `필사즉생`, `허보`, and `초토화` paths. - `turnCommandCoreReference.integration.test.ts`: declaration and live sortie fixtures. @@ -168,11 +169,11 @@ Compatibility is established per case only when: 4. live sortie also passes the battle trace comparison; 5. any ignored path is documented in the case evidence. -As of 2026-07-26, 54 general matrix cases, 26 nation matrix cases, declaration +As of 2026-07-26, 54 general matrix cases, 32 nation matrix cases, declaration and live sortie pass this boundary. Live sortie is the remaining general command key and covers battle entry, conquest, defeated-general neutralization and last-city nation collapse. Thus all 55 general command keys have a -completed success-path comparison. This is 82 executable comparison cases; +completed success-path comparison. This is 88 executable comparison cases; it is not yet a claim that failure/boundary paths or all 38 nation commands have been dynamically compared. diff --git a/packages/logic/src/actions/turn/nation/che_허보.ts b/packages/logic/src/actions/turn/nation/che_허보.ts index e8dd8f1..700da09 100644 --- a/packages/logic/src/actions/turn/nation/che_허보.ts +++ b/packages/logic/src/actions/turn/nation/che_허보.ts @@ -48,14 +48,23 @@ const DEFAULT_GLOBAL_DELAY = 9; const PRE_REQ_TURN = 1; const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); +type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & { + nextIntInclusive?: (maxInclusive: number) => number; +}; + +const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: number): number => { + const inclusive = rng as InclusiveRandomGenerator; + return inclusive.nextIntInclusive ? inclusive.nextIntInclusive(length - 1) : rng.nextInt(0, length); +}; + const pickMoveCityId = (rng: GeneralActionResolveContext['rng'], destCityId: number, candidates: City[]): number => { if (candidates.length === 0) { return destCityId; } - let idx = rng.nextInt(0, candidates.length); + let idx = legacyChoiceIndex(rng, candidates.length); let cityId = candidates[idx]?.id ?? destCityId; - if (cityId === destCityId && candidates.length > 1) { - idx = rng.nextInt(0, candidates.length); + if (cityId === destCityId) { + idx = legacyChoiceIndex(rng, candidates.length); cityId = candidates[idx]?.id ?? destCityId; } return cityId; diff --git a/packages/logic/src/constraints/diplomacy.ts b/packages/logic/src/constraints/diplomacy.ts index 09b0a79..fec2438 100644 --- a/packages/logic/src/constraints/diplomacy.ts +++ b/packages/logic/src/constraints/diplomacy.ts @@ -93,8 +93,51 @@ export const disallowDiplomacyBetweenStatus = (disallowList: Record): Constraint => ({ - ...disallowDiplomacyBetweenStatus(disallowList), name: 'disallowDiplomacyStatus', + requires: (ctx) => [ + ...(ctx.nationId !== undefined ? ([{ kind: 'nation', id: ctx.nationId }] as RequirementKey[]) : []), + { kind: 'diplomacyList' }, + ], + test: (ctx, view) => { + const general = readGeneral(ctx, view); + const baseNationId = ctx.nationId ?? general?.nationId; + if (baseNationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'diplomacyList' }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '외교 정보가 없습니다.'); + } + const entries = view.get(req); + if (!Array.isArray(entries)) { + return unknownOrDeny(ctx, [req], '외교 정보가 없습니다.'); + } + for (const value of entries) { + if (!value || typeof value !== 'object') { + continue; + } + const entry = value as { + fromNationId?: number; + srcNationId?: number; + me?: number; + state?: number; + stateCode?: number; + }; + const fromNationId = entry.fromNationId ?? entry.srcNationId ?? entry.me; + if (fromNationId !== baseNationId) { + continue; + } + const state = entry.state ?? entry.stateCode; + if (state === undefined) { + continue; + } + const reason = disallowList[state]; + if (reason !== undefined) { + return { kind: 'deny', reason }; + } + } + return allow(); + }, }); export const allowDiplomacyBetweenStatus = (allowList: number[], reason: string): Constraint => ({ diff --git a/packages/logic/test/actions/turn/nationMissing.test.ts b/packages/logic/test/actions/turn/nationMissing.test.ts index 01209be..3989324 100644 --- a/packages/logic/test/actions/turn/nationMissing.test.ts +++ b/packages/logic/test/actions/turn/nationMissing.test.ts @@ -422,6 +422,37 @@ describe('Nation Missing Actions', () => { expect(result.kind).toBe('deny'); }); + it('che_초토화: blocks when the nation is at war with any nation', () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + const destNation = buildNation(2); + const city = buildCity(1, 1); + const destCity = buildCity(2, 2); + const view = new TestStateView(); + view.set({ kind: 'general', id: general.id }, general); + view.set({ kind: 'city', id: city.id }, city); + view.set({ kind: 'nation', id: nation.id }, nation); + view.set({ kind: 'destCity', id: destCity.id }, destCity); + view.set({ kind: 'destNation', id: destNation.id }, destNation); + setupDiplomacy(view, nation.id, destNation.id, 3); + view.set({ kind: 'diplomacyList' }, [{ fromNationId: nation.id, toNationId: 3, state: 0, term: 12 }]); + + const definition = new ScorchedEarthAction(); + const args = { destCityId: destCity.id }; + const ctx: ConstraintContext = { + actorId: general.id, + nationId: nation.id, + cityId: city.id, + destCityId: destCity.id, + destNationId: destNation.id, + args, + env: {}, + mode: 'full', + }; + + expect(evaluateConstraints(definition.buildConstraints(ctx, args), ctx, view).kind).toBe('deny'); + }); + it('che_초토화: neutralizes city and increases nation resources', () => { const general = buildGeneral(1, 1, 1); const nation = buildNation(1); diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index b1d7ad6..0747be1 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -290,7 +290,9 @@ const buildWorldInput = ( buildNation( { ...row, - turnLastByOfficerLevel: fixtureNations.get(readNumber(row, 'id'))?.turnLastByOfficerLevel, + turnLastByOfficerLevel: + fixtureNations.get(readNumber(row, 'id'))?.coreTurnLastByOfficerLevel ?? + fixtureNations.get(readNumber(row, 'id'))?.turnLastByOfficerLevel, }, generals ) diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 3b8a9f4..6636975 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -316,6 +316,54 @@ const cases: NationMatrixCase[] = [ { diplomacy: { '1:2': { state: 1, term: 12 }, '2:1': { state: 1, term: 12 } } }, ], ['che_급습', { destNationID: 2 }, { diplomacy: { '1:2': { state: 1, term: 12 }, '2:1': { state: 1, term: 12 } } }], + [ + 'che_필사즉생', + undefined, + { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '필사즉생', arg: {}, term: 2 }, + }, + }, + }, + diplomacy: { '1:2': { state: 0 }, '2:1': { state: 0 } }, + }, + ], + [ + 'che_허보', + { destCityID: 70 }, + { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '허보', arg: { destCityID: 70 }, term: 1 }, + }, + coreTurnLastByOfficerLevel: { + 12: { command: '허보', arg: { destCityId: 70 }, term: 1 }, + }, + }, + }, + diplomacy: { '1:2': { state: 0 }, '2:1': { state: 0 } }, + }, + ], + [ + 'che_초토화', + { destCityID: 70 }, + { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { command: '초토화', arg: { destCityID: 70 }, term: 2 }, + }, + coreTurnLastByOfficerLevel: { + 12: { command: '초토화', arg: { destCityId: 70 }, term: 2 }, + }, + }, + }, + cities: { 70: { nationId: 1, supplyState: 1 } }, + }, + ], ]; integration('nation command success matrix', () => {