test: cover multi-turn nation strategy commands

This commit is contained in:
2026-07-26 02:07:26 +00:00
parent 708ee80595
commit 58abd60cbf
6 changed files with 143 additions and 9 deletions
@@ -62,8 +62,9 @@ normalized to the core `*Id` spelling before command identity comparison.
The matrix includes the four-call `전투태세` path, lifecycle and The matrix includes the four-call `전투태세` path, lifecycle and
appointment commands, troop assembly, rebellion and all three founding appointment commands, troop assembly, rebellion and all three founding
variants. variants.
- `turnCommandNationMatrix.integration.test.ts`: 26 successful nation - `turnCommandNationMatrix.integration.test.ts`: 32 successful nation
command paths, including all nine unit-research commands. command paths, including all nine unit-research commands and the
multi-turn `필사즉생`, `허보`, and `초토화` paths.
- `turnCommandCoreReference.integration.test.ts`: declaration and live - `turnCommandCoreReference.integration.test.ts`: declaration and live
sortie fixtures. sortie fixtures.
@@ -168,11 +169,11 @@ Compatibility is established per case only when:
4. live sortie also passes the battle trace comparison; 4. live sortie also passes the battle trace comparison;
5. any ignored path is documented in the case evidence. 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 and live sortie pass this boundary. Live sortie is the remaining general
command key and covers battle entry, conquest, defeated-general neutralization command key and covers battle entry, conquest, defeated-general neutralization
and last-city nation collapse. Thus all 55 general command keys have a 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 it is not yet a claim that failure/boundary paths or all 38 nation commands
have been dynamically compared. have been dynamically compared.
@@ -48,14 +48,23 @@ const DEFAULT_GLOBAL_DELAY = 9;
const PRE_REQ_TURN = 1; const PRE_REQ_TURN = 1;
const EXP_DED_GAIN = 5 * (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 => { const pickMoveCityId = (rng: GeneralActionResolveContext['rng'], destCityId: number, candidates: City[]): number => {
if (candidates.length === 0) { if (candidates.length === 0) {
return destCityId; return destCityId;
} }
let idx = rng.nextInt(0, candidates.length); let idx = legacyChoiceIndex(rng, candidates.length);
let cityId = candidates[idx]?.id ?? destCityId; let cityId = candidates[idx]?.id ?? destCityId;
if (cityId === destCityId && candidates.length > 1) { if (cityId === destCityId) {
idx = rng.nextInt(0, candidates.length); idx = legacyChoiceIndex(rng, candidates.length);
cityId = candidates[idx]?.id ?? destCityId; cityId = candidates[idx]?.id ?? destCityId;
} }
return cityId; return cityId;
+44 -1
View File
@@ -93,8 +93,51 @@ export const disallowDiplomacyBetweenStatus = (disallowList: Record<number, stri
}); });
export const disallowDiplomacyStatus = (disallowList: Record<number, string>): Constraint => ({ export const disallowDiplomacyStatus = (disallowList: Record<number, string>): Constraint => ({
...disallowDiplomacyBetweenStatus(disallowList),
name: 'disallowDiplomacyStatus', 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 => ({ export const allowDiplomacyBetweenStatus = (allowList: number[], reason: string): Constraint => ({
@@ -422,6 +422,37 @@ describe('Nation Missing Actions', () => {
expect(result.kind).toBe('deny'); 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', () => { it('che_초토화: neutralizes city and increases nation resources', () => {
const general = buildGeneral(1, 1, 1); const general = buildGeneral(1, 1, 1);
const nation = buildNation(1); const nation = buildNation(1);
@@ -290,7 +290,9 @@ const buildWorldInput = (
buildNation( buildNation(
{ {
...row, ...row,
turnLastByOfficerLevel: fixtureNations.get(readNumber(row, 'id'))?.turnLastByOfficerLevel, turnLastByOfficerLevel:
fixtureNations.get(readNumber(row, 'id'))?.coreTurnLastByOfficerLevel ??
fixtureNations.get(readNumber(row, 'id'))?.turnLastByOfficerLevel,
}, },
generals generals
) )
@@ -316,6 +316,54 @@ const cases: NationMatrixCase[] = [
{ diplomacy: { '1:2': { state: 1, term: 12 }, '2:1': { state: 1, term: 12 } } }, { 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_급습', { 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', () => { integration('nation command success matrix', () => {