merge: match deception command boundaries
This commit is contained in:
@@ -23,13 +23,10 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
|||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import type { NationTurnCommandSpec } from './index.js';
|
import type { NationTurnCommandSpec } from './index.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destCityId: z.preprocess(
|
destCityId: z.preprocess(normalizeLegacyIntegerArg, z.number().int().positive()),
|
||||||
(value) => (typeof value === 'number' ? Math.floor(value) : value),
|
|
||||||
z.number().int().positive()
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
export type DeceptionArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type DeceptionArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
|
|
||||||
@@ -59,7 +56,7 @@ const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: numb
|
|||||||
|
|
||||||
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;
|
throw new RangeError('Cannot choose a deception destination from an empty city collection.');
|
||||||
}
|
}
|
||||||
let idx = legacyChoiceIndex(rng, candidates.length);
|
let idx = legacyChoiceIndex(rng, candidates.length);
|
||||||
let cityId = candidates[idx]?.id ?? destCityId;
|
let cityId = candidates[idx]?.id ?? destCityId;
|
||||||
@@ -258,7 +255,7 @@ export const actionContextBuilder: ActionContextBuilder<DeceptionArgs> = (base,
|
|||||||
const friendlyGenerals = generals.filter((general) => general.nationId === base.general.nationId);
|
const friendlyGenerals = generals.filter((general) => general.nationId === base.general.nationId);
|
||||||
const destNationSupplyCities = worldRef
|
const destNationSupplyCities = worldRef
|
||||||
.listCities()
|
.listCities()
|
||||||
.filter((city) => city.nationId === destCity.nationId && city.supplyState > 0);
|
.filter((city) => city.nationId === destCity.nationId && city.supplyState === 1);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
destCity,
|
destCity,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn
|
|||||||
import { ActionDefinition as ChangeNationNameAction } from '../../../src/actions/turn/nation/che_국호변경.js';
|
import { ActionDefinition as ChangeNationNameAction } from '../../../src/actions/turn/nation/che_국호변경.js';
|
||||||
import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js';
|
import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js';
|
||||||
import { ActionDefinition as LastStandAction } from '../../../src/actions/turn/nation/che_필사즉생.js';
|
import { ActionDefinition as LastStandAction } from '../../../src/actions/turn/nation/che_필사즉생.js';
|
||||||
|
import { ActionDefinition as DeceptionAction } from '../../../src/actions/turn/nation/che_허보.js';
|
||||||
import { LogCategory, LogScope } from '../../../src/logging/types.js';
|
import { LogCategory, LogScope } from '../../../src/logging/types.js';
|
||||||
import type { MapDefinition } from '../../../src/world/types.js';
|
import type { MapDefinition } from '../../../src/world/types.js';
|
||||||
import type { TurnSchedule } from '../../../src/turn/calendar.js';
|
import type { TurnSchedule } from '../../../src/turn/calendar.js';
|
||||||
@@ -248,6 +249,33 @@ describe('Nation Actions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('che_허보 (Deception)', () => {
|
||||||
|
it('fails like legacy choice when the target nation has no supplied city', () => {
|
||||||
|
const nation = buildNation(1);
|
||||||
|
const destNation = buildNation(2);
|
||||||
|
const general = buildGeneral(1, 1, 1);
|
||||||
|
const target = buildGeneral(2, 2, 2, 'Target');
|
||||||
|
const definition = new DeceptionAction([]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
definition.resolve(
|
||||||
|
{
|
||||||
|
general,
|
||||||
|
nation,
|
||||||
|
destNation,
|
||||||
|
destCity: buildCity(2, 2),
|
||||||
|
destCityGenerals: [target],
|
||||||
|
friendlyGenerals: [general],
|
||||||
|
destNationSupplyCities: [],
|
||||||
|
addLog: () => {},
|
||||||
|
rng: {} as any,
|
||||||
|
} as any,
|
||||||
|
{ destCityId: 2 }
|
||||||
|
)
|
||||||
|
).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('che_천도 (Move Capital)', () => {
|
describe('che_천도 (Move Capital)', () => {
|
||||||
it('changes nation capital city', () => {
|
it('changes nation capital city', () => {
|
||||||
const nation = buildNation(1);
|
const nation = buildNation(1);
|
||||||
|
|||||||
@@ -354,7 +354,11 @@ const buildRequest = (
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(action === 'che_백성동원' || action === 'che_이호경식' || action === 'che_급습' || action === 'che_필사즉생'
|
...(action === 'che_백성동원' ||
|
||||||
|
action === 'che_이호경식' ||
|
||||||
|
action === 'che_급습' ||
|
||||||
|
action === 'che_필사즉생' ||
|
||||||
|
action === 'che_허보'
|
||||||
? {
|
? {
|
||||||
nationCooldowns: [
|
nationCooldowns: [
|
||||||
{
|
{
|
||||||
@@ -366,7 +370,9 @@ const buildRequest = (
|
|||||||
? '이호경식'
|
? '이호경식'
|
||||||
: action === 'che_급습'
|
: action === 'che_급습'
|
||||||
? '급습'
|
? '급습'
|
||||||
: '필사즉생',
|
: action === 'che_필사즉생'
|
||||||
|
? '필사즉생'
|
||||||
|
: '허보',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -3903,3 +3909,325 @@ integration('nation desperate-survival multistep, diplomacy, effects, and cooldo
|
|||||||
120_000
|
120_000
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deceptionBoundaryCases: Array<{
|
||||||
|
name: string;
|
||||||
|
destCityId: unknown;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
outcome: 'fallback' | 'intermediate' | 'completed';
|
||||||
|
coreResolution?: boolean;
|
||||||
|
expectedPostReqTurn?: number;
|
||||||
|
expectedStrategicCommandLimit?: number;
|
||||||
|
expectedTargetCityId?: number;
|
||||||
|
expectedRngCalls?: number;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'rejects a missing destination city',
|
||||||
|
destCityId: 99,
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accepts a numeric string destination city ID',
|
||||||
|
destCityId: '70',
|
||||||
|
fixturePatches: { diplomacy: { '1:2': { state: 0 } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 62,
|
||||||
|
expectedTargetCityId: 70,
|
||||||
|
expectedRngCalls: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'truncates a fractional destination city ID',
|
||||||
|
destCityId: 70.9,
|
||||||
|
fixturePatches: { diplomacy: { '1:2': { state: 0 } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 62,
|
||||||
|
expectedTargetCityId: 70,
|
||||||
|
expectedRngCalls: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a neutral destination city',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 0 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a destination city occupied by the source nation',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 1 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a trade relation to the destination nation',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { diplomacy: { '1:2': { state: 3 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a reverse-only war relation',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
diplomacy: {
|
||||||
|
'1:2': { state: 3 },
|
||||||
|
'2:1': { state: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a source city occupied by another nation',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: { 3: { nationId: 2 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects an actor below chief rank',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
generals: { 1: { officerLevel: 4, officerCityId: 0 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
coreResolution: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a remaining strategic-command delay',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: { 1: { strategicCommandLimit: 1 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows an unsupplied source city on the intermediate turn',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: { 3: { supplyState: 0 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'completes against a nation under declaration',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { diplomacy: { '1:2': { state: 1 } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 62,
|
||||||
|
expectedTargetCityId: 70,
|
||||||
|
expectedRngCalls: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'excludes supply state two and moves the target to the only supply-one city',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: {
|
||||||
|
70: { supplyState: 2 },
|
||||||
|
23: { nationId: 2, supplyState: 1 },
|
||||||
|
},
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 62,
|
||||||
|
expectedTargetCityId: 23,
|
||||||
|
expectedRngCalls: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'completes without RNG when no general is in the destination city',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: { 23: { nationId: 2, supplyState: 1 } },
|
||||||
|
generals: { 2: { cityId: 23 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 62,
|
||||||
|
expectedTargetCityId: 23,
|
||||||
|
expectedRngCalls: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'restarts at term one after another command interrupted the stack',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'applies the strategist command and global-delay modifiers',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: { 1: { typeCode: 'che_종횡가' } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 46,
|
||||||
|
expectedStrategicCommandLimit: 5,
|
||||||
|
expectedTargetCityId: 70,
|
||||||
|
expectedRngCalls: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'uses the actual eleven-general count for the nation cooldown',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: { 1: { generalCount: 11 } },
|
||||||
|
generals: {
|
||||||
|
4: { nationId: 1 },
|
||||||
|
5: { nationId: 1 },
|
||||||
|
6: { nationId: 1 },
|
||||||
|
7: { nationId: 1 },
|
||||||
|
8: { nationId: 1 },
|
||||||
|
9: { nationId: 1 },
|
||||||
|
10: { nationId: 1 },
|
||||||
|
11: { nationId: 1 },
|
||||||
|
12: { nationId: 1 },
|
||||||
|
},
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 65,
|
||||||
|
expectedTargetCityId: 70,
|
||||||
|
expectedRngCalls: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('nation deception target, multistep, movement, RNG, and cooldown boundaries', () => {
|
||||||
|
it.each(deceptionBoundaryCases)(
|
||||||
|
'$name matches legacy progress, movement, cooldown, logs, RNG, and semantic delta',
|
||||||
|
async ({
|
||||||
|
destCityId,
|
||||||
|
fixturePatches,
|
||||||
|
outcome,
|
||||||
|
coreResolution = true,
|
||||||
|
expectedPostReqTurn,
|
||||||
|
expectedStrategicCommandLimit = 9,
|
||||||
|
expectedTargetCityId,
|
||||||
|
expectedRngCalls,
|
||||||
|
}) => {
|
||||||
|
const normalizedDestCityId =
|
||||||
|
typeof destCityId === 'string' ? Math.trunc(Number(destCityId)) : Math.trunc(Number(destCityId));
|
||||||
|
const completed = outcome === 'completed';
|
||||||
|
const fallback = outcome === 'fallback';
|
||||||
|
const completionNationPatch = completed
|
||||||
|
? {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '허보', arg: { destCityID: destCityId }, term: 1 },
|
||||||
|
},
|
||||||
|
coreTurnLastByOfficerLevel: {
|
||||||
|
12: { command: '허보', arg: { destCityId: normalizedDestCityId }, term: 1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_허보',
|
||||||
|
{ destCityID: destCityId },
|
||||||
|
{
|
||||||
|
...fixturePatches,
|
||||||
|
nations: {
|
||||||
|
...fixturePatches?.nations,
|
||||||
|
1: {
|
||||||
|
...fixturePatches?.nations?.[1],
|
||||||
|
...completionNationPatch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||||
|
if (coreResolution) {
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: 'che_허보',
|
||||||
|
actionKey: fallback ? '휴식' : 'che_허보',
|
||||||
|
usedFallback: fallback,
|
||||||
|
...(fallback ? {} : { completed }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
expect(core.execution.outcome).toBeUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const snapshot of [reference, core]) {
|
||||||
|
const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1);
|
||||||
|
const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1);
|
||||||
|
const actorBefore = snapshot.before.generals.find((entry) => entry.id === 1);
|
||||||
|
const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1);
|
||||||
|
const targetBefore = snapshot.before.generals.find((entry) => entry.id === 2);
|
||||||
|
const targetAfter = snapshot.after.generals.find((entry) => entry.id === 2);
|
||||||
|
|
||||||
|
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0);
|
||||||
|
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0);
|
||||||
|
expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe(
|
||||||
|
completed ? 10 : 0
|
||||||
|
);
|
||||||
|
expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe(
|
||||||
|
completed ? 10 : 0
|
||||||
|
);
|
||||||
|
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
|
||||||
|
completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit')
|
||||||
|
);
|
||||||
|
expect(readNumericField(targetAfter, 'cityId')).toBe(
|
||||||
|
completed && expectedTargetCityId !== undefined
|
||||||
|
? expectedTargetCityId
|
||||||
|
: readNumericField(targetBefore, 'cityId')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
|
||||||
|
expect(addedLogs.map((entry) => entry.text)).toEqual(
|
||||||
|
expect.arrayContaining([expect.stringContaining('허보 발동')])
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('허보'))
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome === 'intermediate') {
|
||||||
|
expect(reference.execution.outcome).toMatchObject({
|
||||||
|
lastTurn: {
|
||||||
|
command: '허보',
|
||||||
|
term: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({
|
||||||
|
command: '허보',
|
||||||
|
term: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (completed) {
|
||||||
|
const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!;
|
||||||
|
expect(reference.after.world.nationCooldowns).toEqual([
|
||||||
|
{
|
||||||
|
nationId: 1,
|
||||||
|
actionName: '허보',
|
||||||
|
nextAvailableTurn: expectedNextAvailableTurn,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns);
|
||||||
|
}
|
||||||
|
if (expectedRngCalls !== undefined) {
|
||||||
|
expect(reference.rng).toHaveLength(expectedRngCalls);
|
||||||
|
}
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user