fix: match legacy flood 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 FloodArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type FloodArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
|
|
||||||
@@ -66,7 +63,13 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
}
|
}
|
||||||
|
|
||||||
getPostReqTurn(context: FloodResolveContext<TriggerState>): number {
|
getPostReqTurn(context: FloodResolveContext<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 * 4) * 10);
|
const base = Math.round(Math.sqrt(genCount * 4) * 10);
|
||||||
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base));
|
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base));
|
||||||
}
|
}
|
||||||
@@ -153,16 +156,19 @@ export class ActionResolver<
|
|||||||
strategic_cmd_limit: globalDelay,
|
strategic_cmd_limit: globalDelay,
|
||||||
};
|
};
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(broadcastMessage, {
|
createLogEffect(
|
||||||
scope: LogScope.NATION,
|
`<Y>${generalName}</>${generalJosa} <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>을 발동`,
|
||||||
category: LogCategory.HISTORY,
|
{
|
||||||
nationId: nation.id,
|
scope: LogScope.NATION,
|
||||||
format: LogFormat.YEAR_MONTH,
|
category: LogCategory.HISTORY,
|
||||||
})
|
nationId: nation.id,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.destNation) {
|
if (context.destNation && context.destNationGenerals.length > 0) {
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(
|
createLogEffect(
|
||||||
`<D><b>${nation?.name ?? '상대국'}</b></>의 <Y>${generalName}</>${generalJosa} 아국의 <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>을 발동`,
|
`<D><b>${nation?.name ?? '상대국'}</b></>의 <Y>${generalName}</>${generalJosa} 아국의 <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>을 발동`,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface TurnSnapshotSelector {
|
|||||||
nationIds: number[];
|
nationIds: number[];
|
||||||
logAfterId?: number;
|
logAfterId?: number;
|
||||||
messageAfterId?: number;
|
messageAfterId?: number;
|
||||||
|
includeNationHistoryLogs?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanonicalTurnSnapshot {
|
export interface CanonicalTurnSnapshot {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export interface TurnCommandFixtureRequest {
|
|||||||
nationIds?: number[];
|
nationIds?: number[];
|
||||||
logAfterId?: number;
|
logAfterId?: number;
|
||||||
messageAfterId?: number;
|
messageAfterId?: number;
|
||||||
|
includeNationHistoryLogs?: boolean;
|
||||||
generalCooldowns?: GeneralCooldownSelector[];
|
generalCooldowns?: GeneralCooldownSelector[];
|
||||||
nationCooldowns?: NationCooldownSelector[];
|
nationCooldowns?: NationCooldownSelector[];
|
||||||
diplomacyPairs?: Array<{ fromNationId: number; toNationId: number }>;
|
diplomacyPairs?: Array<{ fromNationId: number; toNationId: number }>;
|
||||||
|
|||||||
@@ -365,7 +365,8 @@ const buildRequest = (
|
|||||||
action === 'che_급습' ||
|
action === 'che_급습' ||
|
||||||
action === 'che_필사즉생' ||
|
action === 'che_필사즉생' ||
|
||||||
action === 'che_허보' ||
|
action === 'che_허보' ||
|
||||||
action === 'che_의병모집'
|
action === 'che_의병모집' ||
|
||||||
|
action === 'che_수몰'
|
||||||
? {
|
? {
|
||||||
nationCooldowns: [
|
nationCooldowns: [
|
||||||
{
|
{
|
||||||
@@ -381,7 +382,9 @@ const buildRequest = (
|
|||||||
? '필사즉생'
|
? '필사즉생'
|
||||||
: action === 'che_허보'
|
: action === 'che_허보'
|
||||||
? '허보'
|
? '허보'
|
||||||
: '의병모집',
|
: action === 'che_의병모집'
|
||||||
|
? '의병모집'
|
||||||
|
: '수몰',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -966,6 +969,352 @@ integration('nation volunteer-recruitment constraints, creation values, RNG, and
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type FloodOutcome = 'fallback' | 'intermediate' | 'completed';
|
||||||
|
|
||||||
|
const floodBoundaryCases: Array<{
|
||||||
|
name: string;
|
||||||
|
destCityId: unknown;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
outcome: FloodOutcome;
|
||||||
|
coreResolution?: boolean;
|
||||||
|
expectedDefence?: number;
|
||||||
|
expectedWall?: number;
|
||||||
|
expectedPostReqTurn?: number;
|
||||||
|
expectedStrategicCommandLimit?: number;
|
||||||
|
expectedDestNationHistory?: boolean;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'rejects a missing destination city',
|
||||||
|
destCityId: 9999,
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accepts a numeric string destination city ID',
|
||||||
|
destCityId: '70',
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 61,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'truncates a fractional destination city ID',
|
||||||
|
destCityId: 70.9,
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 61,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 source city occupied by another nation',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects an actor below chief rank',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { generals: { 1: { officerLevel: 4, officerCityId: 0 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
coreResolution: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a remaining strategic-command delay',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { nations: { 1: { strategicCommandLimit: 1 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a declaration state instead of active war',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { diplomacy: { '1:2': { state: 1 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a reverse-only war',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
diplomacy: {
|
||||||
|
'1:2': { state: 3 },
|
||||||
|
'2:1': { state: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows unsupplied source and destination cities at term one',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: {
|
||||||
|
3: { supplyState: 0 },
|
||||||
|
70: { supplyState: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'advances the prior first term to term two',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '수몰', arg: { destCityID: 70 }, term: 1 },
|
||||||
|
},
|
||||||
|
coreTurnLastByOfficerLevel: {
|
||||||
|
12: { command: '수몰', arg: { destCityId: 70 }, term: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'restarts at term one after another command interrupted the stack',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '의병모집', term: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'completes the default flood effects and notifications',
|
||||||
|
destCityId: 70,
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 61,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'omits destination nation history when it has no general',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { generals: { 2: { nationId: 0 } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 61,
|
||||||
|
expectedDestNationHistory: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rounds defence and wall multiplication like MariaDB',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: {
|
||||||
|
70: {
|
||||||
|
defence: 1003,
|
||||||
|
wall: 1002,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedDefence: 201,
|
||||||
|
expectedWall: 200,
|
||||||
|
expectedPostReqTurn: 61,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'applies the strategist command and global-delay modifiers',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { nations: { 1: { typeCode: 'che_종횡가' } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 45,
|
||||||
|
expectedStrategicCommandLimit: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'uses stored eleven-general count for the nation cooldown',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { nations: { 1: { generalCount: 11 } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 64,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('nation flood constraints, multistep, city damage, logs, and cooldown boundaries', () => {
|
||||||
|
it.each(floodBoundaryCases)(
|
||||||
|
'$name matches legacy progress, damage, notifications, cooldown, RNG, and semantic delta',
|
||||||
|
async ({
|
||||||
|
destCityId,
|
||||||
|
fixturePatches,
|
||||||
|
outcome,
|
||||||
|
coreResolution = true,
|
||||||
|
expectedDefence,
|
||||||
|
expectedWall,
|
||||||
|
expectedPostReqTurn,
|
||||||
|
expectedStrategicCommandLimit = 9,
|
||||||
|
expectedDestNationHistory = true,
|
||||||
|
}) => {
|
||||||
|
const completed = outcome === 'completed';
|
||||||
|
const fallback = outcome === 'fallback';
|
||||||
|
const normalizedDestCityId = Math.trunc(Number(destCityId));
|
||||||
|
const completionNationPatch = completed
|
||||||
|
? {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '수몰', arg: { destCityID: destCityId }, term: 2 },
|
||||||
|
},
|
||||||
|
coreTurnLastByOfficerLevel: {
|
||||||
|
12: { command: '수몰', arg: { destCityId: normalizedDestCityId }, term: 2 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_수몰',
|
||||||
|
{ destCityID: destCityId },
|
||||||
|
{
|
||||||
|
...fixturePatches,
|
||||||
|
nations: {
|
||||||
|
...fixturePatches?.nations,
|
||||||
|
1: {
|
||||||
|
...fixturePatches?.nations?.[1],
|
||||||
|
...completionNationPatch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
diplomacy: {
|
||||||
|
'1:2': { state: 0 },
|
||||||
|
'2:1': { state: 3 },
|
||||||
|
...fixturePatches?.diplomacy,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.observe!.includeNationHistoryLogs = true;
|
||||||
|
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 cityBefore = snapshot.before.cities.find((entry) => entry.id === normalizedDestCityId);
|
||||||
|
const cityAfter = snapshot.after.cities.find((entry) => entry.id === normalizedDestCityId);
|
||||||
|
|
||||||
|
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 ? 15 : 0
|
||||||
|
);
|
||||||
|
expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe(
|
||||||
|
completed ? 15 : 0
|
||||||
|
);
|
||||||
|
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
|
||||||
|
completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit')
|
||||||
|
);
|
||||||
|
expect(readNumericField(cityAfter, 'defence')).toBe(
|
||||||
|
completed
|
||||||
|
? (expectedDefence ?? Math.round(readNumericField(cityBefore, 'defence') * 0.2))
|
||||||
|
: readNumericField(cityBefore, 'defence')
|
||||||
|
);
|
||||||
|
expect(readNumericField(cityAfter, 'wall')).toBe(
|
||||||
|
completed
|
||||||
|
? (expectedWall ?? Math.round(readNumericField(cityBefore, 'wall') * 0.2))
|
||||||
|
: readNumericField(cityBefore, 'wall')
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
const hasDestGeneral = snapshot.before.generals.some((entry) => entry.nationId === 2);
|
||||||
|
expect(
|
||||||
|
addedLogs.some((entry) => entry.generalId === 2 && String(entry.text).includes('수몰'))
|
||||||
|
).toBe(hasDestGeneral);
|
||||||
|
expect(
|
||||||
|
addedLogs.some(
|
||||||
|
(entry) => String(entry.text).includes('아국의') && String(entry.text).includes('수몰')
|
||||||
|
)
|
||||||
|
).toBe(expectedDestNationHistory);
|
||||||
|
expect(
|
||||||
|
addedLogs.some(
|
||||||
|
(entry) =>
|
||||||
|
String(entry.text).includes('수몰') &&
|
||||||
|
!String(entry.text).includes('아국의') &&
|
||||||
|
String(entry.text).endsWith('발동')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome === 'intermediate') {
|
||||||
|
const priorTurnByOfficerLevel = fixturePatches?.nations?.[1]?.turnLastByOfficerLevel;
|
||||||
|
const priorTerm =
|
||||||
|
priorTurnByOfficerLevel && typeof priorTurnByOfficerLevel === 'object'
|
||||||
|
? (
|
||||||
|
priorTurnByOfficerLevel as Record<
|
||||||
|
number,
|
||||||
|
{ command?: string; arg?: { destCityID?: unknown }; term?: number }
|
||||||
|
>
|
||||||
|
)[12]
|
||||||
|
: undefined;
|
||||||
|
const expectedTerm =
|
||||||
|
priorTerm?.command === '수몰' &&
|
||||||
|
Math.trunc(Number(priorTerm.arg?.destCityID)) === normalizedDestCityId &&
|
||||||
|
priorTerm.term === 1
|
||||||
|
? 2
|
||||||
|
: 1;
|
||||||
|
expect(reference.execution.outcome).toMatchObject({
|
||||||
|
lastTurn: {
|
||||||
|
command: '수몰',
|
||||||
|
term: expectedTerm,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({
|
||||||
|
command: '수몰',
|
||||||
|
term: expectedTerm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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<{
|
const nationResourceAmountCases: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
action: 'che_포상' | 'che_몰수';
|
action: 'che_포상' | 'che_몰수';
|
||||||
|
|||||||
Reference in New Issue
Block a user