merge: align scenario 2400 speciality parity
This commit is contained in:
@@ -206,7 +206,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
|||||||
defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']),
|
defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']),
|
||||||
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
||||||
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
|
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
|
||||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
|
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12),
|
||||||
maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255),
|
maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255),
|
||||||
techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5),
|
techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5),
|
||||||
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import type {
|
|||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { evaluateConstraints } from '@sammo-ts/logic';
|
import { evaluateConstraints } from '@sammo-ts/logic';
|
||||||
import type { ConstraintContext } from '@sammo-ts/logic';
|
import type { ConstraintContext } from '@sammo-ts/logic';
|
||||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||||
import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||||
import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
|
import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
|
||||||
|
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||||
|
|
||||||
import type { ReservedTurnEntry } from '../../reservedTurnStore.js';
|
import type { ReservedTurnEntry } from '../../reservedTurnStore.js';
|
||||||
import type { TurnGeneral, TurnWorldState } from '../../types.js';
|
import type { TurnGeneral, TurnWorldState } from '../../types.js';
|
||||||
@@ -50,6 +51,19 @@ const d징병 = 2;
|
|||||||
const d직전 = 3;
|
const d직전 = 3;
|
||||||
const d전쟁 = 4;
|
const d전쟁 = 4;
|
||||||
|
|
||||||
|
export const calculateRecentWarTurn = (general: TurnGeneral, turnTermMinutes: number): number => {
|
||||||
|
if (general.recentWarTick !== null && general.recentWarTick !== undefined && general.turnTick !== undefined) {
|
||||||
|
const tickDiff = general.turnTick - general.recentWarTick;
|
||||||
|
return tickDiff <= 0 ? 0 : Math.floor(tickDiff / GAME_TICKS_PER_TURN);
|
||||||
|
}
|
||||||
|
const recent = general.recentWarTime;
|
||||||
|
if (!recent) return 12000;
|
||||||
|
const diffMs = general.turnTime.getTime() - recent.getTime();
|
||||||
|
if (diffMs <= 0) return 0;
|
||||||
|
const turnMs = turnTermMinutes * 60 * 1000;
|
||||||
|
return turnMs > 0 ? Math.floor(diffMs / turnMs) : 12000;
|
||||||
|
};
|
||||||
|
|
||||||
export const selectNpcMessageForTurn = (
|
export const selectNpcMessageForTurn = (
|
||||||
message: unknown,
|
message: unknown,
|
||||||
rng: Pick<RandUtil, 'nextBool'>,
|
rng: Pick<RandUtil, 'nextBool'>,
|
||||||
@@ -86,6 +100,66 @@ export const resolveLegacyAiStats = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveLegacyAiStatsWithModules = (
|
||||||
|
general: TurnGeneral,
|
||||||
|
nation: Nation | null | undefined,
|
||||||
|
maxStatLevel: number,
|
||||||
|
modules: TurnCommandEnv['generalActionModules'],
|
||||||
|
worldRef: AiWorldView | null,
|
||||||
|
world: TurnWorldState,
|
||||||
|
startYear: number
|
||||||
|
) => {
|
||||||
|
const maxLevel = Math.max(1, maxStatLevel);
|
||||||
|
const clampStat = (value: number): number => Math.max(0, Math.min(value, maxLevel));
|
||||||
|
const pipeline = new GeneralActionPipeline(modules ?? []);
|
||||||
|
const context = {
|
||||||
|
general,
|
||||||
|
nation,
|
||||||
|
...(worldRef
|
||||||
|
? {
|
||||||
|
worldView: {
|
||||||
|
listGenerals: () => worldRef.listGenerals(),
|
||||||
|
listGeneralsByCity: (cityId: number) =>
|
||||||
|
worldRef.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
||||||
|
listNations: () => worldRef.listNations(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
time: {
|
||||||
|
year: world.currentYear,
|
||||||
|
month: world.currentMonth,
|
||||||
|
startYear,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rawStat = (statName: 'leadership' | 'strength' | 'intelligence'): number => general.stats[statName];
|
||||||
|
const calculate = (
|
||||||
|
statName: 'leadership' | 'strength' | 'intelligence',
|
||||||
|
withInjury: boolean,
|
||||||
|
withStatAdjust: boolean,
|
||||||
|
truncate: boolean
|
||||||
|
): number => {
|
||||||
|
const injuryRatio = withInjury ? (100 - Math.max(0, Math.min(general.injury, 100))) / 100 : 1;
|
||||||
|
let value = rawStat(statName) * injuryRatio;
|
||||||
|
if (withStatAdjust && statName === 'strength') {
|
||||||
|
value += Math.round(calculate('intelligence', withInjury, false, false) / 4);
|
||||||
|
} else if (withStatAdjust && statName === 'intelligence') {
|
||||||
|
value += Math.round(calculate('strength', withInjury, false, false) / 4);
|
||||||
|
}
|
||||||
|
value = clampStat(value);
|
||||||
|
value = clampStat(Number(pipeline.onCalcStat(context, statName, value)));
|
||||||
|
return truncate ? Math.trunc(value) : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullLeadership: calculate('leadership', false, true, true),
|
||||||
|
fullStrength: calculate('strength', false, true, true),
|
||||||
|
fullIntelligence: calculate('intelligence', false, true, true),
|
||||||
|
effectiveLeadership: calculate('leadership', true, true, true),
|
||||||
|
effectiveStrength: calculate('strength', true, true, true),
|
||||||
|
effectiveIntelligence: calculate('intelligence', true, true, true),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export class GeneralAI {
|
export class GeneralAI {
|
||||||
public general: TurnGeneral;
|
public general: TurnGeneral;
|
||||||
public city?: City;
|
public city?: City;
|
||||||
@@ -560,6 +634,28 @@ export class GeneralAI {
|
|||||||
return this.buildCandidate(this.nationDefinitions, this.nationFallback, action, args, reason);
|
return this.buildCandidate(this.nationDefinitions, this.nationFallback, action, args, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLastNationTurn(): Record<string, unknown> {
|
||||||
|
return asRecord(asRecord(this.nation?.meta)[`turn_last_${this.general.officerLevel}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastCapitalMoveTrial(): [number, number] | null {
|
||||||
|
const raw = asRecord(this.nation?.meta).lastCapitalMoveTrial;
|
||||||
|
if (!Array.isArray(raw) || raw.length < 2) return null;
|
||||||
|
const officerLevel = Number(raw[0]);
|
||||||
|
const turnTick = Number(raw[1]);
|
||||||
|
return Number.isFinite(officerLevel) && Number.isFinite(turnTick) ? [officerLevel, turnTick] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
markCapitalMoveTrial(): void {
|
||||||
|
if (!this.nation || this.general.turnTick === undefined) return;
|
||||||
|
const nextMeta = {
|
||||||
|
...(this.promotionNationMeta ?? this.nation.meta),
|
||||||
|
lastCapitalMoveTrial: [this.general.officerLevel, this.general.turnTick],
|
||||||
|
};
|
||||||
|
this.nation = { ...this.nation, meta: nextMeta as Nation['meta'] };
|
||||||
|
this.promotionNationMeta = nextMeta;
|
||||||
|
}
|
||||||
|
|
||||||
getReservedTurn(generalId: number): ReservedTurnEntry {
|
getReservedTurn(generalId: number): ReservedTurnEntry {
|
||||||
return this.reservedTurnProvider.getGeneralTurn(generalId, 0);
|
return this.reservedTurnProvider.getGeneralTurn(generalId, 0);
|
||||||
}
|
}
|
||||||
@@ -930,11 +1026,21 @@ export class GeneralAI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private refreshLegacyFullStats(): void {
|
private refreshLegacyFullStats(): void {
|
||||||
const stats = resolveLegacyAiStats(
|
const stats = this.commandEnv.generalActionModules
|
||||||
this.general,
|
? resolveLegacyAiStatsWithModules(
|
||||||
this.nation,
|
this.general,
|
||||||
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max
|
this.nation,
|
||||||
);
|
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max,
|
||||||
|
this.commandEnv.generalActionModules,
|
||||||
|
this.worldRef,
|
||||||
|
this.world,
|
||||||
|
this.startYear
|
||||||
|
)
|
||||||
|
: resolveLegacyAiStats(
|
||||||
|
this.general,
|
||||||
|
this.nation,
|
||||||
|
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max
|
||||||
|
);
|
||||||
this.general.meta = {
|
this.general.meta = {
|
||||||
...this.general.meta,
|
...this.general.meta,
|
||||||
...stats,
|
...stats,
|
||||||
@@ -1129,6 +1235,15 @@ export class GeneralAI {
|
|||||||
lastAttackable = yearMonth;
|
lastAttackable = yearMonth;
|
||||||
worldLastAttackable.set(nationId, yearMonth);
|
worldLastAttackable.set(nationId, yearMonth);
|
||||||
this.nation!.meta = { ...this.nation!.meta, last_attackable: yearMonth };
|
this.nation!.meta = { ...this.nation!.meta, last_attackable: yearMonth };
|
||||||
|
// Ref writes nation_env.last_attackable while constructing each
|
||||||
|
// GeneralAI instance. Carry that side effect through the existing
|
||||||
|
// nation-meta patch channel even when no promotion was selected.
|
||||||
|
// Promotion runs first in quarter months, so preserve a chief_set
|
||||||
|
// patch already accumulated by choose*Promotion().
|
||||||
|
this.promotionNationMeta = {
|
||||||
|
...(this.promotionNationMeta ?? this.nation!.meta),
|
||||||
|
last_attackable: yearMonth,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (minWarTerm === null) {
|
if (minWarTerm === null) {
|
||||||
@@ -1163,19 +1278,7 @@ export class GeneralAI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private calcRecentWarTurn(general: TurnGeneral): number {
|
private calcRecentWarTurn(general: TurnGeneral): number {
|
||||||
const recent = general.recentWarTime;
|
return calculateRecentWarTurn(general, this.turnTermMinutes);
|
||||||
if (!recent) {
|
|
||||||
return 12000;
|
|
||||||
}
|
|
||||||
const diffMs = general.turnTime.getTime() - recent.getTime();
|
|
||||||
if (diffMs <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const turnMs = this.turnTermMinutes * 60 * 1000;
|
|
||||||
if (turnMs <= 0) {
|
|
||||||
return 12000;
|
|
||||||
}
|
|
||||||
return Math.floor(diffMs / turnMs);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
isCrewTypeAvailable,
|
isCrewTypeAvailable,
|
||||||
} from '@sammo-ts/logic/world/unitSet.js';
|
} from '@sammo-ts/logic/world/unitSet.js';
|
||||||
import { buildWarConfig } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
import { buildWarConfig } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||||
|
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
|
||||||
import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic';
|
import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic';
|
||||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
|
||||||
|
|
||||||
import type { GeneralAI } from '../core.js';
|
import type { GeneralAI } from '../core.js';
|
||||||
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
|
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
|
||||||
@@ -106,19 +106,20 @@ export const do징병 = (ai: GeneralAI) => {
|
|||||||
}
|
}
|
||||||
const armTypeWeights = forcedArmType > 0 ? [] : buildRecruitArmTypeWeights(ai.general, warConfig.armTypes);
|
const armTypeWeights = forcedArmType > 0 ? [] : buildRecruitArmTypeWeights(ai.general, warConfig.armTypes);
|
||||||
let armTypeDraw: number | null = null;
|
let armTypeDraw: number | null = null;
|
||||||
const armType = forcedArmType > 0
|
const armType =
|
||||||
? forcedArmType
|
forcedArmType > 0
|
||||||
: traceEnabled
|
? forcedArmType
|
||||||
? (() => {
|
: traceEnabled
|
||||||
armTypeDraw = ai.rng.nextFloat1();
|
? (() => {
|
||||||
let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0);
|
armTypeDraw = ai.rng.nextFloat1();
|
||||||
for (const [candidate, weight] of armTypeWeights) {
|
let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0);
|
||||||
if (cursor <= weight) return candidate;
|
for (const [candidate, weight] of armTypeWeights) {
|
||||||
cursor -= Math.max(0, weight);
|
if (cursor <= weight) return candidate;
|
||||||
}
|
cursor -= Math.max(0, weight);
|
||||||
return armTypeWeights.at(-1)![0];
|
}
|
||||||
})()
|
return armTypeWeights.at(-1)![0];
|
||||||
: ai.rng.choiceUsingWeightPair(armTypeWeights);
|
})()
|
||||||
|
: ai.rng.choiceUsingWeightPair(armTypeWeights);
|
||||||
trace('arm-type', { forcedArmType, armType, armTypeDraw, armTypeWeights });
|
trace('arm-type', { forcedArmType, armType, armTypeDraw, armTypeWeights });
|
||||||
|
|
||||||
const candidates = (ai.unitSet?.crewTypes ?? [])
|
const candidates = (ai.unitSet?.crewTypes ?? [])
|
||||||
@@ -170,39 +171,31 @@ export const do징병 = (ai: GeneralAI) => {
|
|||||||
const crewTypeId = picked.id;
|
const crewTypeId = picked.id;
|
||||||
|
|
||||||
let crewAmount = crewAmountBase;
|
let crewAmount = crewAmountBase;
|
||||||
const rawGoldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
|
|
||||||
// Ref asks the concrete che_징병 command for getCost() before deciding
|
// Ref asks the concrete che_징병 command for getCost() before deciding
|
||||||
// whether to halve the requested crew. That path includes personality,
|
// whether to halve the requested crew. In particular, that command caps
|
||||||
// traits, items, and the final integer rounding; using the raw unit price
|
// the charge at the actually refillable amount when the selected type is
|
||||||
// makes che_출세 (+20% cost) recruit a full stack incorrectly.
|
// already equipped, then applies traits/items and legacy rounding.
|
||||||
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
|
const recruitContext = {
|
||||||
const goldCost = Math.round(
|
general: ai.general,
|
||||||
actionPipeline.onCalcDomestic(
|
nation,
|
||||||
{
|
...(ai.worldRef
|
||||||
general: ai.general,
|
? {
|
||||||
nation,
|
worldView: {
|
||||||
...(ai.worldRef
|
listGenerals: () => ai.worldRef!.listGenerals(),
|
||||||
? {
|
listGeneralsByCity: (cityId: number) =>
|
||||||
worldView: {
|
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
||||||
listGenerals: () => ai.worldRef!.listGenerals(),
|
listNations: () => ai.worldRef!.listNations(),
|
||||||
listGeneralsByCity: (cityId: number) =>
|
},
|
||||||
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
}
|
||||||
listNations: () => ai.worldRef!.listNations(),
|
: {}),
|
||||||
},
|
time: {
|
||||||
}
|
year: ai.world.currentYear,
|
||||||
: {}),
|
month: ai.world.currentMonth,
|
||||||
time: {
|
startYear: ai.startYear,
|
||||||
year: ai.world.currentYear,
|
},
|
||||||
month: ai.world.currentMonth,
|
};
|
||||||
startYear: ai.startYear,
|
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
|
||||||
},
|
const goldCost = recruitment.getCost(recruitContext, crewTypeId, crewAmount, picked).gold;
|
||||||
},
|
|
||||||
'징병',
|
|
||||||
'cost',
|
|
||||||
rawGoldCost,
|
|
||||||
{ armType: picked.armType }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
|
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
|
||||||
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
|
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
|
||||||
const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1));
|
const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1));
|
||||||
|
|||||||
@@ -121,12 +121,17 @@ export const doNPC구출발령 = (ai: GeneralAI) => {
|
|||||||
if (lostCandidates.length === 0) {
|
if (lostCandidates.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const destCityId = pickRandomCityId(ai, ai.supplyCities);
|
const candidates = lostCandidates.flatMap((general) => {
|
||||||
if (destCityId === null) {
|
const destCityId = pickRandomCityId(ai, ai.supplyCities);
|
||||||
|
return destCityId === null ? [] : [{ general, destCityId }];
|
||||||
|
});
|
||||||
|
if (candidates.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const destGeneral = ai.rng.choice(lostCandidates);
|
// Ref draws one destination city for every lost general, then chooses one
|
||||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC구출발령');
|
// completed (general, city) pair. The unused pairs still consume RNG.
|
||||||
|
const picked = ai.rng.choice(candidates);
|
||||||
|
return buildAssignmentCandidate(ai, picked.general.id, picked.destCityId, 'NPC구출발령');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const doNPC전방발령 = (ai: GeneralAI) => {
|
export const doNPC전방발령 = (ai: GeneralAI) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GeneralAI } from '../core.js';
|
import type { GeneralAI } from '../core.js';
|
||||||
|
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||||
import { calcCityDevRatio } from '../../aiUtils.js';
|
import { calcCityDevRatio } from '../../aiUtils.js';
|
||||||
import { searchAllDistanceByCityList } from '../../distance.js';
|
import { searchAllDistanceByCityList } from '../../distance.js';
|
||||||
|
|
||||||
@@ -9,6 +10,33 @@ export const do천도 = (ai: GeneralAI) => {
|
|||||||
if (!ai.map) {
|
if (!ai.map) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastTurn = ai.getLastNationTurn();
|
||||||
|
const lastArgs =
|
||||||
|
lastTurn.arg && typeof lastTurn.arg === 'object' ? (lastTurn.arg as Record<string, unknown>) : null;
|
||||||
|
const lastDestination = Number(lastArgs?.destCityID ?? lastArgs?.destCityId);
|
||||||
|
if (
|
||||||
|
lastTurn.command === '천도' &&
|
||||||
|
Number.isFinite(lastDestination) &&
|
||||||
|
lastDestination !== ai.nation.capitalCityId
|
||||||
|
) {
|
||||||
|
const continuing = ai.buildNationCandidate('che_천도', { destCityID: lastDestination }, '천도');
|
||||||
|
if (continuing) {
|
||||||
|
ai.markCapitalMoveTrial();
|
||||||
|
return continuing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastTrial = ai.getLastCapitalMoveTrial();
|
||||||
|
const currentTurnTick = ai.general.turnTick;
|
||||||
|
if (
|
||||||
|
lastTrial &&
|
||||||
|
currentTurnTick !== undefined &&
|
||||||
|
Math.abs(currentTurnTick - lastTrial[1]) < Math.floor(GAME_TICKS_PER_TURN / 2) &&
|
||||||
|
lastTrial[0] !== ai.general.officerLevel
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const nationCities = Object.values(ai.nationCities);
|
const nationCities = Object.values(ai.nationCities);
|
||||||
if (nationCities.length <= 1) {
|
if (nationCities.length <= 1) {
|
||||||
return null;
|
return null;
|
||||||
@@ -80,5 +108,7 @@ export const do천도 = (ai: GeneralAI) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ai.buildNationCandidate('che_천도', { destCityID: targetCityId }, '천도');
|
const candidate = ai.buildNationCandidate('che_천도', { destCityID: targetCityId }, '천도');
|
||||||
|
if (candidate) ai.markCapitalMoveTrial();
|
||||||
|
return candidate;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ const normalizeGeneralMetaDatabaseIntegers = (meta: TurnGeneral['meta']): TurnGe
|
|||||||
// Keeping fractional action results in memory until the monthly flush changes
|
// Keeping fractional action results in memory until the monthly flush changes
|
||||||
// later aggregation (notably nation power), even if the eventual DB rows look
|
// later aggregation (notably nation power), even if the eventual DB rows look
|
||||||
// identical after they are rounded.
|
// identical after they are rounded.
|
||||||
const normalizeGeneralDatabaseIntegers = (general: TurnGeneral): TurnGeneral => ({
|
export const normalizeGeneralDatabaseIntegers = (general: TurnGeneral): TurnGeneral => ({
|
||||||
...general,
|
...general,
|
||||||
nationId: toLegacyDatabaseInt(general.nationId),
|
nationId: toLegacyDatabaseInt(general.nationId),
|
||||||
cityId: toLegacyDatabaseInt(general.cityId),
|
cityId: toLegacyDatabaseInt(general.cityId),
|
||||||
@@ -1236,11 +1236,26 @@ export class InMemoryTurnWorld {
|
|||||||
|
|
||||||
const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule);
|
const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule);
|
||||||
if (!result.deleted?.general) {
|
if (!result.deleted?.general) {
|
||||||
|
const resolvedGeneral = result.general ?? currentGeneral;
|
||||||
|
const clock = this.getGameClock();
|
||||||
|
const currentTurnTick = currentGeneral.turnTick ?? clock.dateToTick(currentGeneral.turnTime);
|
||||||
|
const nextTurnTick =
|
||||||
|
currentTurnTick + (clock.dateToTick(nextTurnAt) - clock.dateToTick(currentGeneral.turnTime));
|
||||||
|
const recentWarTimeChanged =
|
||||||
|
(resolvedGeneral.recentWarTime?.getTime() ?? null) !==
|
||||||
|
(currentGeneral.recentWarTime?.getTime() ?? null);
|
||||||
|
const recentWarTickChanged = resolvedGeneral.recentWarTick !== currentGeneral.recentWarTick;
|
||||||
const nextGeneral = this.normalizeGeneralClock(
|
const nextGeneral = this.normalizeGeneralClock(
|
||||||
normalizeGeneralDatabaseIntegers({
|
normalizeGeneralDatabaseIntegers({
|
||||||
...(result.general ?? currentGeneral),
|
...resolvedGeneral,
|
||||||
turnTime: nextTurnAt,
|
turnTime: nextTurnAt,
|
||||||
turnTick: undefined,
|
// Ref advances the logical tick directly. Re-encoding the
|
||||||
|
// millisecond Date would discard its sub-millisecond tail.
|
||||||
|
turnTick: nextTurnTick,
|
||||||
|
// A loaded row always carries recentWarTick (often null).
|
||||||
|
// When battle logic changes recentWarTime, discard that
|
||||||
|
// stale tick so normalizeGeneralClock derives the new one.
|
||||||
|
...(recentWarTimeChanged && !recentWarTickChanged ? { recentWarTick: undefined } : {}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
this.generals.set(nextGeneral.id, nextGeneral);
|
this.generals.set(nextGeneral.id, nextGeneral);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic';
|
import type { TurnCommandEnv } from '@sammo-ts/logic';
|
||||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||||
|
|
||||||
@@ -25,18 +25,24 @@ const resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => {
|
|||||||
return typeof value === 'string' || typeof value === 'number' ? value : String(value);
|
return typeof value === 'string' || typeof value === 'number' ? value : String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTurnTime = (
|
const createTurnClock = (
|
||||||
rng: RandUtil,
|
rng: RandUtil,
|
||||||
environment: MonthlyEventEnvironment,
|
environment: MonthlyEventEnvironment,
|
||||||
tickSeconds: number
|
world: InMemoryTurnWorld
|
||||||
): Date => {
|
): { turnTime: Date; turnTick: number } => {
|
||||||
|
const tickSeconds = world.getState().tickSeconds;
|
||||||
const turnMinutes = tickSeconds / 60;
|
const turnMinutes = tickSeconds / 60;
|
||||||
if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) {
|
if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) {
|
||||||
throw new Error('ProvideNPCTroopLeader requires a positive integer turn term.');
|
throw new Error('ProvideNPCTroopLeader requires a positive integer turn term.');
|
||||||
}
|
}
|
||||||
const seconds = rng.nextRangeInt(0, turnMinutes * 60 - 1);
|
const seconds = rng.nextRangeInt(0, turnMinutes * 60 - 1);
|
||||||
const fraction = rng.nextRangeInt(0, 999_999);
|
const fraction = rng.nextRangeInt(0, 999_999);
|
||||||
return new Date(environment.turnTime.getTime() + seconds * 1_000 + Math.floor(fraction / 1_000));
|
const ticksPerSecond = GAME_TICKS_PER_TURN / tickSeconds;
|
||||||
|
const turnTick =
|
||||||
|
world.dateToGameTick(environment.turnTime) +
|
||||||
|
seconds * ticksPerSecond +
|
||||||
|
Math.floor((fraction * ticksPerSecond) / 1_000_000);
|
||||||
|
return { turnTime: world.gameTickToDate(turnTick), turnTick };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createProvideNpcTroopLeaderHandler = (options: {
|
export const createProvideNpcTroopLeaderHandler = (options: {
|
||||||
@@ -51,9 +57,7 @@ export const createProvideNpcTroopLeaderHandler = (options: {
|
|||||||
}
|
}
|
||||||
const currentLastId = world.getState().meta.lastNPCTroopLeaderID;
|
const currentLastId = world.getState().meta.lastNPCTroopLeaderID;
|
||||||
let lastNpcTroopLeaderId =
|
let lastNpcTroopLeaderId =
|
||||||
typeof currentLastId === 'number' && Number.isFinite(currentLastId)
|
typeof currentLastId === 'number' && Number.isFinite(currentLastId) ? Math.trunc(currentLastId) : 0;
|
||||||
? Math.trunc(currentLastId)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
for (const nation of world.listNations().sort((left, right) => left.id - right.id)) {
|
for (const nation of world.listNations().sort((left, right) => left.id - right.id)) {
|
||||||
const maximum = MAX_LEADERS_BY_NATION_LEVEL[nation.level] ?? 0;
|
const maximum = MAX_LEADERS_BY_NATION_LEVEL[nation.level] ?? 0;
|
||||||
@@ -85,6 +89,7 @@ export const createProvideNpcTroopLeaderHandler = (options: {
|
|||||||
const city = rng.choice(cityPool);
|
const city = rng.choice(cityPool);
|
||||||
const id = world.getNextGeneralId();
|
const id = world.getNextGeneralId();
|
||||||
const age = 20;
|
const age = 20;
|
||||||
|
const turnClock = createTurnClock(rng, environment, world);
|
||||||
const general: TurnGeneral = {
|
const general: TurnGeneral = {
|
||||||
id,
|
id,
|
||||||
userId: null,
|
userId: null,
|
||||||
@@ -117,7 +122,7 @@ export const createProvideNpcTroopLeaderHandler = (options: {
|
|||||||
picture: 'default.jpg',
|
picture: 'default.jpg',
|
||||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
lastTurn: { command: '휴식' },
|
lastTurn: { command: '휴식' },
|
||||||
turnTime: createTurnTime(rng, environment, world.getState().tickSeconds),
|
...turnClock,
|
||||||
recentWarTime: null,
|
recentWarTime: null,
|
||||||
meta: {
|
meta: {
|
||||||
killturn: 70,
|
killturn: 70,
|
||||||
|
|||||||
@@ -149,13 +149,9 @@ export const createAssignGeneralSpecialityHandler = (options: {
|
|||||||
const defaultWar = normalizeCode(world.getScenarioConfig().const.defaultSpecialWar);
|
const defaultWar = normalizeCode(world.getScenarioConfig().const.defaultSpecialWar);
|
||||||
const retirementYear = readRuntimeNumber(world, 'retirementYear', 80);
|
const retirementYear = readRuntimeNumber(world, 'retirementYear', 80);
|
||||||
const scenarioStat = world.getScenarioConfig().stat;
|
const scenarioStat = world.getScenarioConfig().stat;
|
||||||
// ref SQL에 ORDER BY가 없으므로 loader가 보존한 DB scan 순서를 두
|
// Ref explicitly orders both speciality passes by general.no. This
|
||||||
// domestic/war pass에서 그대로 재사용한다.
|
// avoids leaking Aria's deleted-page reuse order into gameplay RNG.
|
||||||
const generals = world.listGenerals().sort((left, right) => {
|
const generals = world.listGenerals().sort((left, right) => left.id - right.id);
|
||||||
const leftOrder = readFiniteNumber(left.meta, ['legacyScanOrder']) ?? left.id;
|
|
||||||
const rightOrder = readFiniteNumber(right.meta, ['legacyScanOrder']) ?? right.id;
|
|
||||||
return leftOrder - rightOrder;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const general of generals) {
|
for (const general of generals) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } fr
|
|||||||
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { GeneralTurnHandler, GeneralTurnResult } from './inMemoryWorld.js';
|
import type { GeneralTurnHandler, GeneralTurnResult } from './inMemoryWorld.js';
|
||||||
|
import { normalizeGeneralDatabaseIntegers } from './inMemoryWorld.js';
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
import type { TurnDiplomacy, TurnGeneral, TurnWorldState } from './types.js';
|
import type { TurnDiplomacy, TurnGeneral, TurnWorldState } from './types.js';
|
||||||
import type { ReservedTurnEntry } from './reservedTurnStore.js';
|
import type { ReservedTurnEntry } from './reservedTurnStore.js';
|
||||||
@@ -1691,6 +1692,12 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
nationAiState = ai.getDebugState();
|
nationAiState = ai.getDebugState();
|
||||||
}
|
}
|
||||||
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
|
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
|
||||||
|
// Ref persists a completed nation command before it chooses and
|
||||||
|
// executes the general command for the same turn. Preserve that
|
||||||
|
// MariaDB INT boundary so fractional rewards cannot leak into the
|
||||||
|
// following command or its AI refresh.
|
||||||
|
currentGeneral = normalizeGeneralDatabaseIntegers(currentGeneral);
|
||||||
|
worldOverlay?.syncGeneral(currentGeneral);
|
||||||
if (
|
if (
|
||||||
worldView &&
|
worldView &&
|
||||||
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))
|
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))
|
||||||
@@ -1760,6 +1767,18 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
nationFallback,
|
nationFallback,
|
||||||
});
|
});
|
||||||
const candidate = ai.chooseGeneralTurn(generalCommand);
|
const candidate = ai.chooseGeneralTurn(generalCommand);
|
||||||
|
// Ref GeneralAI::calcDiplomacyState writes
|
||||||
|
// nation_env.last_attackable for ordinary generals too. The
|
||||||
|
// nation-turn path consumes this patch above, but most NPCs
|
||||||
|
// never execute a nation turn.
|
||||||
|
const generalAiNationState = ai.consumePromotionPatches();
|
||||||
|
if (generalAiNationState.nationMeta && currentNation) {
|
||||||
|
currentNation = {
|
||||||
|
...currentNation,
|
||||||
|
meta: generalAiNationState.nationMeta as Nation['meta'],
|
||||||
|
};
|
||||||
|
worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta });
|
||||||
|
}
|
||||||
const npcMessage = ai.consumeNpcMessage();
|
const npcMessage = ai.consumeNpcMessage();
|
||||||
if (npcMessage) {
|
if (npcMessage) {
|
||||||
const messageTarget = {
|
const messageTarget = {
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { City, General, Nation } from '@sammo-ts/logic';
|
import type { City, General, Nation } from '@sammo-ts/logic';
|
||||||
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
|
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
|
||||||
|
|
||||||
import type { GeneralAI } from '../src/turn/ai/generalAi.js';
|
import { GeneralAI } from '../src/turn/ai/generalAi.js';
|
||||||
import { resolveLegacyAiStats } from '../src/turn/ai/generalAi/core.js';
|
import {
|
||||||
|
calculateRecentWarTurn,
|
||||||
|
resolveLegacyAiStats,
|
||||||
|
resolveLegacyAiStatsWithModules,
|
||||||
|
} from '../src/turn/ai/generalAi/core.js';
|
||||||
import { withCanonicalArgumentAliases } from '../src/turn/ai/aiUtils.js';
|
import { withCanonicalArgumentAliases } from '../src/turn/ai/aiUtils.js';
|
||||||
import { do일반내정, do전쟁내정 } from '../src/turn/ai/generalAi/general/devActions.js';
|
import { do일반내정, do전쟁내정 } from '../src/turn/ai/generalAi/general/devActions.js';
|
||||||
import { do금쌀구매 } from '../src/turn/ai/generalAi/general/economyActions.js';
|
import { do금쌀구매 } from '../src/turn/ai/generalAi/general/economyActions.js';
|
||||||
@@ -12,7 +16,12 @@ import { do징병 } from '../src/turn/ai/generalAi/general/recruitActions.js';
|
|||||||
import { do전투준비, do출병 } from '../src/turn/ai/generalAi/general/warActions.js';
|
import { do전투준비, do출병 } from '../src/turn/ai/generalAi/general/warActions.js';
|
||||||
import { do내정워프, do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js';
|
import { do내정워프, do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js';
|
||||||
import { doNPC몰수, doNPC포상, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js';
|
import { doNPC몰수, doNPC포상, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js';
|
||||||
import { doNPC전방발령, doNPC후방발령 } from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js';
|
import { do천도 } from '../src/turn/ai/generalAi/nation/capital.js';
|
||||||
|
import {
|
||||||
|
doNPC구출발령,
|
||||||
|
doNPC전방발령,
|
||||||
|
doNPC후방발령,
|
||||||
|
} from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js';
|
||||||
|
|
||||||
type Candidate = {
|
type Candidate = {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -125,6 +134,19 @@ const baseGeneral = (): General & { turnTime: Date } => ({
|
|||||||
meta: { killturn: 100, fullLeadership: 70 },
|
meta: { killturn: 100, fullLeadership: 70 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GeneralAI recent war clock parity', () => {
|
||||||
|
it('uses raw logical ticks at an exact turn boundary', () => {
|
||||||
|
const general = {
|
||||||
|
...baseGeneral(),
|
||||||
|
turnTick: 72_000_099,
|
||||||
|
recentWarTick: 36_000_100,
|
||||||
|
recentWarTime: new Date('0189-12-31T23:50:00.000Z'),
|
||||||
|
} as ReturnType<typeof baseGeneral> & { turnTick: number; recentWarTick: number; recentWarTime: Date };
|
||||||
|
|
||||||
|
expect(calculateRecentWarTurn(general, 10)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const baseCity = (): City => ({
|
const baseCity = (): City => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
name: '가상도시',
|
name: '가상도시',
|
||||||
@@ -376,6 +398,52 @@ const makeAi = (
|
|||||||
* selection and RNG-sensitive gates, not TypeScript implementation details.
|
* selection and RNG-sensitive gates, not TypeScript implementation details.
|
||||||
*/
|
*/
|
||||||
describe('legacy NPC AI final-decision parity', () => {
|
describe('legacy NPC AI final-decision parity', () => {
|
||||||
|
it('blocks another officer from starting a capital move within half a turn', () => {
|
||||||
|
const base = makeAi({ general: { officerLevel: 10, turnTick: 36_000_100 } });
|
||||||
|
const ai = Object.assign(Object.create(GeneralAI.prototype), base, {
|
||||||
|
nation: { ...base.nation!, meta: { ...base.nation!.meta, lastCapitalMoveTrial: [12, 36_000_000] } },
|
||||||
|
}) as GeneralAI;
|
||||||
|
|
||||||
|
expect(do천도(ai)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues the same capital move and records the legacy trial tick', () => {
|
||||||
|
const base = makeAi({ general: { officerLevel: 12, turnTick: 72_000_100 } });
|
||||||
|
const ai = Object.assign(Object.create(GeneralAI.prototype), base, {
|
||||||
|
nation: {
|
||||||
|
...base.nation!,
|
||||||
|
capitalCityId: 1,
|
||||||
|
meta: { ...base.nation!.meta, turn_last_12: { command: '천도', arg: { destCityID: 2 } } },
|
||||||
|
},
|
||||||
|
promotionPatches: [],
|
||||||
|
promotionNationMeta: null,
|
||||||
|
}) as GeneralAI;
|
||||||
|
|
||||||
|
expect(do천도(ai)).toMatchObject({ action: 'che_천도', args: { destCityID: 2 } });
|
||||||
|
expect(ai.consumePromotionPatches().nationMeta).toMatchObject({
|
||||||
|
lastCapitalMoveTrial: [12, 72_000_100],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the legacy last-attackable month through the nation meta patch channel', () => {
|
||||||
|
const ai = Object.assign(Object.create(GeneralAI.prototype), {
|
||||||
|
general: { ...baseGeneral(), nationId: 16 },
|
||||||
|
nation: { ...baseNation(), id: 16, meta: { last_attackable: 2234 } },
|
||||||
|
world: { currentYear: 187, currentMonth: 2, meta: {} },
|
||||||
|
worldRef: {
|
||||||
|
listDiplomacy: () => [{ fromNationId: 16, toNationId: 2, state: 0, term: 0 }],
|
||||||
|
listCities: () => [{ ...baseCity(), nationId: 16, frontState: 3 }],
|
||||||
|
},
|
||||||
|
startYear: 180,
|
||||||
|
promotionPatches: [],
|
||||||
|
promotionNationMeta: { last_attackable: 2234, chief_set: 3584 },
|
||||||
|
}) as GeneralAI;
|
||||||
|
|
||||||
|
(ai as unknown as { calcDiplomacyState: () => void }).calcDiplomacyState();
|
||||||
|
|
||||||
|
expect(ai.consumePromotionPatches().nationMeta).toMatchObject({ last_attackable: 2245, chief_set: 3584 });
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes legacy uppercase destination IDs before AI constraint checks', () => {
|
it('normalizes legacy uppercase destination IDs before AI constraint checks', () => {
|
||||||
expect(
|
expect(
|
||||||
withCanonicalArgumentAliases({
|
withCanonicalArgumentAliases({
|
||||||
@@ -409,6 +477,32 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
effectiveLeadership: 70,
|
effectiveLeadership: 70,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies active action modules to the full stats used by legacy AI recruitment', () => {
|
||||||
|
const general = {
|
||||||
|
...baseGeneral(),
|
||||||
|
stats: { leadership: 68, strength: 40, intelligence: 60 },
|
||||||
|
meta: { killturn: 100 },
|
||||||
|
};
|
||||||
|
const leadershipTrait = {
|
||||||
|
onCalcStat: (context: { general: General }, statName: string, value: number): number =>
|
||||||
|
statName === 'leadership' ? value + context.general.stats.leadership * 0.25 : value,
|
||||||
|
};
|
||||||
|
const modules = singleActionModuleStack(leadershipTrait);
|
||||||
|
const world = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 189,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('0189-01-01T00:00:00Z'),
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveLegacyAiStatsWithModules(general, baseNation(), 100, modules, null, world, 180)).toMatchObject({
|
||||||
|
fullLeadership: 85,
|
||||||
|
effectiveLeadership: 85,
|
||||||
|
});
|
||||||
|
});
|
||||||
it.each([
|
it.each([
|
||||||
['Core scenario name', '강유'],
|
['Core scenario name', '강유'],
|
||||||
['Ref stored name', 'ⓝ강유'],
|
['Ref stored name', 'ⓝ강유'],
|
||||||
@@ -535,6 +629,35 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the refillable same-type crew amount for the legacy gold-cost halving threshold', () => {
|
||||||
|
const ai = makeAi({
|
||||||
|
dipState: 2,
|
||||||
|
general: {
|
||||||
|
gold: 1_030,
|
||||||
|
rice: 970,
|
||||||
|
crew: 334,
|
||||||
|
crewTypeId: 1,
|
||||||
|
meta: {
|
||||||
|
killturn: 100,
|
||||||
|
fullLeadership: 70,
|
||||||
|
rank_killcrew: 1_000,
|
||||||
|
rank_deathcrew: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generalActionModules: singleActionModuleStack({
|
||||||
|
eventHandlers: {},
|
||||||
|
onCalcDomestic: (_context, turnType, varType, value) =>
|
||||||
|
turnType === '징병' && varType === 'cost' ? value * 1.2 : value,
|
||||||
|
}),
|
||||||
|
rng: makeRng([], [0, 0]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ref prices only the 6,666 refillable soldiers: 800 gold, below the
|
||||||
|
// 820-gold reserve. It therefore keeps the full rice requirement and
|
||||||
|
// rejects recruitment, instead of halving both crew and rice cost.
|
||||||
|
expect(do징병(ai)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[0, 2000],
|
[0, 2000],
|
||||||
@@ -970,6 +1093,24 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
expect(rng.choices).toEqual([0, 0]);
|
expect(rng.choices).toEqual([0, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws a rescue city for every lost NPC before choosing the completed pair', () => {
|
||||||
|
const rng = makeRng([], [0, 1, 1]);
|
||||||
|
const first = { ...baseGeneral(), id: 2 };
|
||||||
|
const second = { ...baseGeneral(), id: 3 };
|
||||||
|
const ai = makeAi({ rng });
|
||||||
|
ai.lostGenerals = { 2: first, 3: second };
|
||||||
|
ai.supplyCities = {
|
||||||
|
40: { ...baseCity(), id: 40, dev: 1, important: 1 },
|
||||||
|
64: { ...baseCity(), id: 64, dev: 1, important: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(doNPC구출발령(ai)).toMatchObject({
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralId: 3, destCityId: 64 },
|
||||||
|
});
|
||||||
|
expect(rng.choices).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('draws the NPC front-assignment general before the weighted destination city', () => {
|
it('draws the NPC front-assignment general before the weighted destination city', () => {
|
||||||
const rng = makeRng([], [1, 20]);
|
const rng = makeRng([], [1, 20]);
|
||||||
const first = { ...baseGeneral(), id: 2, crew: 3000, train: 100, atmos: 100 };
|
const first = { ...baseGeneral(), id: 2, crew: 3000, train: 100, atmos: 100 };
|
||||||
|
|||||||
@@ -146,11 +146,7 @@ describe('ProvideNPCTroopLeader monthly action', () => {
|
|||||||
|
|
||||||
const created = world.peekDirtyState().createdGenerals;
|
const created = world.peekDirtyState().createdGenerals;
|
||||||
expect(created).toHaveLength(3);
|
expect(created).toHaveLength(3);
|
||||||
expect(created.map((general) => general.name)).toEqual([
|
expect(created.map((general) => general.name)).toEqual(['㉥부대장 9', '㉥부대장 10', '㉥부대장 11']);
|
||||||
'㉥부대장 9',
|
|
||||||
'㉥부대장 10',
|
|
||||||
'㉥부대장 11',
|
|
||||||
]);
|
|
||||||
expect(created[0]).toMatchObject({
|
expect(created[0]).toMatchObject({
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
cityId: process.env.REF_HIDDEN_SEED ? 2 : 1,
|
cityId: process.env.REF_HIDDEN_SEED ? 2 : 1,
|
||||||
@@ -177,6 +173,9 @@ describe('ProvideNPCTroopLeader monthly action', () => {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
for (const general of created) {
|
for (const general of created) {
|
||||||
|
expect(general.turnTick).toBeTypeOf('number');
|
||||||
|
expect(general.turnTick! - world.dateToGameTick(general.turnTime)).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(general.turnTick! - world.dateToGameTick(general.turnTime)).toBeLessThan(60);
|
||||||
expect(reservedTurns.getGeneralTurns(general.id)).toEqual(
|
expect(reservedTurns.getGeneralTurns(general.id)).toEqual(
|
||||||
Array.from({ length: 30 }, () => ({ action: 'che_집합', args: {} }))
|
Array.from({ length: 30 }, () => ({ action: 'che_집합', args: {} }))
|
||||||
);
|
);
|
||||||
@@ -186,11 +185,9 @@ describe('ProvideNPCTroopLeader monthly action', () => {
|
|||||||
const probe = new RandUtil(
|
const probe = new RandUtil(
|
||||||
new LiteHashDRBG(simpleSerialize(process.env.REF_HIDDEN_SEED, 'troopLeader', 200, 1, 1))
|
new LiteHashDRBG(simpleSerialize(process.env.REF_HIDDEN_SEED, 'troopLeader', 200, 1, 1))
|
||||||
);
|
);
|
||||||
expect([
|
expect([probe.choice([1, 2]), probe.nextRangeInt(0, 599), probe.nextRangeInt(0, 999_999)]).toEqual([
|
||||||
probe.choice([1, 2]),
|
2, 567, 821_811,
|
||||||
probe.nextRangeInt(0, 599),
|
]);
|
||||||
probe.nextRangeInt(0, 999_999),
|
|
||||||
]).toEqual([2, 567, 821_811]);
|
|
||||||
expect(
|
expect(
|
||||||
created.map((general) => ({
|
created.map((general) => ({
|
||||||
cityId: general.cityId,
|
cityId: general.cityId,
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ describe('monthly speciality and betrayal actions', () => {
|
|||||||
|
|
||||||
const logs = world.peekDirtyState().logs;
|
const logs = world.peekDirtyState().logs;
|
||||||
expect(logs).toHaveLength(6);
|
expect(logs).toHaveLength(6);
|
||||||
expect(logs.slice(2, 4)).toEqual([
|
expect(logs.filter((log) => log.generalId === 3)).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
generalId: 3,
|
generalId: 3,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
@@ -199,7 +199,7 @@ describe('monthly speciality and betrayal actions', () => {
|
|||||||
expect(world.getGeneralById(1)?.role.specialWar).not.toBeNull();
|
expect(world.getGeneralById(1)?.role.specialWar).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists creation scan order for speciality RNG across a reload', async () => {
|
it('uses general ID order instead of persisted Aria scan order', async () => {
|
||||||
const world = buildWorld();
|
const world = buildWorld();
|
||||||
const laterId = buildGeneral({
|
const laterId = buildGeneral({
|
||||||
id: 5,
|
id: 5,
|
||||||
@@ -249,7 +249,7 @@ describe('monthly speciality and betrayal actions', () => {
|
|||||||
.peekDirtyState()
|
.peekDirtyState()
|
||||||
.logs.filter((log) => log.category === LogCategory.HISTORY)
|
.logs.filter((log) => log.category === LogCategory.HISTORY)
|
||||||
.map((log) => log.generalId)
|
.map((log) => log.generalId)
|
||||||
).toEqual([1, 5, 4, 3, 2]);
|
).toEqual([1, 4, 5, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies the two default scenario betrayal steps only to values within each threshold', async () => {
|
it('applies the two default scenario betrayal steps only to values within each threshold', async () => {
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ describe('InMemoryTurnProcessor ordering', () => {
|
|||||||
|
|
||||||
const generals: TurnGeneral[] = [
|
const generals: TurnGeneral[] = [
|
||||||
buildGeneral(1, addMinutes(baseTime, 20)),
|
buildGeneral(1, addMinutes(baseTime, 20)),
|
||||||
buildGeneral(2, addMinutes(baseTime, 10)),
|
{
|
||||||
|
...buildGeneral(2, addMinutes(baseTime, 10)),
|
||||||
|
turnTick: 6_000_004,
|
||||||
|
recentWarTime: null,
|
||||||
|
recentWarTick: null,
|
||||||
|
},
|
||||||
buildGeneral(3, addMinutes(baseTime, 10)),
|
buildGeneral(3, addMinutes(baseTime, 10)),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -140,6 +145,11 @@ describe('InMemoryTurnProcessor ordering', () => {
|
|||||||
|
|
||||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
generalTurnHandler: {
|
||||||
|
execute: ({ general }) => ({
|
||||||
|
general: general.id === 2 ? { ...general, recentWarTime: new Date(baseTime.getTime()) } : general,
|
||||||
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const executed: number[] = [];
|
const executed: number[] = [];
|
||||||
@@ -163,6 +173,9 @@ describe('InMemoryTurnProcessor ordering', () => {
|
|||||||
const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget);
|
const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget);
|
||||||
expect(tiedGeneralResult.processedTurns).toBe(0);
|
expect(tiedGeneralResult.processedTurns).toBe(0);
|
||||||
expect(executed).toEqual([2, 3]);
|
expect(executed).toEqual([2, 3]);
|
||||||
|
expect(world.getGeneralById(2)?.recentWarTime?.getTime()).toBe(baseTime.getTime());
|
||||||
|
expect(world.getGeneralById(2)?.recentWarTick).not.toBeNull();
|
||||||
|
expect(Number(world.getGeneralById(2)?.turnTick) % 10).toBe(4);
|
||||||
|
|
||||||
await processor.run(addMinutes(baseTime, 30), budget);
|
await processor.run(addMinutes(baseTime, 30), budget);
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ const DEFAULT_WAR_CONFIG = {
|
|||||||
const DEFAULT_AFTER_CONFIG = {
|
const DEFAULT_AFTER_CONFIG = {
|
||||||
techLevelIncYear: 5,
|
techLevelIncYear: 5,
|
||||||
initialAllowedTechLevel: 1,
|
initialAllowedTechLevel: 1,
|
||||||
|
maxTechLevel: 12,
|
||||||
defaultCityWall: 1000,
|
defaultCityWall: 1000,
|
||||||
baseGold: 0,
|
baseGold: 0,
|
||||||
baseRice: 2000,
|
baseRice: 2000,
|
||||||
@@ -235,7 +236,7 @@ export const buildWarAftermathConfig = (
|
|||||||
['initialAllowedTechLevel'],
|
['initialAllowedTechLevel'],
|
||||||
DEFAULT_AFTER_CONFIG.initialAllowedTechLevel
|
DEFAULT_AFTER_CONFIG.initialAllowedTechLevel
|
||||||
),
|
),
|
||||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
|
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_AFTER_CONFIG.maxTechLevel),
|
||||||
defaultCityWall: resolveNumber(constValues, ['defaultCityWall'], DEFAULT_AFTER_CONFIG.defaultCityWall),
|
defaultCityWall: resolveNumber(constValues, ['defaultCityWall'], DEFAULT_AFTER_CONFIG.defaultCityWall),
|
||||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_AFTER_CONFIG.baseGold),
|
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_AFTER_CONFIG.baseGold),
|
||||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_AFTER_CONFIG.baseRice),
|
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_AFTER_CONFIG.baseRice),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
|
|||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||||
|
|
||||||
const ACTION_NAME = '임관';
|
const ACTION_NAME = '임관';
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
@@ -43,7 +44,11 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, AppointmentArgs> {
|
> implements GeneralActionDefinition<TriggerState, AppointmentArgs> {
|
||||||
public readonly key = 'che_임관';
|
public readonly key = 'che_임관';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
constructor(private readonly env: TurnCommandEnv) {}
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
|
||||||
|
constructor(private readonly env: TurnCommandEnv) {
|
||||||
|
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
getInheritanceActiveActionAmount(): number {
|
getInheritanceActiveActionAmount(): number {
|
||||||
return 1;
|
return 1;
|
||||||
@@ -116,7 +121,11 @@ export class ActionDefinition<
|
|||||||
troopId: 0,
|
troopId: 0,
|
||||||
experience:
|
experience:
|
||||||
context.general.experience +
|
context.general.experience +
|
||||||
(context.destNationGeneralCount < this.env.initialNationGenLimit ? 700 : 100),
|
this.pipeline.onCalcStat(
|
||||||
|
context,
|
||||||
|
'experience',
|
||||||
|
context.destNationGeneralCount < this.env.initialNationGenLimit ? 700 : 100
|
||||||
|
),
|
||||||
meta: {
|
meta: {
|
||||||
...context.general.meta,
|
...context.general.meta,
|
||||||
officer_city: 0,
|
officer_city: 0,
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ export interface DispatchResolveContext<
|
|||||||
aftermathConfig: WarAftermathConfig;
|
aftermathConfig: WarAftermathConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const orderDefenderGenerals = <TriggerState extends GeneralTriggerState>(
|
||||||
|
generals: General<TriggerState>[]
|
||||||
|
): General<TriggerState>[] => [...generals].sort((left, right) => left.id - right.id);
|
||||||
|
|
||||||
const ACTION_NAME = '출병';
|
const ACTION_NAME = '출병';
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destCityId: z.number(),
|
destCityId: z.number(),
|
||||||
@@ -86,7 +90,12 @@ const fixtureNumber = (value: unknown, fallback = 0): number =>
|
|||||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
|
||||||
const formatFixtureDate = (value: Date | undefined): string =>
|
const formatFixtureDate = (value: Date | undefined): string =>
|
||||||
value ? value.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, '') : '1970-01-01 00:00:00';
|
value
|
||||||
|
? value
|
||||||
|
.toISOString()
|
||||||
|
.replace('T', ' ')
|
||||||
|
.replace(/\.\d{3}Z$/u, '')
|
||||||
|
: '1970-01-01 00:00:00';
|
||||||
|
|
||||||
const buildBattleGeneralFixture = <TriggerState extends GeneralTriggerState>(general: General<TriggerState>) => {
|
const buildBattleGeneralFixture = <TriggerState extends GeneralTriggerState>(general: General<TriggerState>) => {
|
||||||
const meta = general.meta;
|
const meta = general.meta;
|
||||||
@@ -105,9 +114,7 @@ const buildBattleGeneralFixture = <TriggerState extends GeneralTriggerState>(gen
|
|||||||
inheritBuff = parsed;
|
inheritBuff = parsed;
|
||||||
} else if (typeof parsed === 'object' && parsed !== null) {
|
} else if (typeof parsed === 'object' && parsed !== null) {
|
||||||
inheritBuff = Object.fromEntries(
|
inheritBuff = Object.fromEntries(
|
||||||
Object.entries(parsed).filter(
|
Object.entries(parsed).filter((entry): entry is [string, number] => typeof entry[1] === 'number')
|
||||||
(entry): entry is [string, number] => typeof entry[1] === 'number'
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -556,12 +563,14 @@ export class ActionDefinition<
|
|||||||
defenderCity.meta.term = 3;
|
defenderCity.meta.term = 3;
|
||||||
const defenderNation = defenderCity.nationId > 0 ? (nationMap.get(defenderCity.nationId) ?? null) : null;
|
const defenderNation = defenderCity.nationId > 0 ? (nationMap.get(defenderCity.nationId) ?? null) : null;
|
||||||
|
|
||||||
const defenderGenerals = generals.filter(
|
const defenderGenerals = orderDefenderGenerals(
|
||||||
(general) =>
|
generals.filter(
|
||||||
general.cityId === defenderCity.id &&
|
(general) =>
|
||||||
general.nationId === defenderCity.nationId &&
|
general.cityId === defenderCity.id &&
|
||||||
general.crew > 0 &&
|
general.nationId === defenderCity.nationId &&
|
||||||
(unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false)
|
general.crew > 0 &&
|
||||||
|
(unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const traceGeneralIds = new Set(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []);
|
const traceGeneralIds = new Set(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []);
|
||||||
const shouldTraceWar =
|
const shouldTraceWar =
|
||||||
@@ -647,9 +656,7 @@ export class ActionDefinition<
|
|||||||
// to deploy. Preserve that ordering before snapshotting city effects.
|
// to deploy. Preserve that ordering before snapshotting city effects.
|
||||||
let frontStatePatches: Array<{ id: number; frontState: number }> = [];
|
let frontStatePatches: Array<{ id: number; frontState: number }> = [];
|
||||||
if (battle.conquered && context.map && context.diplomacy) {
|
if (battle.conquered && context.map && context.diplomacy) {
|
||||||
const connections = new Map(
|
const connections = new Map(context.map.cities.map((city) => [city.id, city.connections ?? []] as const));
|
||||||
context.map.cities.map((city) => [city.id, city.connections ?? []] as const)
|
|
||||||
);
|
|
||||||
const nearbyCityIds = new Set([defenderCity.id, ...(connections.get(defenderCity.id) ?? [])]);
|
const nearbyCityIds = new Set([defenderCity.id, ...(connections.get(defenderCity.id) ?? [])]);
|
||||||
const nearbyNationIds = new Set<number>([aftermath.conquest?.conquerNationId ?? attackerNation.id]);
|
const nearbyNationIds = new Set<number>([aftermath.conquest?.conquerNationId ?? attackerNation.id]);
|
||||||
for (const city of cities) {
|
for (const city of cities) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* the value rounded to six significant decimal digits on the next read.
|
* the value rounded to six significant decimal digits on the next read.
|
||||||
* Keep those boundaries separate so later SQL expressions use binary32 state.
|
* Keep those boundaries separate so later SQL expressions use binary32 state.
|
||||||
*/
|
*/
|
||||||
export const storeLegacyCityTrust = (value: number): number => Math.fround(value);
|
import { readLegacyStoredFloat, toLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js';
|
||||||
|
|
||||||
export const readLegacyCityTrust = (value: number): number => Number(Math.fround(value).toPrecision(6));
|
export const storeLegacyCityTrust = (value: number): number => toLegacyStoredFloat(value);
|
||||||
|
|
||||||
|
export const readLegacyCityTrust = (value: number): number => readLegacyStoredFloat(value);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type { NationTurnCommandSpec } from './index.js';
|
|||||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
|
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destCityID: z.preprocess(normalizeLegacyIntegerArg, z.number()),
|
destCityID: z.preprocess(normalizeLegacyIntegerArg, z.number()),
|
||||||
@@ -109,8 +110,11 @@ export class ActionDefinition<
|
|||||||
public readonly key = 'che_천도';
|
public readonly key = 'che_천도';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
public readonly countsAsInheritanceActiveAction = true;
|
public readonly countsAsInheritanceActiveAction = true;
|
||||||
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
|
||||||
constructor(private readonly env: TurnCommandEnv) {}
|
constructor(private readonly env: TurnCommandEnv) {
|
||||||
|
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): MoveCapitalArgs | null {
|
parseArgs(raw: unknown): MoveCapitalArgs | null {
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
@@ -256,8 +260,9 @@ export class ActionDefinition<
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
general.experience += 5 * (dist * 2 + 1);
|
const reward = 5 * (dist * 2 + 1);
|
||||||
general.dedication += 5 * (dist * 2 + 1);
|
general.experience += this.pipeline.onCalcStat(context, 'experience', reward);
|
||||||
|
general.dedication += this.pipeline.onCalcStat(context, 'dedication', reward);
|
||||||
|
|
||||||
return { effects };
|
return { effects };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,27 @@
|
|||||||
// command/battle update, so both boundaries are part of the game state.
|
// command/battle update, so both boundaries are part of the game state.
|
||||||
export const toLegacyStoredFloat = (value: number): number => Math.fround(value);
|
export const toLegacyStoredFloat = (value: number): number => Math.fround(value);
|
||||||
|
|
||||||
export const readLegacyStoredFloat = (value: number): number =>
|
const roundHalfEven = (value: number): number => {
|
||||||
Number(Math.fround(value).toPrecision(6));
|
const lower = Math.floor(value);
|
||||||
|
const fraction = value - lower;
|
||||||
|
const tolerance = Number.EPSILON * Math.max(1, Math.abs(value)) * 4;
|
||||||
|
if (Math.abs(fraction - 0.5) <= tolerance) {
|
||||||
|
return lower % 2 === 0 ? lower : lower + 1;
|
||||||
|
}
|
||||||
|
return Math.round(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readLegacyStoredFloat = (value: number): number => {
|
||||||
|
const stored = Math.fround(value);
|
||||||
|
if (!Number.isFinite(stored) || stored === 0) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
const sign = stored < 0 ? -1 : 1;
|
||||||
|
const absolute = Math.abs(stored);
|
||||||
|
const exponent = Math.floor(Math.log10(absolute));
|
||||||
|
const scale = 10 ** (5 - exponent);
|
||||||
|
return sign * (roundHalfEven(absolute * scale) / scale);
|
||||||
|
};
|
||||||
|
|
||||||
export const addLegacyStoredFloat = (current: number, delta: number): number =>
|
export const addLegacyStoredFloat = (current: number, delta: number): number =>
|
||||||
toLegacyStoredFloat(readLegacyStoredFloat(current) + delta);
|
toLegacyStoredFloat(readLegacyStoredFloat(current) + delta);
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ export interface General<TriggerState extends GeneralTriggerState = GeneralTrigg
|
|||||||
meta: GeneralMeta;
|
meta: GeneralMeta;
|
||||||
lastTurn?: GeneralLastTurn;
|
lastTurn?: GeneralLastTurn;
|
||||||
turnTime?: Date;
|
turnTime?: Date;
|
||||||
|
turnTick?: number;
|
||||||
recentWarTime?: Date | null;
|
recentWarTime?: Date | null;
|
||||||
|
recentWarTick?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface City {
|
export interface City {
|
||||||
|
|||||||
@@ -494,7 +494,11 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
|
|||||||
const defenderNation = input.defenderNation;
|
const defenderNation = input.defenderNation;
|
||||||
const cityKilled = cityReport?.killed ?? 0;
|
const cityKilled = cityReport?.killed ?? 0;
|
||||||
|
|
||||||
if ((cityReport?.dead ?? 0) > 0) {
|
// Ref branches on WarUnitCity::getPhase(), not accumulated city
|
||||||
|
// casualties. A city can retain dead casualties from earlier battles
|
||||||
|
// while being conquered before its wall receives a phase.
|
||||||
|
const cityPhase = cityReport?.phase ?? ((cityReport?.dead ?? 0) > 0 ? 1 : 0);
|
||||||
|
if (cityPhase > 0) {
|
||||||
const crewTypeIndex = buildCrewTypeIndex(input.unitSet);
|
const crewTypeIndex = buildCrewTypeIndex(input.unitSet);
|
||||||
const crewType = crewTypeIndex.get(input.config.castleCrewTypeId);
|
const crewType = crewTypeIndex.get(input.config.castleCrewTypeId);
|
||||||
const riceCoef = crewType?.rice ?? 1;
|
const riceCoef = crewType?.rice ?? 1;
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
|
|||||||
isAttacker: unit.isAttacker(),
|
isAttacker: unit.isAttacker(),
|
||||||
killed: unit.getKilled(),
|
killed: unit.getKilled(),
|
||||||
dead: unit.getDead(),
|
dead: unit.getDead(),
|
||||||
|
phase: unit.getPhase(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +177,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
|
|||||||
isAttacker: unit.isAttacker(),
|
isAttacker: unit.isAttacker(),
|
||||||
killed: unit.getKilled(),
|
killed: unit.getKilled(),
|
||||||
dead: unit.getDead(),
|
dead: unit.getDead(),
|
||||||
|
phase: unit.getPhase(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +188,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
|
|||||||
isAttacker: unit.isAttacker(),
|
isAttacker: unit.isAttacker(),
|
||||||
killed: unit.getKilled(),
|
killed: unit.getKilled(),
|
||||||
dead: unit.getDead(),
|
dead: unit.getDead(),
|
||||||
|
phase: unit.getPhase(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export interface WarUnitReport {
|
|||||||
isAttacker: boolean;
|
isAttacker: boolean;
|
||||||
killed: number;
|
killed: number;
|
||||||
dead: number;
|
dead: number;
|
||||||
|
/** Number of battle phases consumed by this unit. */
|
||||||
|
phase?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WarBattleMetrics {
|
export interface WarBattleMetrics {
|
||||||
|
|||||||
@@ -112,10 +112,6 @@ export class WarUnitGeneral<
|
|||||||
super.setOppose(oppose);
|
super.setOppose(oppose);
|
||||||
increaseMetaNumber(this.general.meta, RANK_WARNUM, 1);
|
increaseMetaNumber(this.general.meta, RANK_WARNUM, 1);
|
||||||
|
|
||||||
if (!oppose) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseTurnTime = this.isAttacker()
|
const baseTurnTime = this.isAttacker()
|
||||||
? this.general.turnTime
|
? this.general.turnTime
|
||||||
: oppose instanceof WarUnitGeneral
|
: oppose instanceof WarUnitGeneral
|
||||||
@@ -126,6 +122,16 @@ export class WarUnitGeneral<
|
|||||||
}
|
}
|
||||||
const phase = clamp(this.getRealPhase(), 0, 99);
|
const phase = clamp(this.getRealPhase(), 0, 99);
|
||||||
this.general.recentWarTime = new Date(baseTurnTime.getTime());
|
this.general.recentWarTime = new Date(baseTurnTime.getTime());
|
||||||
|
const baseTurnTick = this.isAttacker()
|
||||||
|
? this.general.turnTick
|
||||||
|
: oppose instanceof WarUnitGeneral
|
||||||
|
? oppose.general.turnTick
|
||||||
|
: this.general.turnTick;
|
||||||
|
if (baseTurnTick !== undefined) {
|
||||||
|
this.general.recentWarTick = baseTurnTick - (baseTurnTick % 100) + phase;
|
||||||
|
} else {
|
||||||
|
delete this.general.recentWarTick;
|
||||||
|
}
|
||||||
this.general.meta.recent_war_phase = phase;
|
this.general.meta.recent_war_phase = phase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -343,12 +343,22 @@ describe('Nation Actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('che_천도 (Move Capital)', () => {
|
describe('che_천도 (Move Capital)', () => {
|
||||||
it('changes nation capital city', () => {
|
it('changes nation capital city and applies general reward modules', () => {
|
||||||
const nation = buildNation(1);
|
const nation = buildNation(1);
|
||||||
const city1 = buildCity(1, 1);
|
const city1 = buildCity(1, 1);
|
||||||
const city2 = buildCity(2, 1);
|
const city2 = buildCity(2, 1);
|
||||||
const general = buildGeneral(1, 1, 1);
|
const general = buildGeneral(1, 1, 1);
|
||||||
const env = { develCost: 100, baseGold: 100, baseRice: 100 };
|
const env = {
|
||||||
|
develCost: 100,
|
||||||
|
baseGold: 100,
|
||||||
|
baseRice: 100,
|
||||||
|
generalActionModules: [
|
||||||
|
{
|
||||||
|
onCalcStat: (_context: unknown, statName: string, value: number) =>
|
||||||
|
statName === 'experience' ? value * 0.9 : value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
const definition = new MoveCapitalAction(env as any);
|
const definition = new MoveCapitalAction(env as any);
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
@@ -374,6 +384,8 @@ describe('Nation Actions', () => {
|
|||||||
patch: expect.objectContaining({ capitalCityId: 2 }),
|
patch: expect.objectContaining({ capitalCityId: 2 }),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
expect(general.experience).toBe(113.5);
|
||||||
|
expect(general.dedication).toBe(115);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import type { City, General, Nation } from '../src/domain/entities.js';
|
import type { City, General, Nation } from '../src/domain/entities.js';
|
||||||
import { resolveGeneralAction } from '../src/actions/engine.js';
|
import { resolveGeneralAction } from '../src/actions/engine.js';
|
||||||
import { ActionDefinition } from '../src/actions/turn/general/che_출병.js';
|
import { ActionDefinition, orderDefenderGenerals } from '../src/actions/turn/general/che_출병.js';
|
||||||
import type { DispatchResolveContext } from '../src/actions/turn/general/che_출병.js';
|
import type { DispatchResolveContext } from '../src/actions/turn/general/che_출병.js';
|
||||||
import type { TurnSchedule } from '../src/turn/calendar.js';
|
import type { TurnSchedule } from '../src/turn/calendar.js';
|
||||||
import type { WarAftermathConfig, WarEngineConfig } from '../src/war/types.js';
|
import type { WarAftermathConfig, WarEngineConfig } from '../src/war/types.js';
|
||||||
@@ -183,6 +183,7 @@ describe('che_출병', () => {
|
|||||||
const defenderCity = buildCity(2, defenderNation.id);
|
const defenderCity = buildCity(2, defenderNation.id);
|
||||||
const neutralCity = buildCity(3, 0);
|
const neutralCity = buildCity(3, 0);
|
||||||
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
|
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
|
||||||
|
attacker.turnTime = new Date('2000-01-01T00:00:00Z');
|
||||||
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
|
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
|
||||||
defender.crew = 0;
|
defender.crew = 0;
|
||||||
defenderCity.defence = 0;
|
defenderCity.defence = 0;
|
||||||
@@ -204,9 +205,36 @@ describe('che_출병', () => {
|
|||||||
id: 'test-map',
|
id: 'test-map',
|
||||||
name: 'test-map',
|
name: 'test-map',
|
||||||
cities: [
|
cities: [
|
||||||
{ id: 1, name: 'City1', level: 2, region: 1, position: { x: 0, y: 0 }, connections: [2, 3], max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 }, initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 } },
|
{
|
||||||
{ id: 2, name: 'City2', level: 2, region: 1, position: { x: 1, y: 0 }, connections: [1], max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 }, initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 } },
|
id: 1,
|
||||||
{ id: 3, name: 'City3', level: 2, region: 1, position: { x: 0, y: 1 }, connections: [1], max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 }, initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 } },
|
name: 'City1',
|
||||||
|
level: 2,
|
||||||
|
region: 1,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
connections: [2, 3],
|
||||||
|
max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
||||||
|
initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'City2',
|
||||||
|
level: 2,
|
||||||
|
region: 1,
|
||||||
|
position: { x: 1, y: 0 },
|
||||||
|
connections: [1],
|
||||||
|
max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
||||||
|
initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'City3',
|
||||||
|
level: 2,
|
||||||
|
region: 1,
|
||||||
|
position: { x: 0, y: 1 },
|
||||||
|
connections: [1],
|
||||||
|
max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
||||||
|
initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
diplomacy: [
|
diplomacy: [
|
||||||
@@ -235,6 +263,7 @@ describe('che_출병', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(resolution.logs.length).toBeGreaterThan(0);
|
expect(resolution.logs.length).toBeGreaterThan(0);
|
||||||
|
expect(resolution.general.recentWarTime?.toISOString()).toBe(attacker.turnTime.toISOString());
|
||||||
expect(resolution.patches?.generals.some((patch) => patch.id === defender.id)).toBe(true);
|
expect(resolution.patches?.generals.some((patch) => patch.id === defender.id)).toBe(true);
|
||||||
expect(resolution.patches?.cities.some((patch) => patch.id === defenderCity.id)).toBe(true);
|
expect(resolution.patches?.cities.some((patch) => patch.id === defenderCity.id)).toBe(true);
|
||||||
expect({ city: resolution.city, patches: resolution.patches?.cities }).toMatchObject({
|
expect({ city: resolution.city, patches: resolution.patches?.cities }).toMatchObject({
|
||||||
@@ -250,6 +279,13 @@ describe('che_출병', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('orders equal-priority defender inputs by general number before the stable battle sort', () => {
|
||||||
|
const defender2 = buildGeneral(2, 2, 2);
|
||||||
|
const defender3 = buildGeneral(3, 2, 2);
|
||||||
|
|
||||||
|
expect(orderDefenderGenerals([defender3, defender2]).map((general) => general.id)).toEqual([2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
it('prefers an enemy on the shortest route layer before considering the next layer', () => {
|
it('prefers an enemy on the shortest route layer before considering the next layer', () => {
|
||||||
const attackerNation = buildNation(1);
|
const attackerNation = buildNation(1);
|
||||||
const attackerCity = buildCity(1, attackerNation.id);
|
const attackerCity = buildCity(1, attackerNation.id);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { commandSpec as destroySpec } from '../../src/actions/turn/general/che_
|
|||||||
import { commandSpec as agitateSpec } from '../../src/actions/turn/general/che_선동.js';
|
import { commandSpec as agitateSpec } from '../../src/actions/turn/general/che_선동.js';
|
||||||
import { commandSpec as seizeSpec } from '../../src/actions/turn/general/che_탈취.js';
|
import { commandSpec as seizeSpec } from '../../src/actions/turn/general/che_탈취.js';
|
||||||
import { commandSpec as fireSpec } from '../../src/actions/turn/general/che_화계.js';
|
import { commandSpec as fireSpec } from '../../src/actions/turn/general/che_화계.js';
|
||||||
|
import { ActionDefinition as AppointmentAction } from '../../src/actions/turn/general/che_임관.js';
|
||||||
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
||||||
import {
|
import {
|
||||||
createItemActionModules,
|
createItemActionModules,
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
import { readLegacyCityTrust, storeLegacyCityTrust } from '../../src/actions/turn/general/legacyCityTrust.js';
|
import { readLegacyCityTrust, storeLegacyCityTrust } from '../../src/actions/turn/general/legacyCityTrust.js';
|
||||||
import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js';
|
import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js';
|
||||||
import { resolveLegacyDomesticTrust } from '../../src/actions/turn/general/che_상업투자.js';
|
import { resolveLegacyDomesticTrust } from '../../src/actions/turn/general/che_상업투자.js';
|
||||||
|
import { traitModule as ambitiousPersonality } from '../../src/actionModules/traits/personality/che_출세.js';
|
||||||
|
|
||||||
describe('General Commands New Scenario', () => {
|
describe('General Commands New Scenario', () => {
|
||||||
it('truncates generated NPC dex like GeneralBuilder integer arguments', () => {
|
it('truncates generated NPC dex like GeneralBuilder integer arguments', () => {
|
||||||
@@ -75,6 +77,8 @@ describe('General Commands New Scenario', () => {
|
|||||||
expect(toLegacyStoredTech(value)).not.toBe(Number(Math.fround(value).toPrecision(6)));
|
expect(toLegacyStoredTech(value)).not.toBe(Number(Math.fround(value).toPrecision(6)));
|
||||||
expect(readLegacyStoredTech(624.0966796875)).toBe(624.097);
|
expect(readLegacyStoredTech(624.0966796875)).toBe(624.097);
|
||||||
expect(addLegacyStoredTech(624.0966796875, 22.9)).toBe(Math.fround(624.097 + 22.9));
|
expect(addLegacyStoredTech(624.0966796875, 22.9)).toBe(Math.fround(624.097 + 22.9));
|
||||||
|
expect(readLegacyStoredTech(533.3125)).toBe(533.312);
|
||||||
|
expect(readLegacyStoredTech(533.4375)).toBe(533.438);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('separates MariaDB FLOAT trust storage from its six-digit PHP read value', () => {
|
it('separates MariaDB FLOAT trust storage from its six-digit PHP read value', () => {
|
||||||
@@ -84,6 +88,7 @@ describe('General Commands New Scenario', () => {
|
|||||||
expect(stored).not.toBe(readLegacyCityTrust(stored));
|
expect(stored).not.toBe(readLegacyCityTrust(stored));
|
||||||
expect(readLegacyCityTrust(stored)).toBe(88.3068);
|
expect(readLegacyCityTrust(stored)).toBe(88.3068);
|
||||||
expect(readLegacyCityTrust(storeLegacyCityTrust(readLegacyCityTrust(stored) + 10))).toBe(98.3068);
|
expect(readLegacyCityTrust(storeLegacyCityTrust(readLegacyCityTrust(stored) + 10))).toBe(98.3068);
|
||||||
|
expect(readLegacyCityTrust(storeLegacyCityTrust(93.40625))).toBe(93.4062);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rounds recruitment cost across the PHP half boundary', () => {
|
it('rounds recruitment cost across the PHP half boundary', () => {
|
||||||
@@ -100,6 +105,38 @@ describe('General Commands New Scenario', () => {
|
|||||||
expect(resolveLegacyDomesticTrust(null)).toBe(50);
|
expect(resolveLegacyDomesticTrust(null)).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies the personality experience modifier to appointment rewards', () => {
|
||||||
|
const action = new AppointmentAction({
|
||||||
|
initialNationGenLimit: 10,
|
||||||
|
generalActionModules: [ambitiousPersonality],
|
||||||
|
} as unknown as TurnCommandEnv);
|
||||||
|
const general = {
|
||||||
|
id: 1,
|
||||||
|
name: 'General',
|
||||||
|
experience: 1_000,
|
||||||
|
role: {
|
||||||
|
personality: 'che_출세',
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
meta: {},
|
||||||
|
} as General;
|
||||||
|
const result = action.resolve(
|
||||||
|
{
|
||||||
|
general,
|
||||||
|
destNation: { id: 2, name: 'Nation', meta: {} } as Nation,
|
||||||
|
destNationGeneralCount: 10,
|
||||||
|
destCityId: 3,
|
||||||
|
addLog: () => undefined,
|
||||||
|
} as unknown as Parameters<typeof action.resolve>[0],
|
||||||
|
{ destNationId: 2 }
|
||||||
|
);
|
||||||
|
const generalPatch = result.effects.find((effect) => effect.type === 'general:patch');
|
||||||
|
|
||||||
|
expect(generalPatch).toMatchObject({ patch: { experience: 1_110 } });
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Setup Environment
|
// 1. Setup Environment
|
||||||
const systemEnv: TurnCommandEnv = {
|
const systemEnv: TurnCommandEnv = {
|
||||||
develCost: 100,
|
develCost: 100,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { UnitSetDefinition } from '../src/world/types.js';
|
|||||||
import { resolveWarAftermath } from '../src/war/aftermath.js';
|
import { resolveWarAftermath } from '../src/war/aftermath.js';
|
||||||
import type { WarAftermathConfig } from '../src/war/types.js';
|
import type { WarAftermathConfig } from '../src/war/types.js';
|
||||||
import { LogFormat } from '../src/logging/types.js';
|
import { LogFormat } from '../src/logging/types.js';
|
||||||
|
import { buildWarAftermathConfig } from '../src/actions/turn/actionContextHelpers.js';
|
||||||
|
import type { ScenarioConfig } from '../src/scenario/types.js';
|
||||||
|
|
||||||
const buildUnitSet = (): UnitSetDefinition => ({
|
const buildUnitSet = (): UnitSetDefinition => ({
|
||||||
id: 'test',
|
id: 'test',
|
||||||
@@ -129,6 +131,12 @@ const buildGeneral = (id: number, nationId: number, cityId: number): General =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('war aftermath', () => {
|
describe('war aftermath', () => {
|
||||||
|
it('defaults the omitted legacy maximum tech level to 12', () => {
|
||||||
|
const config = buildWarAftermathConfig({ const: {} } as ScenarioConfig, 999);
|
||||||
|
|
||||||
|
expect(config.maxTechLevel).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
it('updates tech and diplomacy deltas', () => {
|
it('updates tech and diplomacy deltas', () => {
|
||||||
const attackerNation = buildNation(1);
|
const attackerNation = buildNation(1);
|
||||||
const defenderNation = buildNation(2);
|
const defenderNation = buildNation(2);
|
||||||
@@ -268,6 +276,51 @@ describe('war aftermath', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the city battle phase, not retained casualties, for conquered supply-city rice', () => {
|
||||||
|
const attackerNation = buildNation(1);
|
||||||
|
const defenderNation = buildNation(2);
|
||||||
|
defenderNation.rice = 6000;
|
||||||
|
defenderNation.capitalCityId = 3;
|
||||||
|
const attackerCity = buildCity(1, 1);
|
||||||
|
const defenderCity = buildCity(2, 2);
|
||||||
|
const defenderCapital = buildCity(3, 2);
|
||||||
|
defenderCity.meta.supply = 1;
|
||||||
|
const attacker = buildGeneral(1, 1, 1);
|
||||||
|
|
||||||
|
resolveWarAftermath({
|
||||||
|
battle: {
|
||||||
|
attacker,
|
||||||
|
defenders: [],
|
||||||
|
defenderCity,
|
||||||
|
logs: [],
|
||||||
|
conquered: true,
|
||||||
|
reports: [
|
||||||
|
{
|
||||||
|
id: defenderCity.id,
|
||||||
|
type: 'city',
|
||||||
|
name: defenderCity.name,
|
||||||
|
isAttacker: false,
|
||||||
|
killed: 0,
|
||||||
|
dead: 100,
|
||||||
|
phase: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attackerNation,
|
||||||
|
defenderNation,
|
||||||
|
attackerCity,
|
||||||
|
defenderCity,
|
||||||
|
nations: [attackerNation, defenderNation],
|
||||||
|
cities: [attackerCity, defenderCity, defenderCapital],
|
||||||
|
generals: [attacker],
|
||||||
|
unitSet: buildUnitSet(),
|
||||||
|
config: buildConfig(),
|
||||||
|
time: { year: 200, month: 1, startYear: 180 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(defenderNation.rice).toBe(6500);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies conquest collapse rewards', () => {
|
it('applies conquest collapse rewards', () => {
|
||||||
const rng = new RandUtil(new ConstantRNG(0));
|
const rng = new RandUtil(new ConstantRNG(0));
|
||||||
const attackerNation = buildNation(1);
|
const attackerNation = buildNation(1);
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ describe('war triggers', () => {
|
|||||||
const city = buildCity();
|
const city = buildCity();
|
||||||
const attackerGeneral = buildGeneral(80);
|
const attackerGeneral = buildGeneral(80);
|
||||||
attackerGeneral.turnTime = new Date('2026-07-26T13:38:45.000Z');
|
attackerGeneral.turnTime = new Date('2026-07-26T13:38:45.000Z');
|
||||||
|
attackerGeneral.turnTick = 123_456;
|
||||||
|
|
||||||
const attacker = new WarUnitGeneral(
|
const attacker = new WarUnitGeneral(
|
||||||
rng,
|
rng,
|
||||||
@@ -338,8 +339,13 @@ describe('war triggers', () => {
|
|||||||
attacker.setOppose(defender);
|
attacker.setOppose(defender);
|
||||||
defender.setOppose(attacker);
|
defender.setOppose(attacker);
|
||||||
expect(attackerGeneral.recentWarTime?.toISOString()).toBe('2026-07-26T13:38:45.000Z');
|
expect(attackerGeneral.recentWarTime?.toISOString()).toBe('2026-07-26T13:38:45.000Z');
|
||||||
|
expect(attackerGeneral.recentWarTick).toBe(123_400);
|
||||||
expect(attackerGeneral.meta.recent_war_phase).toBe(0);
|
expect(attackerGeneral.meta.recent_war_phase).toBe(0);
|
||||||
|
|
||||||
|
attackerGeneral.turnTick = 123_556;
|
||||||
|
attacker.setOppose(null);
|
||||||
|
expect(attackerGeneral.recentWarTick).toBe(123_500);
|
||||||
|
|
||||||
attacker.beginPhase();
|
attacker.beginPhase();
|
||||||
|
|
||||||
const [module] = await loadWarTriggerModules(['che_필살']);
|
const [module] = await loadWarTriggerModules(['che_필살']);
|
||||||
|
|||||||
Reference in New Issue
Block a user