fix: match legacy counter-strategy boundaries
This commit is contained in:
@@ -64,10 +64,7 @@ const STRATEGIC_COMMANDS: Record<(typeof STRATEGIC_COMMAND_KEYS)[number], string
|
||||
};
|
||||
|
||||
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(),
|
||||
commandType: z
|
||||
.enum(STRATEGIC_COMMAND_KEYS)
|
||||
.refine((value) => value !== 'che_피장파장', '같은 전략은 선택할 수 없습니다.'),
|
||||
@@ -133,7 +130,13 @@ export class ActionResolver<
|
||||
}
|
||||
|
||||
getTargetPostReqTurn(context: CounterStrategyResolveContext<TriggerState>): number {
|
||||
const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit);
|
||||
const storedCount = context.nation?.meta.gennum;
|
||||
const genCount = Math.max(
|
||||
typeof storedCount === 'number' && Number.isFinite(storedCount)
|
||||
? storedCount
|
||||
: context.friendlyGenerals.length,
|
||||
this.initialNationGenLimit
|
||||
);
|
||||
const base = Math.round(Math.sqrt(genCount * 2) * 10);
|
||||
const triggered = Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base));
|
||||
return Math.max(triggered, Math.round(TARGET_DELAY * 1.2));
|
||||
@@ -204,14 +207,6 @@ export class ActionResolver<
|
||||
...(nation.meta as object),
|
||||
[`next_execute_${targetCommandName}`]: currentYearMonth + this.getTargetPostReqTurn(context),
|
||||
};
|
||||
effects.push(
|
||||
createLogEffect(broadcastMessage, {
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: nation.id,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const destMeta = destNation.meta;
|
||||
|
||||
@@ -346,6 +346,19 @@ const buildWorldInput = (
|
||||
generals
|
||||
)
|
||||
);
|
||||
const referenceNationCooldowns = Array.isArray(referenceBefore.world.nationCooldowns)
|
||||
? referenceBefore.world.nationCooldowns
|
||||
: [];
|
||||
for (const rawCooldown of referenceNationCooldowns) {
|
||||
const cooldown = asRecord(rawCooldown);
|
||||
const nationId = readNumber(cooldown, 'nationId');
|
||||
const actionName = readString(cooldown, 'actionName', '');
|
||||
const nextAvailableTurn = cooldown.nextAvailableTurn;
|
||||
const nation = nations.find((entry) => entry.id === nationId);
|
||||
if (nation && actionName && typeof nextAvailableTurn === 'number' && Number.isFinite(nextAvailableTurn)) {
|
||||
nation.meta[`next_execute_${actionName}`] = nextAvailableTurn;
|
||||
}
|
||||
}
|
||||
const observedCityRows = new Map(referenceBefore.cities.map((row) => [readNumber(row, 'id'), row] as const));
|
||||
const randomFoundingCandidateCityIds = request.setup?.randomFoundingCandidateCityIds
|
||||
? new Set(request.setup.randomFoundingCandidateCityIds)
|
||||
|
||||
@@ -1315,6 +1315,380 @@ integration('nation flood constraints, multistep, city damage, logs, and cooldow
|
||||
);
|
||||
});
|
||||
|
||||
const counterStrategyNames = {
|
||||
che_필사즉생: '필사즉생',
|
||||
che_백성동원: '백성동원',
|
||||
che_수몰: '수몰',
|
||||
che_허보: '허보',
|
||||
che_의병모집: '의병모집',
|
||||
che_이호경식: '이호경식',
|
||||
che_급습: '급습',
|
||||
} as const;
|
||||
|
||||
type CounterStrategyCommand = keyof typeof counterStrategyNames;
|
||||
|
||||
interface CounterStrategyBoundaryCase {
|
||||
name: string;
|
||||
destNationId?: unknown;
|
||||
commandType?: unknown;
|
||||
omitDestNationId?: boolean;
|
||||
omitCommandType?: boolean;
|
||||
fixturePatches?: FixturePatches;
|
||||
outcome: 'fallback' | 'intermediate' | 'completed';
|
||||
coreResolution?: boolean;
|
||||
expectedSourceTargetOffset?: number;
|
||||
expectedDestTargetTurn?: number;
|
||||
expectedDestGeneralLog?: boolean;
|
||||
}
|
||||
|
||||
const counterStrategyBoundaryCases: CounterStrategyBoundaryCase[] = [
|
||||
{
|
||||
name: 'rejects a missing destination nation argument',
|
||||
omitDestNationId: true,
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a numeric string destination nation',
|
||||
destNationId: '2',
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a fractional destination nation instead of truncating it',
|
||||
destNationId: 2.9,
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a zero destination nation',
|
||||
destNationId: 0,
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a missing selected strategy',
|
||||
omitCommandType: true,
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a non-string selected strategy',
|
||||
commandType: 1,
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects an unknown selected strategy',
|
||||
commandType: 'che_초토화',
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects selecting counter strategy itself',
|
||||
commandType: 'che_피장파장',
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a nation that no longer exists',
|
||||
destNationId: 9,
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a source city occupied by another nation',
|
||||
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects an actor below chief rank',
|
||||
fixturePatches: { generals: { 1: { officerLevel: 4 } } },
|
||||
outcome: 'fallback',
|
||||
coreResolution: false,
|
||||
},
|
||||
{
|
||||
name: 'rejects a remaining strategic-command delay',
|
||||
fixturePatches: { nations: { 1: { strategicCommandLimit: 1 } } },
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a selected strategy still cooling down in the source nation',
|
||||
fixturePatches: { nations: { 1: { nationEnv: { next_execute_허보: 2281 } } } },
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a forward diplomacy state outside declaration or war',
|
||||
fixturePatches: { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 0 } } },
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a reverse-only war',
|
||||
fixturePatches: { diplomacy: { '1:2': { state: 3 }, '2:1': { state: 0 } } },
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'allows an unsupplied source city and starts at term one',
|
||||
fixturePatches: { cities: { 3: { supplyState: 0 } } },
|
||||
outcome: 'intermediate',
|
||||
},
|
||||
{
|
||||
name: 'restarts at term one when the same command has different prior arguments',
|
||||
fixturePatches: {
|
||||
nations: {
|
||||
1: {
|
||||
turnLastByOfficerLevel: {
|
||||
12: {
|
||||
command: '피장파장',
|
||||
arg: { destNationID: 2, commandType: 'che_백성동원' },
|
||||
term: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
outcome: 'intermediate',
|
||||
},
|
||||
{
|
||||
name: 'restarts at term one after another command interrupted the stack',
|
||||
fixturePatches: {
|
||||
nations: {
|
||||
1: {
|
||||
turnLastByOfficerLevel: {
|
||||
12: {
|
||||
command: '수몰',
|
||||
arg: { destCityID: 70 },
|
||||
term: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
outcome: 'intermediate',
|
||||
},
|
||||
{
|
||||
name: 'completes the default source and destination cooldowns and notifications',
|
||||
outcome: 'completed',
|
||||
expectedSourceTargetOffset: 72,
|
||||
expectedDestTargetTurn: 2340,
|
||||
},
|
||||
{
|
||||
name: 'allows the exact current-turn selected-strategy cooldown',
|
||||
fixturePatches: { nations: { 1: { nationEnv: { next_execute_허보: 2280 } } } },
|
||||
outcome: 'completed',
|
||||
expectedSourceTargetOffset: 72,
|
||||
expectedDestTargetTurn: 2340,
|
||||
},
|
||||
{
|
||||
name: 'adds sixty turns after an existing future destination cooldown',
|
||||
fixturePatches: { nations: { 2: { nationEnv: { next_execute_허보: 2300 } } } },
|
||||
outcome: 'completed',
|
||||
expectedSourceTargetOffset: 72,
|
||||
expectedDestTargetTurn: 2360,
|
||||
},
|
||||
{
|
||||
name: 'uses stored thirty-general count for the source selected-strategy cooldown',
|
||||
fixturePatches: { nations: { 1: { generalCount: 30 } } },
|
||||
outcome: 'completed',
|
||||
expectedSourceTargetOffset: 77,
|
||||
expectedDestTargetTurn: 2340,
|
||||
},
|
||||
{
|
||||
name: 'applies the strategist delay modifier but preserves the seventy-two-turn floor',
|
||||
fixturePatches: { nations: { 1: { typeCode: 'che_종횡가' } } },
|
||||
outcome: 'completed',
|
||||
expectedSourceTargetOffset: 72,
|
||||
expectedDestTargetTurn: 2340,
|
||||
},
|
||||
{
|
||||
name: 'keeps destination nation history when it has no general',
|
||||
fixturePatches: { generals: { 2: { nationId: 0 } } },
|
||||
outcome: 'completed',
|
||||
expectedSourceTargetOffset: 72,
|
||||
expectedDestTargetTurn: 2340,
|
||||
expectedDestGeneralLog: false,
|
||||
},
|
||||
{
|
||||
name: 'allows declaration state one regardless of reverse diplomacy',
|
||||
fixturePatches: { diplomacy: { '1:2': { state: 1 }, '2:1': { state: 7 } } },
|
||||
outcome: 'completed',
|
||||
expectedSourceTargetOffset: 72,
|
||||
expectedDestTargetTurn: 2340,
|
||||
},
|
||||
];
|
||||
|
||||
integration('nation counter-strategy constraints, stack, dual cooldowns, and log boundaries', () => {
|
||||
it.each(counterStrategyBoundaryCases)(
|
||||
'$name matches legacy progress, cooldowns, notifications, RNG, and semantic delta',
|
||||
async ({
|
||||
destNationId = 2,
|
||||
commandType = 'che_허보',
|
||||
omitDestNationId = false,
|
||||
omitCommandType = false,
|
||||
fixturePatches,
|
||||
outcome,
|
||||
coreResolution = true,
|
||||
expectedSourceTargetOffset,
|
||||
expectedDestTargetTurn,
|
||||
expectedDestGeneralLog = true,
|
||||
}) => {
|
||||
const completed = outcome === 'completed';
|
||||
const fallback = outcome === 'fallback';
|
||||
const completionPatch = completed
|
||||
? {
|
||||
turnLastByOfficerLevel: {
|
||||
12: {
|
||||
command: '피장파장',
|
||||
arg: { destNationID: destNationId, commandType },
|
||||
term: 1,
|
||||
},
|
||||
},
|
||||
coreTurnLastByOfficerLevel: {
|
||||
12: {
|
||||
command: '피장파장',
|
||||
arg: { destNationId, commandType },
|
||||
term: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const args = {
|
||||
...(omitDestNationId ? {} : { destNationID: destNationId }),
|
||||
...(omitCommandType ? {} : { commandType }),
|
||||
};
|
||||
const request = buildRequest('che_피장파장', args, {
|
||||
...fixturePatches,
|
||||
nations: {
|
||||
...fixturePatches?.nations,
|
||||
1: {
|
||||
...fixturePatches?.nations?.[1],
|
||||
...completionPatch,
|
||||
},
|
||||
},
|
||||
diplomacy: {
|
||||
'1:2': { state: 0 },
|
||||
'2:1': { state: 3 },
|
||||
...fixturePatches?.diplomacy,
|
||||
},
|
||||
});
|
||||
const selectedCommand =
|
||||
typeof commandType === 'string' && commandType in counterStrategyNames
|
||||
? (commandType as CounterStrategyCommand)
|
||||
: 'che_허보';
|
||||
const selectedCommandName = counterStrategyNames[selectedCommand];
|
||||
request.observe!.includeNationHistoryLogs = true;
|
||||
request.observe!.nationCooldowns =
|
||||
typeof commandType === 'string' && commandType in counterStrategyNames
|
||||
? [
|
||||
{ nationId: 1, actionName: '피장파장' },
|
||||
{ nationId: 1, actionName: selectedCommandName },
|
||||
{ nationId: 2, actionName: selectedCommandName },
|
||||
]
|
||||
: [];
|
||||
|
||||
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 sourceBefore = snapshot.before.nations.find((entry) => entry.id === 1);
|
||||
const sourceAfter = 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);
|
||||
expect(readNationResource(sourceBefore, 'gold') - readNationResource(sourceAfter, 'gold')).toBe(0);
|
||||
expect(readNationResource(sourceBefore, 'rice') - readNationResource(sourceAfter, '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(sourceAfter, 'strategicCommandLimit')).toBe(
|
||||
readNumericField(sourceBefore, 'strategicCommandLimit')
|
||||
);
|
||||
|
||||
if (completed) {
|
||||
const nationHistoryWatermark = snapshot.before.logs
|
||||
.filter((entry) => entry.scope === 'nation')
|
||||
.reduce((max, entry) => Math.max(max, readNumericField(entry, 'id')), 0);
|
||||
const addedLogs = snapshot.after.logs.filter((entry) =>
|
||||
entry.scope === 'nation'
|
||||
? readNumericField(entry, 'id') > nationHistoryWatermark
|
||||
: readNumericField(entry, 'id') > snapshot.before.watermarks.logId
|
||||
);
|
||||
expect(
|
||||
addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('피장파장'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
addedLogs.some((entry) => entry.generalId === 2 && String(entry.text).includes('피장파장'))
|
||||
).toBe(expectedDestGeneralLog);
|
||||
expect(
|
||||
addedLogs.filter(
|
||||
(entry) =>
|
||||
String(entry.scope).toLowerCase() === 'nation' &&
|
||||
entry.nationId === 1 &&
|
||||
String(entry.text).includes('피장파장')
|
||||
)
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
addedLogs.filter(
|
||||
(entry) =>
|
||||
String(entry.scope).toLowerCase() === 'nation' &&
|
||||
entry.nationId === 2 &&
|
||||
String(entry.text).includes('피장파장')
|
||||
)
|
||||
).toHaveLength(1);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
expect(reference.after.world.nationCooldowns).toEqual([
|
||||
{
|
||||
nationId: 1,
|
||||
actionName: '피장파장',
|
||||
nextAvailableTurn: 2287,
|
||||
},
|
||||
{
|
||||
nationId: 1,
|
||||
actionName: selectedCommandName,
|
||||
nextAvailableTurn: 2280 + expectedSourceTargetOffset!,
|
||||
},
|
||||
{
|
||||
nationId: 2,
|
||||
actionName: selectedCommandName,
|
||||
nextAvailableTurn: expectedDestTargetTurn,
|
||||
},
|
||||
]);
|
||||
expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns);
|
||||
}
|
||||
expect(reference.rng).toEqual([]);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
const nationResourceAmountCases: Array<{
|
||||
name: string;
|
||||
action: 'che_포상' | 'che_몰수';
|
||||
|
||||
Reference in New Issue
Block a user