fix: match volunteer recruitment boundaries
This commit is contained in:
@@ -96,11 +96,19 @@ export const buildAverageNationGeneralCount = (world: ActionContextWorldRef | nu
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const generals = world.listGenerals();
|
const generals = world.listGenerals();
|
||||||
const nations = world.listNations();
|
const nations = world.listNations().filter((nation) => nation.level > 0);
|
||||||
if (nations.length === 0) {
|
if (nations.length === 0) {
|
||||||
return generals.length;
|
return generals.length;
|
||||||
}
|
}
|
||||||
return generals.length / nations.length;
|
return (
|
||||||
|
nations.reduce((sum, nation) => {
|
||||||
|
const storedCount = nation.meta.gennum;
|
||||||
|
if (typeof storedCount === 'number' && Number.isFinite(storedCount)) {
|
||||||
|
return sum + storedCount;
|
||||||
|
}
|
||||||
|
return sum + generals.filter((general) => general.nationId === nation.id).length;
|
||||||
|
}, 0) / nations.length
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveStartYear = (world: ActionContextWorldState, scenarioMeta?: ScenarioMeta): number => {
|
export const resolveStartYear = (world: ActionContextWorldState, scenarioMeta?: ScenarioMeta): number => {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { RandomGenerator } from '@sammo-ts/common';
|
import type { RandomGenerator } from '@sammo-ts/common';
|
||||||
import type { GeneralMeta, GeneralTriggerState, StatBlock, TriggerValue } from '@sammo-ts/logic/domain/entities.js';
|
import type {
|
||||||
|
General,
|
||||||
|
GeneralMeta,
|
||||||
|
GeneralTriggerState,
|
||||||
|
StatBlock,
|
||||||
|
TriggerValue,
|
||||||
|
} from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
availableStrategicCommand,
|
availableStrategicCommand,
|
||||||
@@ -16,7 +22,7 @@ import type {
|
|||||||
GeneralActionResolveContext,
|
GeneralActionResolveContext,
|
||||||
GeneralActionResolver,
|
GeneralActionResolver,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createGeneralAddEffect } from '@sammo-ts/logic/actions/engine.js';
|
import { createGeneralAddEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { buildRecruitmentGeneral } from '@sammo-ts/logic/actions/turn/general/recruitment.js';
|
import { buildRecruitmentGeneral } from '@sammo-ts/logic/actions/turn/general/recruitment.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
@@ -53,6 +59,7 @@ export interface VolunteerRecruitResolveContext<
|
|||||||
nationAverageExperience?: number;
|
nationAverageExperience?: number;
|
||||||
nationAverageDedication?: number;
|
nationAverageDedication?: number;
|
||||||
nationAverageDex?: [number, number, number, number, number];
|
nationAverageDex?: [number, number, number, number, number];
|
||||||
|
friendlyGenerals: Array<General<TriggerState>>;
|
||||||
generalPool?: VolunteerRecruitCandidate[];
|
generalPool?: VolunteerRecruitCandidate[];
|
||||||
createGeneralId: () => number;
|
createGeneralId: () => number;
|
||||||
turnTermSeconds: number;
|
turnTermSeconds: number;
|
||||||
@@ -284,6 +291,10 @@ export class ActionResolver<
|
|||||||
void _args;
|
void _args;
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
const nation = context.nation;
|
const nation = context.nation;
|
||||||
|
const generalName = general.name;
|
||||||
|
const generalJosa = JosaUtil.pick(generalName, '이');
|
||||||
|
const actionJosa = JosaUtil.pick(ACTION_NAME, '을');
|
||||||
|
const broadcastMessage = `<Y>${generalName}</>${generalJosa} <M>${ACTION_NAME}</>${actionJosa} 발동하였습니다.`;
|
||||||
|
|
||||||
const expGain = 5 * (DEFAULT_PRE_TURN + 1);
|
const expGain = 5 * (DEFAULT_PRE_TURN + 1);
|
||||||
const dedGain = 5 * (DEFAULT_PRE_TURN + 1);
|
const dedGain = 5 * (DEFAULT_PRE_TURN + 1);
|
||||||
@@ -300,10 +311,7 @@ export class ActionResolver<
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (nation) {
|
if (nation) {
|
||||||
const generalName = general.name;
|
context.addLog(`<Y>${generalName}</>${generalJosa} <M>${actionName}</>${actionJosa} 발동`, {
|
||||||
const generalJosa = JosaUtil.pick(generalName, '이');
|
|
||||||
const actionJosa = JosaUtil.pick(actionName, '을');
|
|
||||||
context.addLog(`<Y>${generalName}</>${generalJosa} <M>${actionName}</>${actionJosa} 발동했습니다.`, {
|
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
@@ -327,6 +335,19 @@ export class ActionResolver<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
||||||
|
for (const target of context.friendlyGenerals) {
|
||||||
|
if (target.id === general.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
effects.push(
|
||||||
|
createLogEffect(broadcastMessage, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
generalId: target.id,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const baseAge = this.env.npcAge ?? DEFAULT_NPC_AGE;
|
const baseAge = this.env.npcAge ?? DEFAULT_NPC_AGE;
|
||||||
const deathYears = this.env.npcDeathYears ?? DEFAULT_NPC_DEATH_YEARS;
|
const deathYears = this.env.npcDeathYears ?? DEFAULT_NPC_DEATH_YEARS;
|
||||||
@@ -359,7 +380,7 @@ export class ActionResolver<
|
|||||||
const stats = candidate.stats ? resolveStats(context, context.rng, this.env, candidate) : generated.stats;
|
const stats = candidate.stats ? resolveStats(context, context.rng, this.env, candidate) : generated.stats;
|
||||||
const averageDex = context.nationAverageDex ?? [0, 0, 0, 0, 0];
|
const averageDex = context.nationAverageDex ?? [0, 0, 0, 0, 0];
|
||||||
const dexTotal = averageDex[0] + averageDex[1] + averageDex[2] + averageDex[3];
|
const dexTotal = averageDex[0] + averageDex[1] + averageDex[2] + averageDex[3];
|
||||||
const dex: [number, number, number, number] =
|
const rawDex: [number, number, number, number] =
|
||||||
generated.pickType === '무'
|
generated.pickType === '무'
|
||||||
? legacyChoice(context.rng, [
|
? legacyChoice(context.rng, [
|
||||||
[(dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8, dexTotal / 8],
|
[(dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8, dexTotal / 8],
|
||||||
@@ -367,6 +388,12 @@ export class ActionResolver<
|
|||||||
[dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8, dexTotal / 8],
|
[dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8, dexTotal / 8],
|
||||||
])
|
])
|
||||||
: [dexTotal / 8, dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8];
|
: [dexTotal / 8, dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8];
|
||||||
|
const dex: [number, number, number, number] = [
|
||||||
|
Math.trunc(rawDex[0]),
|
||||||
|
Math.trunc(rawDex[1]),
|
||||||
|
Math.trunc(rawDex[2]),
|
||||||
|
Math.trunc(rawDex[3]),
|
||||||
|
];
|
||||||
const personality =
|
const personality =
|
||||||
candidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']);
|
candidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']);
|
||||||
const turnSecond = randomRangeInt(context.rng, 0, context.turnTermSeconds - 1);
|
const turnSecond = randomRangeInt(context.rng, 0, context.turnTermSeconds - 1);
|
||||||
@@ -382,7 +409,7 @@ export class ActionResolver<
|
|||||||
dex2: dex[1],
|
dex2: dex[1],
|
||||||
dex3: dex[2],
|
dex3: dex[2],
|
||||||
dex4: dex[3],
|
dex4: dex[3],
|
||||||
dex5: averageDex[4],
|
dex5: Math.trunc(averageDex[4]),
|
||||||
turnSecond,
|
turnSecond,
|
||||||
turnFraction,
|
turnFraction,
|
||||||
};
|
};
|
||||||
@@ -406,8 +433,8 @@ export class ActionResolver<
|
|||||||
npcState: NPC_TYPE,
|
npcState: NPC_TYPE,
|
||||||
gold: this.env.defaultNpcGold,
|
gold: this.env.defaultNpcGold,
|
||||||
rice: this.env.defaultNpcRice,
|
rice: this.env.defaultNpcRice,
|
||||||
experience: context.nationAverageExperience ?? 0,
|
experience: Math.trunc(context.nationAverageExperience ?? 0),
|
||||||
dedication: context.nationAverageDedication ?? 0,
|
dedication: Math.trunc(context.nationAverageDedication ?? 0),
|
||||||
crewTypeId: this.env.defaultCrewTypeId,
|
crewTypeId: this.env.defaultCrewTypeId,
|
||||||
role: {
|
role: {
|
||||||
personality,
|
personality,
|
||||||
@@ -477,6 +504,8 @@ export class ActionDefinition<
|
|||||||
// 예약 턴 실행에 필요한 국가 평균 정보를 구성한다.
|
// 예약 턴 실행에 필요한 국가 평균 정보를 구성한다.
|
||||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||||
const nationSummary = buildNationSummary(options.worldRef, base.general.nationId);
|
const nationSummary = buildNationSummary(options.worldRef, base.general.nationId);
|
||||||
|
const friendlyGenerals =
|
||||||
|
options.worldRef?.listGenerals().filter((general) => general.nationId === base.general.nationId) ?? [];
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
currentYear: options.world.currentYear,
|
currentYear: options.world.currentYear,
|
||||||
@@ -487,6 +516,7 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
|||||||
nationAverageExperience: nationSummary.averageExperience,
|
nationAverageExperience: nationSummary.averageExperience,
|
||||||
nationAverageDedication: nationSummary.averageDedication,
|
nationAverageDedication: nationSummary.averageDedication,
|
||||||
nationAverageDex: nationSummary.averageDex,
|
nationAverageDex: nationSummary.averageDex,
|
||||||
|
friendlyGenerals,
|
||||||
createGeneralId: options.createGeneralId,
|
createGeneralId: options.createGeneralId,
|
||||||
turnTermSeconds: Math.max(1, Math.round(options.world.tickSeconds)),
|
turnTermSeconds: Math.max(1, Math.round(options.world.tickSeconds)),
|
||||||
turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime,
|
turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime,
|
||||||
|
|||||||
@@ -480,6 +480,7 @@ const projectWorld = (
|
|||||||
generalIds: Set<number>;
|
generalIds: Set<number>;
|
||||||
cityIds: Set<number>;
|
cityIds: Set<number>;
|
||||||
nationIds: Set<number>;
|
nationIds: Set<number>;
|
||||||
|
initialGeneralIds: Set<number>;
|
||||||
generalCooldowns: GeneralCooldownSelector[];
|
generalCooldowns: GeneralCooldownSelector[];
|
||||||
nationCooldowns: NationCooldownSelector[];
|
nationCooldowns: NationCooldownSelector[];
|
||||||
}
|
}
|
||||||
@@ -566,7 +567,11 @@ const projectWorld = (
|
|||||||
rankData: world
|
rankData: world
|
||||||
.listGenerals()
|
.listGenerals()
|
||||||
.filter((general) => selector.generalIds.has(general.id))
|
.filter((general) => selector.generalIds.has(general.id))
|
||||||
.flatMap(buildLegacyComparableRankRows)
|
.flatMap((general) =>
|
||||||
|
buildLegacyComparableRankRows(general).map((row) =>
|
||||||
|
selector.initialGeneralIds.has(general.id) ? row : { ...row, nationId: 0, value: 0 }
|
||||||
|
)
|
||||||
|
)
|
||||||
.map((row) => ({ ...row })),
|
.map((row) => ({ ...row })),
|
||||||
cities: world
|
cities: world
|
||||||
.listCities()
|
.listCities()
|
||||||
@@ -674,6 +679,7 @@ export const runCoreTurnCommandTrace = async (
|
|||||||
]),
|
]),
|
||||||
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
|
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
|
||||||
nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))),
|
nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))),
|
||||||
|
initialGeneralIds: new Set(referenceBefore.generals.map((row) => readNumber(row, 'id'))),
|
||||||
generalCooldowns: request.observe?.generalCooldowns ?? [],
|
generalCooldowns: request.observe?.generalCooldowns ?? [],
|
||||||
nationCooldowns: request.observe?.nationCooldowns ?? [],
|
nationCooldowns: request.observe?.nationCooldowns ?? [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -331,9 +331,15 @@ const buildRequest = (
|
|||||||
4,
|
4,
|
||||||
5,
|
5,
|
||||||
6,
|
6,
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
11,
|
||||||
|
12,
|
||||||
...Object.keys(fixturePatches.generals ?? {})
|
...Object.keys(fixturePatches.generals ?? {})
|
||||||
.map(Number)
|
.map(Number)
|
||||||
.filter((id) => ![1, 2, 3, 4, 5, 6].includes(id)),
|
.filter((id) => ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(id)),
|
||||||
],
|
],
|
||||||
cityIds: [
|
cityIds: [
|
||||||
3,
|
3,
|
||||||
@@ -358,7 +364,8 @@ const buildRequest = (
|
|||||||
action === 'che_이호경식' ||
|
action === 'che_이호경식' ||
|
||||||
action === 'che_급습' ||
|
action === 'che_급습' ||
|
||||||
action === 'che_필사즉생' ||
|
action === 'che_필사즉생' ||
|
||||||
action === 'che_허보'
|
action === 'che_허보' ||
|
||||||
|
action === 'che_의병모집'
|
||||||
? {
|
? {
|
||||||
nationCooldowns: [
|
nationCooldowns: [
|
||||||
{
|
{
|
||||||
@@ -372,7 +379,9 @@ const buildRequest = (
|
|||||||
? '급습'
|
? '급습'
|
||||||
: action === 'che_필사즉생'
|
: action === 'che_필사즉생'
|
||||||
? '필사즉생'
|
? '필사즉생'
|
||||||
: '허보',
|
: action === 'che_허보'
|
||||||
|
? '허보'
|
||||||
|
: '의병모집',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -667,6 +676,296 @@ integration('nation command success matrix', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed';
|
||||||
|
|
||||||
|
const volunteerRecruitBoundaryCases: Array<{
|
||||||
|
name: string;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
outcome: VolunteerRecruitOutcome;
|
||||||
|
coreResolution?: boolean;
|
||||||
|
expectedCreateCount?: number;
|
||||||
|
expectedPostReqTurn?: number;
|
||||||
|
expectedStrategicCommandLimit?: number;
|
||||||
|
expectedAverageExperience?: number;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'rejects a neutral actor',
|
||||||
|
fixturePatches: {
|
||||||
|
generals: { 1: { nationId: 0 } },
|
||||||
|
cities: { 3: { nationId: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
coreResolution: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects an actor city occupied by another nation',
|
||||||
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects an actor below chief rank',
|
||||||
|
fixturePatches: { generals: { 1: { officerLevel: 4, officerCityId: 0 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
coreResolution: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a remaining strategic-command delay',
|
||||||
|
fixturePatches: { nations: { 1: { strategicCommandLimit: 1 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects the turn before the three-year opening boundary',
|
||||||
|
fixturePatches: { world: { year: 182 } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accepts the exact three-year opening boundary',
|
||||||
|
fixturePatches: { world: { year: 183 } },
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows an unsupplied actor city',
|
||||||
|
fixturePatches: { cities: { 3: { supplyState: 0 } } },
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'starts at term one',
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'advances the prior first term to term two',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '의병모집', term: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'restarts at term one after another command interrupted the stack',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'creates three default volunteer generals',
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedCreateCount: 3,
|
||||||
|
expectedPostReqTurn: 98,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'applies the strategist command and global-delay modifiers',
|
||||||
|
fixturePatches: { nations: { 1: { typeCode: 'che_종횡가' } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedCreateCount: 3,
|
||||||
|
expectedPostReqTurn: 73,
|
||||||
|
expectedStrategicCommandLimit: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'uses stored eleven-general count for the nation cooldown',
|
||||||
|
fixturePatches: { nations: { 1: { generalCount: 11 } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedCreateCount: 4,
|
||||||
|
expectedPostReqTurn: 103,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rounds the active nations stored average at the four-general creation boundary',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: { generalCount: 4 },
|
||||||
|
2: { generalCount: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedCreateCount: 4,
|
||||||
|
expectedPostReqTurn: 98,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'excludes a level-zero nation from the stored average',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: { generalCount: 4 },
|
||||||
|
2: { generalCount: 100, level: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedCreateCount: 4,
|
||||||
|
expectedPostReqTurn: 98,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preserves legacy integer persistence for fractional nation averages',
|
||||||
|
fixturePatches: {
|
||||||
|
generals: {
|
||||||
|
3: {
|
||||||
|
experience: 1001,
|
||||||
|
dedication: 1001,
|
||||||
|
meta: { dex1: 1, dex2: 1, dex3: 1, dex4: 1, dex5: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedCreateCount: 3,
|
||||||
|
expectedPostReqTurn: 98,
|
||||||
|
expectedAverageExperience: 1000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('nation volunteer-recruitment constraints, creation values, RNG, and cooldown boundaries', () => {
|
||||||
|
it.each(volunteerRecruitBoundaryCases)(
|
||||||
|
'$name matches legacy progress, created generals, cooldown, logs, RNG, and semantic delta',
|
||||||
|
async ({
|
||||||
|
fixturePatches,
|
||||||
|
outcome,
|
||||||
|
coreResolution = true,
|
||||||
|
expectedCreateCount = 0,
|
||||||
|
expectedPostReqTurn,
|
||||||
|
expectedStrategicCommandLimit = 9,
|
||||||
|
expectedAverageExperience = 1000,
|
||||||
|
}) => {
|
||||||
|
const completed = outcome === 'completed';
|
||||||
|
const fallback = outcome === 'fallback';
|
||||||
|
const completionNationPatch = completed
|
||||||
|
? {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '의병모집', term: 2 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
const request = buildRequest('che_의병모집', undefined, {
|
||||||
|
...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 beforeGeneralIds = new Set(snapshot.before.generals.map((entry) => entry.id));
|
||||||
|
const createdGenerals = snapshot.after.generals.filter((entry) => !beforeGeneralIds.has(entry.id));
|
||||||
|
|
||||||
|
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(createdGenerals).toHaveLength(completed ? expectedCreateCount : 0);
|
||||||
|
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
|
||||||
|
completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
for (const created of createdGenerals) {
|
||||||
|
expect(created).toMatchObject({
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 3,
|
||||||
|
officerLevel: 1,
|
||||||
|
npcState: 4,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
age: 20,
|
||||||
|
experience: expectedAverageExperience,
|
||||||
|
dedication: expectedAverageExperience,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
});
|
||||||
|
expect(String(created.name)).toMatch(/^ⓖ/);
|
||||||
|
expect(
|
||||||
|
readNumericField(created, 'leadership') +
|
||||||
|
readNumericField(created, 'strength') +
|
||||||
|
readNumericField(created, 'intelligence')
|
||||||
|
).toBe(150);
|
||||||
|
expect(readNumericField(created, 'killTurn')).toBeGreaterThanOrEqual(64);
|
||||||
|
expect(readNumericField(created, 'killTurn')).toBeLessThanOrEqual(70);
|
||||||
|
}
|
||||||
|
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') {
|
||||||
|
const priorTurnByOfficerLevel = fixturePatches?.nations?.[1]?.turnLastByOfficerLevel;
|
||||||
|
const priorTerm =
|
||||||
|
priorTurnByOfficerLevel && typeof priorTurnByOfficerLevel === 'object'
|
||||||
|
? (priorTurnByOfficerLevel as Record<number, { command?: string; term?: number }>)[12]
|
||||||
|
: undefined;
|
||||||
|
const expectedTerm = priorTerm?.command === '의병모집' && 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 currentYear = fixturePatches?.world?.year ?? 190;
|
||||||
|
const expectedNextAvailableTurn = currentYear * 12 + expectedPostReqTurn!;
|
||||||
|
expect(reference.after.world.nationCooldowns).toEqual([
|
||||||
|
{
|
||||||
|
nationId: 1,
|
||||||
|
actionName: '의병모집',
|
||||||
|
nextAvailableTurn: expectedNextAvailableTurn,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns);
|
||||||
|
}
|
||||||
|
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