fix: match scenario 2601 monthly seed progression
This commit is contained in:
@@ -102,6 +102,7 @@ export const buildBattleSimEnvironment = async (
|
||||
maxTrainByWar: resolveNumber(constValues, ['maxTrainByWar'], DEFAULT_WAR_CONFIG.maxTrainByWar),
|
||||
maxAtmosByWar: resolveNumber(constValues, ['maxAtmosByWar'], DEFAULT_WAR_CONFIG.maxAtmosByWar),
|
||||
maxGeneralStat: resolveNumber(constValues, ['maxLevel'], 255),
|
||||
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
|
||||
castleCrewTypeId,
|
||||
armTypes: {
|
||||
footman: 1,
|
||||
|
||||
@@ -37,12 +37,17 @@ export const nextStage = (stage: number): number => {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveScheduledBaseMs = (state: TournamentState): number => {
|
||||
const scheduled = new Date(state.nextAt).getTime();
|
||||
return Number.isFinite(scheduled) ? scheduled : Date.now();
|
||||
};
|
||||
|
||||
export const resolveNextAt = (state: TournamentState): string =>
|
||||
new Date(Date.now() + Math.max(1, state.termSeconds) * 1000).toISOString();
|
||||
new Date(resolveScheduledBaseMs(state) + Math.max(1, state.termSeconds) * 1000).toISOString();
|
||||
|
||||
export const resolveBettingCloseAt = (state: TournamentState): string => {
|
||||
const bettingTermMs = Math.min(state.termSeconds * 60, 3600) * 1000;
|
||||
return new Date(Date.now() + Math.max(1000, bettingTermMs)).toISOString();
|
||||
return new Date(resolveScheduledBaseMs(state) + Math.max(1000, bettingTermMs)).toISOString();
|
||||
};
|
||||
|
||||
export const resolveStatValue = (
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
TournamentState,
|
||||
} from '../src/tournament/types.js';
|
||||
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
|
||||
import { buildBettingPayouts } from '../src/tournament/workerHelpers.js';
|
||||
import { buildBettingPayouts, resolveBettingCloseAt, resolveNextAt } from '../src/tournament/workerHelpers.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
|
||||
class MemoryRedis {
|
||||
@@ -210,6 +210,18 @@ const runTournamentToCompletion = async (options: {
|
||||
|
||||
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe('tournament worker schedule compatibility', () => {
|
||||
it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => {
|
||||
const state = createTournamentState({
|
||||
termSeconds: 600,
|
||||
nextAt: '2026-08-02T10:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(resolveNextAt(state)).toBe('2026-08-02T10:10:00.000Z');
|
||||
expect(resolveBettingCloseAt(state)).toBe('2026-08-02T11:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tournament worker (in-memory)', () => {
|
||||
it('당첨자가 없으면 레거시와 같이 베팅금을 지급하거나 환불하지 않는다', () => {
|
||||
expect(
|
||||
|
||||
@@ -167,7 +167,11 @@ export class TurnDaemonLifecycle {
|
||||
const nowMs = this.clock.nowMs();
|
||||
const nextTurnMs = nextRunTime.getTime();
|
||||
if (nowMs >= nextTurnMs) {
|
||||
await this.runOnce({ reason: 'schedule', targetTime: nextRunTime });
|
||||
// Ref checkDelay() executes every turn due at the observed
|
||||
// wall-clock time in one snapshot. Using only the oldest due
|
||||
// timestamp lets generals created by that batch run before a
|
||||
// monthly boundary, although Ref defers them to the next pass.
|
||||
await this.runOnce({ reason: 'schedule', targetTime: new Date(nowMs) });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -195,9 +199,10 @@ export class TurnDaemonLifecycle {
|
||||
const lastTurnTime = new Date(this.status.lastTurnTime);
|
||||
const nextGeneralTurnTime = await this.stateStore.loadNextGeneralTurnTime();
|
||||
const nextTickTime = this.getNextTickTime(lastTurnTime);
|
||||
// 가장 빠른 장수 턴과 현재 틱 경계 중 먼저 오는 시각을 선택한다.
|
||||
// 같은 시각이면 Ref처럼 월 경계를 먼저 처리한다. 해당 장수는 월
|
||||
// 처리 직후 다음 daemon pass에서 과거 due turn으로 실행된다.
|
||||
const nextTurnTime =
|
||||
nextGeneralTurnTime && nextGeneralTurnTime.getTime() <= nextTickTime.getTime()
|
||||
nextGeneralTurnTime && nextGeneralTurnTime.getTime() < nextTickTime.getTime()
|
||||
? nextGeneralTurnTime
|
||||
: nextTickTime;
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ const resolveKillturnFromDeathYear = (
|
||||
if (!Number.isFinite(deathYear) || deathYear <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth);
|
||||
const diff = (deathYear - currentYear) * 12 + (deathMonth - 1) + currentMonth - 1;
|
||||
return Math.max(diff, 0);
|
||||
};
|
||||
|
||||
@@ -209,18 +209,6 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const hiddenSeed =
|
||||
integrationSeed && integrationSeed.length > 0 ? integrationSeed : randomBytes(16).toString('hex');
|
||||
|
||||
const { seed, warnings } = buildScenarioBootstrap({
|
||||
scenario: scenarioDefinition,
|
||||
map,
|
||||
unitSet,
|
||||
options: {
|
||||
includeNeutralNationInSeed: options.includeNeutralNationInSeed ?? true,
|
||||
hiddenSeed,
|
||||
},
|
||||
});
|
||||
seed.cities = applyInitialChangeCityEvents(seed.cities, seed.initialEvents);
|
||||
|
||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||
const now = options.now ?? new Date();
|
||||
const tickSeconds =
|
||||
install?.turnTermMinutes !== undefined
|
||||
@@ -229,6 +217,22 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
||||
const sync = install?.sync ?? false;
|
||||
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
|
||||
|
||||
const { seed, warnings } = buildScenarioBootstrap({
|
||||
scenario: scenarioDefinition,
|
||||
map,
|
||||
unitSet,
|
||||
options: {
|
||||
includeNeutralNationInSeed: options.includeNeutralNationInSeed ?? true,
|
||||
hiddenSeed,
|
||||
initialYear: startState.currentYear,
|
||||
initialMonth: startState.currentMonth,
|
||||
turnTermMinutes,
|
||||
},
|
||||
});
|
||||
seed.cities = applyInitialChangeCityEvents(seed.cities, seed.initialEvents);
|
||||
|
||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||
const generalGold = options.defaultGeneralGold ?? DEFAULT_GENERAL_GOLD;
|
||||
const generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE;
|
||||
|
||||
@@ -257,7 +261,15 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const worldMeta: Record<string, unknown> = {
|
||||
scenarioId: options.scenarioId,
|
||||
scenarioMeta: seed.scenarioMeta,
|
||||
// ResetHelper persists the actual pre-opening calendar separately from
|
||||
// scenario.startyear. NPC wandering-nation AI and founding constraints
|
||||
// derive their opening-month gates from these values.
|
||||
initYear: startState.currentYear,
|
||||
initMonth: startState.currentMonth,
|
||||
genius: Math.max(0, Math.floor(asNumber(scenarioConst.defaultMaxGenius, 5))),
|
||||
// Ref seeds game_env.develcost before the first general turn. The
|
||||
// monthly pre-handler recalculates the same value at each boundary.
|
||||
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
|
||||
starttime: formatDateTime(startState.startTime),
|
||||
turntime: formatDateTime(now),
|
||||
opentime: formatDateTime(now),
|
||||
@@ -493,6 +505,8 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
leadership: general.stats.leadership,
|
||||
strength: general.stats.strength,
|
||||
intel: general.stats.intelligence,
|
||||
experience: general.experience ?? 0,
|
||||
dedication: general.dedication ?? 0,
|
||||
officerLevel: general.officerLevel,
|
||||
gold: generalGold,
|
||||
rice: generalRice,
|
||||
@@ -501,9 +515,17 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
weaponCode: general.weapon ?? 'None',
|
||||
bookCode: general.book ?? 'None',
|
||||
itemCode: general.item ?? 'None',
|
||||
turnTime: now,
|
||||
age: resolveGeneralAge(scenario.startYear ?? null, general.birthYear),
|
||||
startAge: resolveGeneralAge(scenario.startYear ?? null, general.birthYear),
|
||||
turnTime: new Date(
|
||||
now.getTime() +
|
||||
Math.floor(
|
||||
(typeof general.meta.initialTurnOffsetMicros === 'number'
|
||||
? general.meta.initialTurnOffsetMicros
|
||||
: 0) / 1_000
|
||||
)
|
||||
),
|
||||
age: resolveGeneralAge(startState.currentYear, general.birthYear),
|
||||
// Legacy GeneralBuilder leaves startage at the schema default on install.
|
||||
startAge: 20,
|
||||
personalCode: general.personality ?? 'None',
|
||||
specialCode: general.special ?? 'None',
|
||||
special2Code: general.specialWar ?? 'None',
|
||||
|
||||
@@ -85,6 +85,21 @@ export const parseYearMonth = (value: number): [number, number] => {
|
||||
return [year, month];
|
||||
};
|
||||
|
||||
export const withCanonicalArgumentAliases = (args: Record<string, unknown>): Record<string, unknown> => {
|
||||
const normalized = { ...args };
|
||||
for (const [legacyKey, canonicalKey] of [
|
||||
['destCityID', 'destCityId'],
|
||||
['destNationID', 'destNationId'],
|
||||
['destGeneralID', 'destGeneralId'],
|
||||
['destTroopID', 'destTroopId'],
|
||||
] as const) {
|
||||
if (normalized[canonicalKey] === undefined && normalized[legacyKey] !== undefined) {
|
||||
normalized[canonicalKey] = normalized[legacyKey];
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const calcCityDevRatio = (city: City): number => {
|
||||
const total = city.agriculture + city.commerce + city.security + city.defence + city.wall;
|
||||
const max = city.agricultureMax + city.commerceMax + city.securityMax + city.defenceMax + city.wallMax;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ConstraintContext } from '@sammo-ts/logic';
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.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 type { ReservedTurnEntry } from '../../reservedTurnStore.js';
|
||||
import type { TurnGeneral, TurnWorldState } from '../../types.js';
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
readRequiredMetaNumber,
|
||||
roundTo,
|
||||
valueFit,
|
||||
withCanonicalArgumentAliases,
|
||||
} from '../aiUtils.js';
|
||||
import { searchAllDistanceByNationList } from '../distance.js';
|
||||
import { generalActionHandlers } from '../generalAiGeneralActions.js';
|
||||
@@ -48,10 +50,36 @@ const d징병 = 2;
|
||||
const d직전 = 3;
|
||||
const d전쟁 = 4;
|
||||
|
||||
export const resolveLegacyAiStats = (
|
||||
general: Pick<TurnGeneral, 'injury' | 'officerLevel' | 'stats'>,
|
||||
nation: Nation | null | undefined,
|
||||
maxStatLevel: number
|
||||
) => {
|
||||
const maxLevel = Math.max(1, maxStatLevel);
|
||||
const clampStat = (value: number): number => Math.max(0, Math.min(value, maxLevel));
|
||||
const injuryRatio = (100 - Math.max(0, Math.min(general.injury, 100))) / 100;
|
||||
const nationLevel = nation?.level ?? 0;
|
||||
const leadershipBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0;
|
||||
const leadershipWithBonus = (value: number): number => clampStat(clampStat(value) + leadershipBonus);
|
||||
|
||||
const injuredLeadership = general.stats.leadership * injuryRatio;
|
||||
const injuredStrength = general.stats.strength * injuryRatio;
|
||||
const injuredIntelligence = general.stats.intelligence * injuryRatio;
|
||||
|
||||
return {
|
||||
fullLeadership: leadershipWithBonus(general.stats.leadership),
|
||||
fullStrength: clampStat(general.stats.strength + Math.round(general.stats.intelligence / 4)),
|
||||
fullIntelligence: clampStat(general.stats.intelligence + Math.round(general.stats.strength / 4)),
|
||||
effectiveLeadership: Math.trunc(leadershipWithBonus(injuredLeadership)),
|
||||
effectiveStrength: Math.trunc(clampStat(injuredStrength + Math.round(injuredIntelligence / 4))),
|
||||
effectiveIntelligence: Math.trunc(clampStat(injuredIntelligence + Math.round(injuredStrength / 4))),
|
||||
};
|
||||
};
|
||||
|
||||
export class GeneralAI {
|
||||
public readonly general: TurnGeneral;
|
||||
public readonly city?: City;
|
||||
public readonly nation?: Nation | null;
|
||||
public general: TurnGeneral;
|
||||
public city?: City;
|
||||
public nation?: Nation | null;
|
||||
public readonly world: TurnWorldState;
|
||||
public readonly worldRef: AiWorldView | null;
|
||||
public readonly map?: MapDefinition;
|
||||
@@ -125,11 +153,15 @@ export class GeneralAI {
|
||||
private devRate: Record<string, number> | null = null;
|
||||
private categorizedCities = false;
|
||||
private categorizedGenerals = false;
|
||||
private promotionPatches: Array<{ generalId: number; officerLevel: number; officerCity: number }> = [];
|
||||
private promotionNationMeta: Record<string, unknown> | null = null;
|
||||
private readonly initialGeneralMeta: Record<string, unknown>;
|
||||
|
||||
private readonly reservedTurnProvider: AiReservedTurnProvider;
|
||||
|
||||
constructor(options: GeneralAIOptions) {
|
||||
this.general = { ...options.general, meta: { ...options.general.meta } };
|
||||
this.initialGeneralMeta = { ...options.general.meta };
|
||||
this.city = options.city;
|
||||
const nation =
|
||||
options.nation ??
|
||||
@@ -160,7 +192,51 @@ export class GeneralAI {
|
||||
this.world.currentMonth,
|
||||
this.general.id
|
||||
);
|
||||
this.rng = new RandUtil(LiteHashDRBG.build(seed));
|
||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
|
||||
process.stdout.write(
|
||||
`AI_GENERAL_SEED_TRACE ${JSON.stringify({
|
||||
generalId: this.general.id,
|
||||
year: this.world.currentYear,
|
||||
month: this.world.currentMonth,
|
||||
seedHex: Buffer.from(seed).toString('hex'),
|
||||
})}\n`
|
||||
);
|
||||
}
|
||||
const baseRng = new RandUtil(LiteHashDRBG.build(seed));
|
||||
const traceRng = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id));
|
||||
let traceSequence = 0;
|
||||
this.rng = traceRng
|
||||
? new Proxy(baseRng, {
|
||||
get: (target, property, receiver) => {
|
||||
const value = Reflect.get(target, property, receiver);
|
||||
if (typeof value !== 'function') return value;
|
||||
return (...args: unknown[]) => {
|
||||
const result = Reflect.apply(value, receiver, args);
|
||||
if (
|
||||
['nextFloat1', 'nextRangeInt', 'nextInt', 'nextBit', 'nextBool', 'choice', 'choiceUsingWeight', 'choiceUsingWeightPair'].includes(
|
||||
String(property)
|
||||
)
|
||||
) {
|
||||
process.stdout.write(
|
||||
`AI_RNG_TRACE ${JSON.stringify({
|
||||
generalId: this.general.id,
|
||||
sequence: traceSequence++,
|
||||
method: String(property),
|
||||
caller: new Error().stack?.split('\n')[2]?.trim() ?? null,
|
||||
result:
|
||||
result === null || ['string', 'number', 'boolean'].includes(typeof result)
|
||||
? result
|
||||
: Array.isArray(result)
|
||||
? `[array:${result.length}]`
|
||||
: '[object]',
|
||||
})}\n`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
},
|
||||
})
|
||||
: baseRng;
|
||||
|
||||
const constValues = asRecord(this.scenarioConfig.const);
|
||||
this.aiConst = {
|
||||
@@ -176,7 +252,7 @@ export class GeneralAI {
|
||||
npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0),
|
||||
availableNationTypes: Array.isArray(constValues.availableNationType)
|
||||
? constValues.availableNationType.filter((value) => typeof value === 'string')
|
||||
: [],
|
||||
: NATION_TRAIT_KEYS.filter((value) => value !== 'che_중립'),
|
||||
};
|
||||
|
||||
const generalPolicy = new AutorunGeneralPolicy(
|
||||
@@ -223,6 +299,14 @@ export class GeneralAI {
|
||||
this.categorizeNationCities();
|
||||
this.categorizeNationGeneral();
|
||||
|
||||
if (this.general.npcState >= 2 && [3, 6, 9, 12].includes(this.world.currentMonth)) {
|
||||
if (this.general.officerLevel === 12) {
|
||||
this.chooseNpcPromotion();
|
||||
} else {
|
||||
this.chooseNonLordPromotion();
|
||||
}
|
||||
}
|
||||
|
||||
if (reservedTurn.action !== ACTION_REST) {
|
||||
const reservedCandidate = this.buildNationCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
|
||||
if (reservedCandidate) {
|
||||
@@ -243,6 +327,16 @@ export class GeneralAI {
|
||||
}
|
||||
const result = handler(this);
|
||||
if (result) {
|
||||
// Ref refreshes the cached AI state after these selected nation
|
||||
// commands, before choosing the general command with the same
|
||||
// RNG. The refresh includes another mixed-general type draw.
|
||||
if (
|
||||
['유저장긴급포상', 'NPC긴급포상', '선전포고', '천도'].includes(
|
||||
actionName
|
||||
)
|
||||
) {
|
||||
this.reqUpdateInstance = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -250,13 +344,47 @@ export class GeneralAI {
|
||||
return this.buildNationCandidate(ACTION_REST, {}, 'neutral');
|
||||
}
|
||||
|
||||
consumePromotionPatches(): {
|
||||
generals: Array<{ generalId: number; officerLevel: number; officerCity: number }>;
|
||||
nationMeta: Record<string, unknown> | null;
|
||||
} {
|
||||
const result = {
|
||||
generals: this.promotionPatches,
|
||||
nationMeta: this.promotionNationMeta,
|
||||
};
|
||||
this.promotionPatches = [];
|
||||
this.promotionNationMeta = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
consumePersistentGeneralMetaPatch(): { set: Record<string, unknown>; unset: string[] } {
|
||||
const transientKeys = new Set([
|
||||
'fullLeadership',
|
||||
'fullStrength',
|
||||
'fullIntelligence',
|
||||
'effectiveLeadership',
|
||||
'effectiveStrength',
|
||||
'effectiveIntelligence',
|
||||
]);
|
||||
const set: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(this.general.meta)) {
|
||||
if (transientKeys.has(key) || Object.is(this.initialGeneralMeta[key], value)) continue;
|
||||
set[key] = value;
|
||||
}
|
||||
const unset = Object.keys(this.initialGeneralMeta).filter(
|
||||
(key) => !transientKeys.has(key) && !Object.prototype.hasOwnProperty.call(this.general.meta, key)
|
||||
);
|
||||
return { set, unset };
|
||||
}
|
||||
|
||||
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
||||
this.updateInstance();
|
||||
if (!this.worldRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcMessage = asRecord(this.general.meta).npcmsg;
|
||||
const generalMeta = asRecord(this.general.meta);
|
||||
const npcMessage = generalMeta.npcmsg ?? generalMeta.text;
|
||||
if (npcMessage && this.rng.nextBool((this.aiConst.npcMessageFreqByDay * this.turnTermMinutes) / (60 * 24))) {
|
||||
// 메시지 영속화는 turn handler가 담당한다. 여기서는 레거시와 같은 RNG 소비를 보존한다.
|
||||
}
|
||||
@@ -336,7 +464,13 @@ export class GeneralAI {
|
||||
}
|
||||
|
||||
for (const actionName of this.generalPolicy.priority) {
|
||||
if (!this.generalPolicy.can(actionName)) {
|
||||
const allowed = this.generalPolicy.can(actionName);
|
||||
if (!allowed) {
|
||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
|
||||
process.stdout.write(
|
||||
`AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result: null })}\n`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const handler = generalActionHandlers[actionName];
|
||||
@@ -344,6 +478,11 @@ export class GeneralAI {
|
||||
continue;
|
||||
}
|
||||
const result = handler(this);
|
||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
|
||||
process.stdout.write(
|
||||
`AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result })}\n`
|
||||
);
|
||||
}
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
@@ -354,7 +493,6 @@ export class GeneralAI {
|
||||
}
|
||||
|
||||
getDebugState(): GeneralAiDebugState {
|
||||
this.updateInstance();
|
||||
this.categorizeNationCities();
|
||||
const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth);
|
||||
const startYearMonth = joinYearMonth(this.startYear + 2, 5);
|
||||
@@ -626,10 +764,35 @@ export class GeneralAI {
|
||||
}
|
||||
this.reqUpdateInstance = false;
|
||||
|
||||
const nation = this.nation;
|
||||
if (!nation) {
|
||||
return;
|
||||
const refreshedGeneral = this.worldRef?.getGeneralById(this.general.id);
|
||||
if (refreshedGeneral) {
|
||||
this.general = { ...refreshedGeneral, meta: { ...refreshedGeneral.meta } };
|
||||
}
|
||||
const refreshedCity = this.worldRef?.getCityById(this.general.cityId);
|
||||
if (refreshedCity) {
|
||||
this.city = refreshedCity;
|
||||
}
|
||||
const refreshedNation =
|
||||
this.general.nationId > 0 ? (this.worldRef?.getNationById(this.general.nationId) ?? null) : null;
|
||||
if (refreshedNation !== undefined) {
|
||||
this.nation = refreshedNation ? { ...refreshedNation, meta: { ...refreshedNation.meta } } : refreshedNation;
|
||||
}
|
||||
|
||||
const nation =
|
||||
this.nation ??
|
||||
({
|
||||
id: 0,
|
||||
name: '재야',
|
||||
color: '#000000',
|
||||
capitalCityId: null,
|
||||
chiefGeneralId: null,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
level: 0,
|
||||
typeCode: 'neutral',
|
||||
meta: {},
|
||||
} satisfies Nation);
|
||||
|
||||
const baseDevelCost = this.commandEnv.develCost * 12;
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
@@ -654,6 +817,7 @@ export class GeneralAI {
|
||||
}
|
||||
|
||||
this.calcDiplomacyState();
|
||||
this.refreshLegacyFullStats();
|
||||
this.genType = this.calcGenType();
|
||||
|
||||
void baseDevelCost;
|
||||
@@ -671,16 +835,17 @@ export class GeneralAI {
|
||||
if (parsedArgs === null) {
|
||||
return null;
|
||||
}
|
||||
const constraintArgs = withCanonicalArgumentAliases(parsedArgs as Record<string, unknown>);
|
||||
const constraintEnv = this.buildConstraintEnv();
|
||||
const ctx: ConstraintContext = {
|
||||
actorId: this.general.id,
|
||||
cityId: this.city?.id,
|
||||
nationId: this.general.nationId,
|
||||
args: parsedArgs as Record<string, unknown>,
|
||||
args: constraintArgs,
|
||||
env: constraintEnv,
|
||||
mode: 'full',
|
||||
};
|
||||
const view = new WorldStateView(this.worldRef, constraintEnv, parsedArgs as Record<string, unknown>, {
|
||||
const view = new WorldStateView(this.worldRef, constraintEnv, constraintArgs, {
|
||||
general: this.general,
|
||||
city: this.city,
|
||||
nation: this.nation ?? null,
|
||||
@@ -688,6 +853,11 @@ export class GeneralAI {
|
||||
const constraints = definition.buildConstraints(ctx, parsedArgs as never);
|
||||
const result = evaluateConstraints(constraints, ctx, view);
|
||||
if (result.kind !== 'allow') {
|
||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
|
||||
process.stdout.write(
|
||||
`AI_GENERAL_CONSTRAINT_TRACE ${JSON.stringify({ generalId: this.general.id, action, args: parsedArgs, result })}\n`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -708,9 +878,10 @@ export class GeneralAI {
|
||||
}
|
||||
|
||||
private calcGenType(): number {
|
||||
const leadership = this.general.stats.leadership;
|
||||
const strength = Math.max(this.general.stats.strength, 1);
|
||||
const intel = Math.max(this.general.stats.intelligence, 1);
|
||||
const meta = asRecord(this.general.meta);
|
||||
const leadership = readMetaNumber(meta, 'fullLeadership', this.general.stats.leadership);
|
||||
const strength = Math.max(readMetaNumber(meta, 'fullStrength', this.general.stats.strength), 1);
|
||||
const intel = Math.max(readMetaNumber(meta, 'fullIntelligence', this.general.stats.intelligence), 1);
|
||||
let genType: number;
|
||||
|
||||
if (strength >= intel) {
|
||||
@@ -736,6 +907,153 @@ export class GeneralAI {
|
||||
return genType;
|
||||
}
|
||||
|
||||
private refreshLegacyFullStats(): void {
|
||||
const stats = resolveLegacyAiStats(
|
||||
this.general,
|
||||
this.nation,
|
||||
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max
|
||||
);
|
||||
this.general.meta = {
|
||||
...this.general.meta,
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
|
||||
private chooseNpcPromotion(): void {
|
||||
if (!this.nation || !this.worldRef) {
|
||||
return;
|
||||
}
|
||||
const minChiefLevel = this.nation.level >= 6 ? 5 : this.nation.level >= 4 ? 7 : this.nation.level >= 2 ? 9 : 11;
|
||||
let chiefSet = readMetaNumber(asRecord(this.nation.meta), 'chief_set', 0);
|
||||
const generals = this.worldRef
|
||||
.listGenerals()
|
||||
.filter((candidate) => candidate.nationId === this.nation!.id)
|
||||
.sort((left, right) => {
|
||||
const leftScore = left.stats.leadership * 2 + left.stats.strength + left.stats.intelligence;
|
||||
const rightScore = right.stats.leadership * 2 + right.stats.strength + right.stats.intelligence;
|
||||
// Ref's nation query returns primary-key order and uasort keeps
|
||||
// that order when the raw-stat score ties.
|
||||
return rightScore - leftScore || left.id - right.id;
|
||||
});
|
||||
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
|
||||
|
||||
for (let chiefLevel = 11; chiefLevel >= minChiefLevel; chiefLevel -= 1) {
|
||||
if ((chiefSet & (1 << chiefLevel)) !== 0 || this.general.officerLevel === chiefLevel) {
|
||||
continue;
|
||||
}
|
||||
const oldChief = generals.find((candidate) => candidate.officerLevel === chiefLevel);
|
||||
if (oldChief) {
|
||||
const newChiefProbability = this.rng.nextBool(0.1) ? 1 : 0;
|
||||
// GeneralAI.php performs a second nextBool(0) call on the
|
||||
// rejection path. Preserve that consumption for the shared
|
||||
// nation/general AI RNG stream.
|
||||
if (newChiefProbability < 1 && !this.rng.nextBool(newChiefProbability)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const nextChief = generals.find((candidate) => {
|
||||
if ((effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel) > 4 || candidate.npcState < 2) {
|
||||
return false;
|
||||
}
|
||||
const killturn = readRequiredMetaNumber(
|
||||
asRecord(candidate.meta),
|
||||
'killturn',
|
||||
`generalId=${candidate.id}`
|
||||
);
|
||||
if (killturn < 36) {
|
||||
return false;
|
||||
}
|
||||
if (chiefLevel !== 11 && chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
chiefLevel !== 11 &&
|
||||
chiefLevel % 2 === 1 &&
|
||||
candidate.stats.intelligence < this.aiConst.chiefStatMin
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!nextChief) {
|
||||
continue;
|
||||
}
|
||||
if (oldChief) {
|
||||
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
|
||||
}
|
||||
this.promotionPatches.push({ generalId: nextChief.id, officerLevel: chiefLevel, officerCity: 0 });
|
||||
if (process.env.CORE_AI_TRACE_SEQUENCE === '1') {
|
||||
process.stdout.write(
|
||||
`AI_PROMOTION_TRACE ${JSON.stringify({ engine: 'core', mode: 'lord', actor: this.general.id, chiefLevel, picked: nextChief.id })}\n`
|
||||
);
|
||||
}
|
||||
effectiveOfficerLevel.set(nextChief.id, chiefLevel);
|
||||
chiefSet |= 1 << chiefLevel;
|
||||
}
|
||||
|
||||
if (this.promotionPatches.length > 0) {
|
||||
this.promotionNationMeta = { ...this.nation.meta, chief_set: chiefSet };
|
||||
}
|
||||
}
|
||||
|
||||
private chooseNonLordPromotion(): void {
|
||||
if (!this.nation) {
|
||||
return;
|
||||
}
|
||||
const minChiefLevel = this.nation.level >= 6 ? 5 : this.nation.level >= 4 ? 7 : this.nation.level >= 2 ? 9 : 11;
|
||||
let chiefSet = readMetaNumber(asRecord(this.nation.meta), 'chief_set', 0);
|
||||
const pools = [this.npcWarGenerals, this.npcCivilGenerals, this.userWarGenerals, this.userCivilGenerals];
|
||||
|
||||
for (let chiefLevel = minChiefLevel; chiefLevel <= 12; chiefLevel += 1) {
|
||||
if (
|
||||
(chiefSet & (1 << chiefLevel)) !== 0 ||
|
||||
this.chiefGenerals[chiefLevel] ||
|
||||
this.general.officerLevel === chiefLevel
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let picked: TurnGeneral | null = null;
|
||||
for (let trial = 0; trial < 5; trial += 1) {
|
||||
const pool = pools.find((candidatePool) => Object.keys(candidatePool).length > 0);
|
||||
if (!pool) {
|
||||
break;
|
||||
}
|
||||
const candidate = this.rng.choice(Object.values(pool));
|
||||
if (candidate.officerLevel !== 1) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
chiefLevel !== 11 &&
|
||||
((chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) ||
|
||||
(chiefLevel % 2 === 1 && candidate.stats.intelligence < this.aiConst.chiefStatMin))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
picked = candidate;
|
||||
break;
|
||||
}
|
||||
if (!picked) {
|
||||
continue;
|
||||
}
|
||||
|
||||
picked.officerLevel = chiefLevel;
|
||||
picked.meta = { ...picked.meta, officer_city: 0 };
|
||||
this.promotionPatches.push({ generalId: picked.id, officerLevel: chiefLevel, officerCity: 0 });
|
||||
if (process.env.CORE_AI_TRACE_SEQUENCE === '1') {
|
||||
process.stdout.write(
|
||||
`AI_PROMOTION_TRACE ${JSON.stringify({ engine: 'core', mode: 'non-lord', actor: this.general.id, chiefLevel, picked: picked.id })}\n`
|
||||
);
|
||||
}
|
||||
this.chiefGenerals[chiefLevel] = picked;
|
||||
chiefSet |= 1 << chiefLevel;
|
||||
}
|
||||
|
||||
if (this.promotionPatches.length > 0) {
|
||||
this.promotionNationMeta = { ...this.nation.meta, chief_set: chiefSet };
|
||||
}
|
||||
}
|
||||
|
||||
private calcDiplomacyState(): void {
|
||||
if (!this.nation || !this.worldRef) {
|
||||
return;
|
||||
@@ -815,6 +1133,11 @@ export class GeneralAI {
|
||||
}
|
||||
|
||||
// legacy GeneralAI.php 기준: 평화/선포 상태에서 병력 보유 여부로 d징병 전환하지 않음.
|
||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) {
|
||||
process.stdout.write(
|
||||
`AI_DIPLOMACY_TRACE ${JSON.stringify({ generalId: this.general.id, warTargets, dipState: this.dipState })}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private calcRecentWarTurn(general: TurnGeneral): number {
|
||||
|
||||
@@ -27,6 +27,10 @@ export const do일반내정 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
const develRate = ai.calcCityDevelRate(city);
|
||||
const generalMeta = asRecord(ai.general.meta);
|
||||
const leadership = readMetaNumber(generalMeta, 'effectiveLeadership', ai.general.stats.leadership);
|
||||
const strength = readMetaNumber(generalMeta, 'effectiveStrength', ai.general.stats.strength);
|
||||
const intelligence = readMetaNumber(generalMeta, 'effectiveIntelligence', ai.general.stats.intelligence);
|
||||
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
|
||||
const isSpringSummer = ai.world.currentMonth <= 6;
|
||||
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
|
||||
@@ -35,18 +39,18 @@ export const do일반내정 = (ai: GeneralAI) => {
|
||||
if (develRate.trust[0] < 0.98) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_주민선정', {}, '일반내정'),
|
||||
(ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
|
||||
(leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
|
||||
]);
|
||||
}
|
||||
if (develRate.pop[0] < 0.8) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'),
|
||||
ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001),
|
||||
leadership / valueFit(develRate.pop[0], 0.001),
|
||||
]);
|
||||
} else if (develRate.pop[0] < 0.99) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'),
|
||||
ai.general.stats.leadership / valueFit(develRate.pop[0] / 4, 0.001),
|
||||
leadership / valueFit(develRate.pop[0] / 4, 0.001),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -55,51 +59,69 @@ export const do일반내정 = (ai: GeneralAI) => {
|
||||
if (develRate.def[0] < 1) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_수비강화', {}, '일반내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.def[0], 0.001),
|
||||
strength / valueFit(develRate.def[0], 0.001),
|
||||
]);
|
||||
}
|
||||
if (develRate.wall[0] < 1) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_성벽보수', {}, '일반내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.wall[0], 0.001),
|
||||
strength / valueFit(develRate.wall[0], 0.001),
|
||||
]);
|
||||
}
|
||||
if (develRate.secu[0] < 0.9) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1),
|
||||
strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1),
|
||||
]);
|
||||
} else if (develRate.secu[0] < 1) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'),
|
||||
ai.general.stats.strength / 2 / valueFit(develRate.secu[0], 0.001),
|
||||
strength / 2 / valueFit(develRate.secu[0], 0.001),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (ai.genType & t지장) {
|
||||
if (!isTechLimited(ai, tech)) {
|
||||
const nextTech = (tech % 1000) + 1;
|
||||
const weight = !isTechLimited(ai, tech + 1000)
|
||||
? ai.general.stats.intelligence / (nextTech / 2000)
|
||||
: ai.general.stats.intelligence;
|
||||
// PHP's `%` coerces the FLOAT nation tech value to an integer
|
||||
// before taking the remainder. Keeping the fraction here can move
|
||||
// a weighted draw across a candidate boundary.
|
||||
const nextTech = (Math.trunc(tech) % 1000) + 1;
|
||||
const weight = !isTechLimited(ai, tech + 1000) ? intelligence / (nextTech / 2000) : intelligence;
|
||||
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), weight]);
|
||||
}
|
||||
if (develRate.agri[0] < 1) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'),
|
||||
((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) / valueFit(develRate.agri[0], 0.001, 1),
|
||||
((isSpringSummer ? 1.2 : 0.8) * intelligence) / valueFit(develRate.agri[0], 0.001, 1),
|
||||
]);
|
||||
}
|
||||
if (develRate.comm[0] < 1) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_상업투자', {}, '일반내정'),
|
||||
((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) / valueFit(develRate.comm[0], 0.001, 1),
|
||||
((isSpringSummer ? 0.8 : 1.2) * intelligence) / valueFit(develRate.comm[0], 0.001, 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, cmdList);
|
||||
const picked = pickWeightedCandidate(ai, cmdList);
|
||||
const traceGeneralIds = process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? [];
|
||||
if (traceGeneralIds.includes(String(ai.general.id))) {
|
||||
process.stdout.write(
|
||||
`AI_DEVEL_TRACE ${JSON.stringify({
|
||||
generalId: ai.general.id,
|
||||
year: ai.world.currentYear,
|
||||
month: ai.world.currentMonth,
|
||||
genType: ai.genType,
|
||||
city,
|
||||
candidates: cmdList.flatMap(([candidate, weight]) =>
|
||||
candidate ? [{ action: candidate.action, weight }] : []
|
||||
),
|
||||
picked: picked?.action ?? null,
|
||||
})}\n`
|
||||
);
|
||||
}
|
||||
return picked;
|
||||
};
|
||||
|
||||
export const do긴급내정 = (ai: GeneralAI) => {
|
||||
@@ -111,12 +133,13 @@ export const do긴급내정 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
const trust = resolveCityTrust(city);
|
||||
if (trust < 70 && ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin)) {
|
||||
const leadership = readMetaNumber(asRecord(ai.general.meta), 'effectiveLeadership', ai.general.stats.leadership);
|
||||
if (trust < 70 && ai.rng.nextBool(leadership / ai.aiConst.chiefStatMin)) {
|
||||
return ai.buildGeneralCandidate('che_주민선정', {}, '긴급내정');
|
||||
}
|
||||
if (
|
||||
city.population < ai.nationPolicy.minNpcRecruitCityPopulation &&
|
||||
ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin / 2)
|
||||
ai.rng.nextBool(leadership / ai.aiConst.chiefStatMin / 2)
|
||||
) {
|
||||
return ai.buildGeneralCandidate('che_정착장려', {}, '긴급내정');
|
||||
}
|
||||
@@ -139,6 +162,10 @@ export const do전쟁내정 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
const develRate = ai.calcCityDevelRate(city);
|
||||
const generalMeta = asRecord(ai.general.meta);
|
||||
const leadership = readMetaNumber(generalMeta, 'effectiveLeadership', ai.general.stats.leadership);
|
||||
const strength = readMetaNumber(generalMeta, 'effectiveStrength', ai.general.stats.strength);
|
||||
const intelligence = readMetaNumber(generalMeta, 'effectiveIntelligence', ai.general.stats.intelligence);
|
||||
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
|
||||
const isSpringSummer = ai.world.currentMonth <= 6;
|
||||
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
|
||||
@@ -147,13 +174,13 @@ export const do전쟁내정 = (ai: GeneralAI) => {
|
||||
if (develRate.trust[0] < 0.98) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_주민선정', {}, '전쟁내정'),
|
||||
(ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
|
||||
(leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2,
|
||||
]);
|
||||
}
|
||||
if (develRate.pop[0] < 0.8) {
|
||||
const weight = [1, 3].includes(city.frontState)
|
||||
? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001)
|
||||
: ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
|
||||
? leadership / valueFit(develRate.pop[0], 0.001)
|
||||
: leadership / valueFit(develRate.pop[0], 0.001) / 2;
|
||||
cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]);
|
||||
}
|
||||
}
|
||||
@@ -162,52 +189,62 @@ export const do전쟁내정 = (ai: GeneralAI) => {
|
||||
if (develRate.def[0] < 0.5) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_수비강화', {}, '전쟁내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.def[0], 0.001) / 2,
|
||||
strength / valueFit(develRate.def[0], 0.001) / 2,
|
||||
]);
|
||||
}
|
||||
if (develRate.wall[0] < 0.5) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_성벽보수', {}, '전쟁내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.wall[0], 0.001) / 2,
|
||||
strength / valueFit(develRate.wall[0], 0.001) / 2,
|
||||
]);
|
||||
}
|
||||
if (develRate.secu[0] < 0.5) {
|
||||
cmdList.push([
|
||||
ai.buildGeneralCandidate('che_치안강화', {}, '전쟁내정'),
|
||||
ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1) / 4,
|
||||
strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1) / 4,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (ai.genType & t지장) {
|
||||
if (!isTechLimited(ai, tech)) {
|
||||
const nextTech = (tech % 1000) + 1;
|
||||
const weight = !isTechLimited(ai, tech + 1000)
|
||||
? ai.general.stats.intelligence / (nextTech / 3000)
|
||||
: ai.general.stats.intelligence;
|
||||
const nextTech = (Math.trunc(tech) % 1000) + 1;
|
||||
const weight = !isTechLimited(ai, tech + 1000) ? intelligence / (nextTech / 3000) : intelligence;
|
||||
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), weight]);
|
||||
}
|
||||
if (develRate.agri[0] < 0.5) {
|
||||
const weight = [1, 3].includes(city.frontState)
|
||||
? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
|
||||
4 /
|
||||
valueFit(develRate.agri[0], 0.001, 1)
|
||||
: ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
|
||||
2 /
|
||||
valueFit(develRate.agri[0], 0.001, 1);
|
||||
? ((isSpringSummer ? 1.2 : 0.8) * intelligence) / 4 / valueFit(develRate.agri[0], 0.001, 1)
|
||||
: ((isSpringSummer ? 1.2 : 0.8) * intelligence) / 2 / valueFit(develRate.agri[0], 0.001, 1);
|
||||
cmdList.push([ai.buildGeneralCandidate('che_농지개간', {}, '전쟁내정'), weight]);
|
||||
}
|
||||
if (develRate.comm[0] < 0.5) {
|
||||
const weight = [1, 3].includes(city.frontState)
|
||||
? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
|
||||
4 /
|
||||
valueFit(develRate.comm[0], 0.001, 1)
|
||||
: ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
|
||||
2 /
|
||||
valueFit(develRate.comm[0], 0.001, 1);
|
||||
? ((isSpringSummer ? 0.8 : 1.2) * intelligence) / 4 / valueFit(develRate.comm[0], 0.001, 1)
|
||||
: ((isSpringSummer ? 0.8 : 1.2) * intelligence) / 2 / valueFit(develRate.comm[0], 0.001, 1);
|
||||
cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, cmdList);
|
||||
const picked = pickWeightedCandidate(ai, cmdList);
|
||||
const traceGeneralIds = process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? [];
|
||||
if (traceGeneralIds.includes(String(ai.general.id))) {
|
||||
process.stdout.write(
|
||||
`AI_DEVEL_TRACE ${JSON.stringify({
|
||||
generalId: ai.general.id,
|
||||
year: ai.world.currentYear,
|
||||
month: ai.world.currentMonth,
|
||||
mode: '전쟁내정',
|
||||
genType: ai.genType,
|
||||
dipState: ai.dipState,
|
||||
city,
|
||||
nationTech: tech,
|
||||
candidates: cmdList.flatMap(([candidate, weight]) =>
|
||||
candidate ? [{ action: candidate.action, weight }] : []
|
||||
),
|
||||
picked: picked?.action ?? null,
|
||||
})}\n`
|
||||
);
|
||||
}
|
||||
return picked;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic';
|
||||
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
|
||||
|
||||
import type { GeneralAI } from '../core.js';
|
||||
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
|
||||
|
||||
export const do금쌀구매 = (ai: GeneralAI) => {
|
||||
const traceEnabled = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id));
|
||||
const trace = (stage: string, values: Record<string, unknown> = {}) => {
|
||||
if (!traceEnabled) {
|
||||
return;
|
||||
}
|
||||
process.stderr.write(
|
||||
`AI_ECONOMY_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n`
|
||||
);
|
||||
};
|
||||
const city = ai.city;
|
||||
if (!city) {
|
||||
trace('no-city');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -14,8 +25,9 @@ export const do금쌀구매 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kill = readMetaNumber(asRecord(ai.general.meta), 'killcrew', 50000) + 50000;
|
||||
const death = readMetaNumber(asRecord(ai.general.meta), 'deathcrew', 50000) + 50000;
|
||||
const generalMeta = asRecord(ai.general.meta);
|
||||
const kill = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0)) + 50000;
|
||||
const death = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0)) + 50000;
|
||||
const deathRate = death / kill;
|
||||
|
||||
const absGold = ai.general.gold;
|
||||
@@ -24,16 +36,64 @@ export const do금쌀구매 = (ai: GeneralAI) => {
|
||||
const relRice = absRice * deathRate;
|
||||
|
||||
const baseDevelCost = ai.commandEnv.develCost * 12;
|
||||
trace('resources', {
|
||||
absGold,
|
||||
absRice,
|
||||
relGold,
|
||||
relRice,
|
||||
deathRate,
|
||||
baseDevelCost,
|
||||
canIgnoreTrader: ai.generalPolicy.can('상인무시'),
|
||||
trade,
|
||||
});
|
||||
if (absGold + absRice < baseDevelCost * 2) {
|
||||
trace('insufficient-base-resource');
|
||||
return null;
|
||||
}
|
||||
|
||||
const crewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
|
||||
const tech = readMetaNumber(asRecord(ai.nation?.meta ?? {}), 'tech', 0);
|
||||
const fullLeadership = ai.general.stats.leadership;
|
||||
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
|
||||
const crewAmount = fullLeadership * 100;
|
||||
const goldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0;
|
||||
const riceCost = crewAmount / 100;
|
||||
const rawGoldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0;
|
||||
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
|
||||
const goldCost = Math.round(
|
||||
actionPipeline.onCalcDomestic(
|
||||
{
|
||||
general: ai.general,
|
||||
nation: ai.nation ?? undefined,
|
||||
...(ai.worldRef
|
||||
? {
|
||||
worldView: {
|
||||
listGenerals: () => ai.worldRef!.listGenerals(),
|
||||
listGeneralsByCity: (cityId: number) =>
|
||||
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
||||
listNations: () => ai.worldRef!.listNations(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
time: {
|
||||
year: ai.world.currentYear,
|
||||
month: ai.world.currentMonth,
|
||||
startYear: ai.startYear,
|
||||
},
|
||||
},
|
||||
'징병',
|
||||
'cost',
|
||||
rawGoldCost,
|
||||
{ armType: crewType?.armType ?? 0 }
|
||||
) * (ai.generalPolicy.can('모병') ? 2 : 1)
|
||||
);
|
||||
const riceCost = crewType ? (crewType.rice * getTechCost(tech) * crewAmount) / 100 : 0;
|
||||
trace('recruit-cost', {
|
||||
crewTypeId: crewType?.id ?? null,
|
||||
crewCost: crewType?.cost ?? null,
|
||||
crewRice: crewType?.rice ?? null,
|
||||
tech,
|
||||
crewAmount,
|
||||
goldCost,
|
||||
riceCost,
|
||||
});
|
||||
|
||||
if ((relGold + relRice) * 1.5 <= goldCost + riceCost) {
|
||||
return null;
|
||||
@@ -55,9 +115,15 @@ export const do금쌀구매 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
if (tryBuying) {
|
||||
const amount = valueFit(Math.floor((relGold - relRice) / (1 + deathRate)), 100, ai.maxResourceActionAmount);
|
||||
const amount = valueFit(
|
||||
Math.floor((relGold - relRice) / (1 + deathRate)),
|
||||
100,
|
||||
ai.aiConst.maxResourceActionAmount
|
||||
);
|
||||
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
|
||||
return ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매');
|
||||
const result = ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매');
|
||||
trace('buy', { amount, minimumResourceActionAmount: ai.nationPolicy.minimumResourceActionAmount, result });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +139,11 @@ export const do금쌀구매 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
if (trySelling) {
|
||||
const amount = valueFit(Math.floor((relRice - relGold) / (1 + deathRate)), 100, ai.maxResourceActionAmount);
|
||||
const amount = valueFit(
|
||||
Math.floor((relRice - relGold) / (1 + deathRate)),
|
||||
100,
|
||||
ai.aiConst.maxResourceActionAmount
|
||||
);
|
||||
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
|
||||
return ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { searchDistance } from '@sammo-ts/logic/world/distance.js';
|
||||
import { searchDistance, searchDistanceEntries } from '@sammo-ts/logic/world/distance.js';
|
||||
|
||||
import type { GeneralAI } from '../core.js';
|
||||
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
|
||||
@@ -23,7 +23,8 @@ export const do국가선택 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
if (ai.world.currentYear < ai.startYear + 3) {
|
||||
const nations = ai.worldRef.listNations();
|
||||
// Ref queries the nation table, which has no synthetic neutral row.
|
||||
const nations = ai.worldRef.listNations().filter((nation) => nation.id > 0);
|
||||
const nationCount = nations.length;
|
||||
const notFullNationCount = nations.filter((nation) => {
|
||||
const count = ai.worldRef!.listGenerals().filter((general) => general.nationId === nation.id).length;
|
||||
@@ -104,18 +105,16 @@ export const do거병 = (ai: GeneralAI) => {
|
||||
.map((c) => c.id)
|
||||
);
|
||||
for (const general of ai.worldRef.listGenerals()) {
|
||||
if (general.officerLevel === 12 && general.nationId === 0) {
|
||||
// Ref joins through city and checks city.nation=0. A ruler of a newly
|
||||
// raised wandering nation therefore still occupies its neutral city.
|
||||
if (general.officerLevel === 12 && ai.worldRef.getCityById(general.cityId)?.nationId === 0) {
|
||||
occupied.add(general.cityId);
|
||||
}
|
||||
}
|
||||
|
||||
let availableNearCity = false;
|
||||
const nearby = searchDistance(ai.map, ai.general.cityId, 3);
|
||||
for (const [targetCityId, dist] of Object.entries(nearby)) {
|
||||
const cityId = Number(targetCityId);
|
||||
if (!Number.isFinite(cityId)) {
|
||||
continue;
|
||||
}
|
||||
const nearby = searchDistanceEntries(ai.map, ai.general.cityId, 3);
|
||||
for (const [cityId, dist] of nearby) {
|
||||
if (occupied.has(cityId)) {
|
||||
continue;
|
||||
}
|
||||
@@ -159,7 +158,10 @@ export const do건국 = (ai: GeneralAI) => {
|
||||
? (ai.rng.choice(ai.aiConst.availableNationTypes) as string)
|
||||
: 'che_도적';
|
||||
const colorType = ai.rng.nextRangeInt(0, 32);
|
||||
const nationName = `㉿${Array.from(ai.general.name).slice(1).join('')}`;
|
||||
// Ref stores the NPC display prefix in general.name and removes it here.
|
||||
// Core's installed scenario rows keep the prefix in npcState instead.
|
||||
const characters = Array.from(ai.general.name);
|
||||
const nationName = `㉿${characters[0] === 'ⓝ' ? characters.slice(1).join('') : ai.general.name}`;
|
||||
|
||||
const result = ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국');
|
||||
if (result) {
|
||||
@@ -198,13 +200,13 @@ export const do방랑군이동 = (ai: GeneralAI) => {
|
||||
if (!city || !ai.map || !ai.worldRef) {
|
||||
return null;
|
||||
}
|
||||
const lordCities = ai.worldRef
|
||||
.listGenerals()
|
||||
.filter((general) => general.officerLevel === 12 && general.nationId === 0)
|
||||
.map((general) => general.cityId);
|
||||
if (lordCities.filter((cityId) => cityId === city.id).length <= 1 && [5, 6].includes(city.level)) {
|
||||
const rulers = ai.worldRef.listGenerals().filter((general) => general.officerLevel === 12);
|
||||
if (rulers.filter((general) => general.cityId === city.id).length <= 1 && [5, 6].includes(city.level)) {
|
||||
return null;
|
||||
}
|
||||
const lordCities = rulers
|
||||
.filter((general) => ai.worldRef!.getCityById(general.cityId)?.nationId === 0)
|
||||
.map((general) => general.cityId);
|
||||
|
||||
const occupied = new Set(
|
||||
ai.worldRef
|
||||
@@ -222,11 +224,10 @@ export const do방랑군이동 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
if (movingTargetCityId === null) {
|
||||
const nearby = searchDistance(ai.map, city.id, 4);
|
||||
const nearby = searchDistanceEntries(ai.map, city.id, 4);
|
||||
const candidates: Array<[number, number]> = [];
|
||||
for (const [cityIdRaw, dist] of Object.entries(nearby)) {
|
||||
const cityId = Number(cityIdRaw);
|
||||
if (!Number.isFinite(cityId) || occupied.has(cityId)) {
|
||||
for (const [cityId, dist] of nearby) {
|
||||
if (occupied.has(cityId)) {
|
||||
continue;
|
||||
}
|
||||
const target = ai.worldRef.getCityById(cityId);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@sammo-ts/logic/world/unitSet.js';
|
||||
import { buildWarConfig } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||
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 { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
|
||||
@@ -40,26 +41,46 @@ const getRequiredTech = (crewType: CrewTypeDefinition): number | null => {
|
||||
};
|
||||
|
||||
export const do징병 = (ai: GeneralAI) => {
|
||||
const traceEnabled = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id));
|
||||
const trace = (stage: string, values: Record<string, unknown> = {}) => {
|
||||
if (traceEnabled) {
|
||||
process.stdout.write(
|
||||
`AI_RECRUIT_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n`
|
||||
);
|
||||
}
|
||||
};
|
||||
const city = ai.city;
|
||||
const nation = ai.nation;
|
||||
if (!city || !nation || !ai.unitSet || !ai.map) {
|
||||
trace('missing-context');
|
||||
return null;
|
||||
}
|
||||
if ([0, 1].includes(ai.dipState)) {
|
||||
trace('diplomacy', { dipState: ai.dipState });
|
||||
return null;
|
||||
}
|
||||
if (!(ai.genType & t통솔장)) {
|
||||
trace('general-type', { genType: ai.genType });
|
||||
return null;
|
||||
}
|
||||
if (ai.general.crew >= ai.nationPolicy.minWarCrew) {
|
||||
trace('existing-crew', { crew: ai.general.crew, minWarCrew: ai.nationPolicy.minWarCrew });
|
||||
return null;
|
||||
}
|
||||
|
||||
const generalMeta = asRecord(ai.general.meta);
|
||||
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
|
||||
trace('population-policy', {
|
||||
population: city.population,
|
||||
populationMax: city.populationMax,
|
||||
safeRatio: ai.nationPolicy.safeRecruitCityPopulationRatio,
|
||||
minPopulation: ai.nationPolicy.minNpcRecruitCityPopulation,
|
||||
canLimitRecruit: ai.generalPolicy.can('한계징병'),
|
||||
});
|
||||
if (!ai.generalPolicy.can('한계징병')) {
|
||||
const remainPop = city.population - ai.nationPolicy.minNpcRecruitCityPopulation - fullLeadership * 100;
|
||||
if (remainPop <= 0) {
|
||||
trace('population-floor', { remainPop, fullLeadership });
|
||||
return null;
|
||||
}
|
||||
const maxPop = city.populationMax - ai.nationPolicy.minNpcRecruitCityPopulation;
|
||||
@@ -67,6 +88,7 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
city.population / city.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio &&
|
||||
ai.rng.nextBool(remainPop / Math.max(1, maxPop))
|
||||
) {
|
||||
trace('population-random', { remainPop, maxPop, fullLeadership });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -82,10 +104,22 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
) {
|
||||
forcedArmType = 0;
|
||||
}
|
||||
const armType =
|
||||
forcedArmType > 0
|
||||
? forcedArmType
|
||||
: ai.rng.choiceUsingWeightPair(buildRecruitArmTypeWeights(ai.general, warConfig.armTypes));
|
||||
const armTypeWeights = forcedArmType > 0 ? [] : buildRecruitArmTypeWeights(ai.general, warConfig.armTypes);
|
||||
let armTypeDraw: number | null = null;
|
||||
const armType = forcedArmType > 0
|
||||
? forcedArmType
|
||||
: traceEnabled
|
||||
? (() => {
|
||||
armTypeDraw = ai.rng.nextFloat1();
|
||||
let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0);
|
||||
for (const [candidate, weight] of armTypeWeights) {
|
||||
if (cursor <= weight) return candidate;
|
||||
cursor -= Math.max(0, weight);
|
||||
}
|
||||
return armTypeWeights.at(-1)![0];
|
||||
})()
|
||||
: ai.rng.choiceUsingWeightPair(armTypeWeights);
|
||||
trace('arm-type', { forcedArmType, armType, armTypeDraw, armTypeWeights });
|
||||
|
||||
const candidates = (ai.unitSet?.crewTypes ?? [])
|
||||
.filter((crew) => crew.armType === armType)
|
||||
@@ -100,11 +134,17 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
})
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
trace('no-crew-type', { armType });
|
||||
return null;
|
||||
}
|
||||
let picked = ai.rng.choiceUsingWeightPair(
|
||||
candidates.map((crew) => [crew, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)])
|
||||
);
|
||||
trace('crew-type', {
|
||||
armType,
|
||||
candidates: candidates.map((crew) => [crew.id, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)]),
|
||||
picked: picked.id,
|
||||
});
|
||||
if (ai.generalPolicy.can('고급병종')) {
|
||||
const currentCrewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId);
|
||||
if (
|
||||
@@ -130,7 +170,39 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
const crewTypeId = picked.id;
|
||||
|
||||
let crewAmount = crewAmountBase;
|
||||
const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
|
||||
const rawGoldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
|
||||
// Ref asks the concrete che_징병 command for getCost() before deciding
|
||||
// whether to halve the requested crew. That path includes personality,
|
||||
// traits, items, and the final integer rounding; using the raw unit price
|
||||
// makes che_출세 (+20% cost) recruit a full stack incorrectly.
|
||||
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
|
||||
const goldCost = Math.round(
|
||||
actionPipeline.onCalcDomestic(
|
||||
{
|
||||
general: ai.general,
|
||||
nation,
|
||||
...(ai.worldRef
|
||||
? {
|
||||
worldView: {
|
||||
listGenerals: () => ai.worldRef!.listGenerals(),
|
||||
listGeneralsByCity: (cityId: number) =>
|
||||
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
||||
listNations: () => ai.worldRef!.listNations(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
time: {
|
||||
year: ai.world.currentYear,
|
||||
month: ai.world.currentMonth,
|
||||
startYear: ai.startYear,
|
||||
},
|
||||
},
|
||||
'징병',
|
||||
'cost',
|
||||
rawGoldCost,
|
||||
{ armType: picked.armType }
|
||||
)
|
||||
);
|
||||
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
|
||||
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
|
||||
const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1));
|
||||
@@ -139,6 +211,7 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
const remainingGold = ai.general.gold - fullLeadership * 3;
|
||||
const remainingRice = ai.general.rice - fullLeadership * 4;
|
||||
if (remainingGold <= 0 || remainingRice <= 0) {
|
||||
trace('reserve-floor', { remainingGold, remainingRice, fullLeadership });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -156,8 +229,18 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
if (!ai.generalPolicy.can('한계징병') && remainingRice * 1.1 <= riceCost) {
|
||||
trace('rice-cost', { remainingGold, remainingRice, goldCost, riceCost, crewAmount, crewTypeId });
|
||||
return null;
|
||||
}
|
||||
|
||||
return ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병');
|
||||
const result = ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병');
|
||||
trace(result ? 'selected' : 'constraint', {
|
||||
remainingGold,
|
||||
remainingRice,
|
||||
goldCost,
|
||||
riceCost,
|
||||
crewAmount,
|
||||
crewTypeId,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GeneralAI } from '../core.js';
|
||||
import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js';
|
||||
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js';
|
||||
import { t통솔장 } from './helpers.js';
|
||||
|
||||
export const do후방워프 = (ai: GeneralAI) => {
|
||||
@@ -20,11 +20,13 @@ export const do후방워프 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
let minRecruitPop = ai.general.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
|
||||
// Ref uses getLeadership(false): item/officer bonuses included, injury ignored.
|
||||
const fullLeadership = readMetaNumber(asRecord(ai.general.meta), 'fullLeadership', ai.general.stats.leadership);
|
||||
let minRecruitPop = fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop;
|
||||
if (!ai.generalPolicy.can('한계징병')) {
|
||||
minRecruitPop = Math.max(
|
||||
minRecruitPop,
|
||||
ai.general.stats.leadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
|
||||
fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +84,12 @@ export const do후방워프 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) {
|
||||
process.stdout.write(
|
||||
`AI_WARP_TRACE ${JSON.stringify({ generalId: ai.general.id, kind: 'rear', fullLeadership, minRecruitPop, recruitable })}\n`
|
||||
);
|
||||
}
|
||||
|
||||
return ai.buildGeneralCandidate(
|
||||
'che_NPC능동',
|
||||
{ optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(recruitable) },
|
||||
@@ -158,7 +166,6 @@ export const do내정워프 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
ai.categorizeNationCities();
|
||||
ai.categorizeNationGeneral();
|
||||
const candidateCities: Record<number, number> = {};
|
||||
for (const candidate of Object.values(ai.supplyCities)) {
|
||||
if (candidate.id === city.id) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GeneralAI } from '../../core.js';
|
||||
import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio, selectRecruitableCity } from '../helpers.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio } from '../helpers.js';
|
||||
|
||||
export const doNPC후방발령 = (ai: GeneralAI) => {
|
||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||
@@ -12,6 +13,26 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
|
||||
const actionContext = (general: GeneralAI['general']) => ({
|
||||
general,
|
||||
nation: ai.nation,
|
||||
...(ai.worldRef
|
||||
? {
|
||||
worldView: {
|
||||
listGenerals: () => ai.worldRef!.listGenerals(),
|
||||
listGeneralsByCity: (cityId: number) =>
|
||||
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
||||
listNations: () => ai.worldRef!.listNations(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
time: {
|
||||
year: ai.world.currentYear,
|
||||
month: ai.world.currentMonth,
|
||||
startYear: ai.startYear,
|
||||
},
|
||||
});
|
||||
const candidates = Object.values(ai.npcWarGenerals).filter((general) => {
|
||||
if (general.id === ai.general.id) {
|
||||
return false;
|
||||
@@ -29,16 +50,56 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
|
||||
if (general.crew >= ai.nationPolicy.minWarCrew) {
|
||||
return false;
|
||||
}
|
||||
if (actionPipeline.onCalcDomestic(actionContext(general), '징집인구', 'score', 100) <= 1) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ai.supplyCities).length === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const picked = ai.rng.choice(candidates);
|
||||
const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
|
||||
const destCityCandidates = selectRecruitableCity(ai, minPop);
|
||||
const fullLeadership = actionPipeline.onCalcStat(
|
||||
actionContext(picked),
|
||||
'leadership',
|
||||
picked.stats.leadership
|
||||
) as number;
|
||||
const minPop = Math.max(
|
||||
fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop,
|
||||
fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
|
||||
);
|
||||
const destCityCandidates: Record<number, number> = {};
|
||||
for (const city of Object.values(ai.backupCities)) {
|
||||
const ratio = resolveCityPopRatio(city);
|
||||
if (
|
||||
city.id !== ai.city?.id &&
|
||||
city.population >= ai.nationPolicy.minNpcRecruitCityPopulation &&
|
||||
city.population >= minPop &&
|
||||
ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio
|
||||
) {
|
||||
destCityCandidates[city.id] = ratio;
|
||||
}
|
||||
}
|
||||
if (Object.keys(destCityCandidates).length === 0) {
|
||||
for (const city of Object.values(ai.supplyCities)) {
|
||||
const ratio = resolveCityPopRatio(city);
|
||||
if (
|
||||
city.id !== ai.city?.id &&
|
||||
city.population >= ai.nationPolicy.minNpcRecruitCityPopulation &&
|
||||
city.population > minPop &&
|
||||
ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio
|
||||
) {
|
||||
// Ref contains a non-assigning `pop_ratio / 2` expression for
|
||||
// front cities, so the persisted behavior keeps this weight.
|
||||
destCityCandidates[city.id] = ratio;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(destCityCandidates).length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -102,12 +163,14 @@ export const doNPC전방발령 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ref consumes the general draw before the weighted front-city draw.
|
||||
// Reversing these two calls keeps the seed but assigns a different person.
|
||||
const destGeneral = ai.rng.choice(candidates);
|
||||
const cityCandidates = pickFrontCityWeight(ai);
|
||||
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
|
||||
if (!Number.isFinite(destCityId)) {
|
||||
return null;
|
||||
}
|
||||
const destGeneral = ai.rng.choice(candidates);
|
||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC전방발령');
|
||||
};
|
||||
|
||||
|
||||
@@ -2,9 +2,40 @@ import type { GeneralAI } from '../core.js';
|
||||
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
|
||||
import type { TurnGeneral } from '../../../types.js';
|
||||
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js';
|
||||
import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate } from './helpers.js';
|
||||
import { buildAwardCandidate, buildSeizureCandidate } from './helpers.js';
|
||||
|
||||
type ResourceName = 'gold' | 'rice';
|
||||
type ResourceCandidate = [{ destGeneralId: number; amount: number; isGold: boolean }, number];
|
||||
|
||||
const pickResourceCandidate = (
|
||||
ai: GeneralAI,
|
||||
action: 'award' | 'seizure',
|
||||
candidates: ResourceCandidate[],
|
||||
reason: string
|
||||
) => {
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Ref chooses the raw argument tuple first and only then checks the
|
||||
// command's full constraints. The draw is therefore observable even
|
||||
// when the selected command is rejected. Building/filtering every
|
||||
// command before the draw shifts the shared nation/general AI stream.
|
||||
const selected = ai.rng.choiceUsingWeightPair(candidates);
|
||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) {
|
||||
process.stdout.write(
|
||||
`AI_REWARD_SELECTION_TRACE ${JSON.stringify({
|
||||
engine: 'core',
|
||||
actor: ai.general.id,
|
||||
reason,
|
||||
candidates,
|
||||
selected,
|
||||
})}\n`
|
||||
);
|
||||
}
|
||||
return action === 'award'
|
||||
? buildAwardCandidate(ai, selected.destGeneralId, selected.amount, selected.isGold, reason)
|
||||
: buildSeizureCandidate(ai, selected.destGeneralId, selected.amount, selected.isGold, reason);
|
||||
};
|
||||
|
||||
const clampLegacy = (value: number, min: number | null, max: number | null): number => {
|
||||
if (min !== null && max !== null && max < min) {
|
||||
@@ -13,13 +44,30 @@ const clampLegacy = (value: number, min: number | null, max: number | null): num
|
||||
return Math.max(min ?? -Infinity, Math.min(max ?? Infinity, value));
|
||||
};
|
||||
|
||||
const getFullLeadership = (general: TurnGeneral): number =>
|
||||
readMetaNumber(asRecord(general.meta), 'fullLeadership', general.stats.leadership);
|
||||
const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => {
|
||||
const nationLevel = ai.nation?.level ?? 0;
|
||||
const officerBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0;
|
||||
const maxStat = ai.commandEnv.maxStatLevel ?? ai.scenarioConfig.stat.max;
|
||||
return Math.max(0, Math.min(general.stats.leadership + officerBonus, maxStat));
|
||||
};
|
||||
|
||||
const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, multiplier: number): number => {
|
||||
const getCrewGoldCost = (
|
||||
ai: GeneralAI,
|
||||
general: TurnGeneral,
|
||||
baseMultiplier: number,
|
||||
finalMultiplier = 1
|
||||
): number => {
|
||||
const crewType = findCrewTypeById(ai.unitSet, general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
|
||||
const tech = readMetaNumber(asRecord(ai.nation?.meta), 'tech', 0);
|
||||
return (crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(general) * multiplier;
|
||||
// Ref evaluates costWithTech() first, including its `/ 100`, and then
|
||||
// applies `* 100 * baseMultiplier * finalMultiplier` from GeneralAI.
|
||||
// Keeping that operation order is observable at exact resource boundaries
|
||||
// (for example 3036 versus 3036.0000000000005).
|
||||
return (
|
||||
((((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) * 100 *
|
||||
baseMultiplier) *
|
||||
finalMultiplier
|
||||
);
|
||||
};
|
||||
|
||||
const sortedByResource = (generals: Record<number, TurnGeneral>, resource: ResourceName, descending = false) =>
|
||||
@@ -35,7 +83,7 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
|
||||
if (!nation) {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const candidates: ResourceCandidate[] = [];
|
||||
const resourceMap: Array<[ResourceName, number]> = [
|
||||
['gold', ai.nationPolicy.reqHumanWarUrgentGold],
|
||||
['rice', ai.nationPolicy.reqHumanWarUrgentRice],
|
||||
@@ -50,7 +98,7 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
|
||||
if (!canUseGeneral(general)) {
|
||||
continue;
|
||||
}
|
||||
let required = getCrewGoldCost(ai, general, 3 * 1.1);
|
||||
let required = getCrewGoldCost(ai, general, 3, 1.1);
|
||||
if (ai.world.currentYear > ai.startYear + 3) {
|
||||
required = Math.max(required, minimum);
|
||||
}
|
||||
@@ -64,14 +112,11 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
|
||||
continue;
|
||||
}
|
||||
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
||||
candidates.push([
|
||||
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'),
|
||||
generals.length - index,
|
||||
]);
|
||||
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, candidates);
|
||||
return pickResourceCandidate(ai, 'award', candidates, '유저장긴급포상');
|
||||
};
|
||||
|
||||
export const do유저장포상 = (ai: GeneralAI) => {
|
||||
@@ -79,7 +124,7 @@ export const do유저장포상 = (ai: GeneralAI) => {
|
||||
if (!nation) {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const candidates: ResourceCandidate[] = [];
|
||||
const resourceMap: Array<[ResourceName, number, number, number]> = [
|
||||
[
|
||||
'gold',
|
||||
@@ -109,7 +154,7 @@ export const do유저장포상 = (ai: GeneralAI) => {
|
||||
}
|
||||
let enough: number;
|
||||
if (ai.userWarGenerals[general.id]) {
|
||||
let required = getCrewGoldCost(ai, general, 6 * 1.1);
|
||||
let required = getCrewGoldCost(ai, general, 6, 1.1);
|
||||
if (ai.world.currentYear > ai.startYear + 3) {
|
||||
required = Math.max(required, warMinimum);
|
||||
}
|
||||
@@ -126,14 +171,11 @@ export const do유저장포상 = (ai: GeneralAI) => {
|
||||
continue;
|
||||
}
|
||||
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
||||
candidates.push([
|
||||
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'),
|
||||
generals.length - index,
|
||||
]);
|
||||
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, candidates);
|
||||
return pickResourceCandidate(ai, 'award', candidates, '유저장포상');
|
||||
};
|
||||
|
||||
export const doNPC긴급포상 = (ai: GeneralAI) => {
|
||||
@@ -141,7 +183,7 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
|
||||
if (!nation) {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const candidates: ResourceCandidate[] = [];
|
||||
const resourceMap: Array<[ResourceName, number, number]> = [
|
||||
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2],
|
||||
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2],
|
||||
@@ -173,14 +215,11 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
|
||||
continue;
|
||||
}
|
||||
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
||||
candidates.push([
|
||||
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'),
|
||||
generals.length - index,
|
||||
]);
|
||||
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, candidates);
|
||||
return pickResourceCandidate(ai, 'award', candidates, 'NPC긴급포상');
|
||||
};
|
||||
|
||||
export const doNPC포상 = (ai: GeneralAI) => {
|
||||
@@ -188,7 +227,7 @@ export const doNPC포상 = (ai: GeneralAI) => {
|
||||
if (!nation) {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const candidates: ResourceCandidate[] = [];
|
||||
const resourceMap: Array<[ResourceName, number, number, number]> = [
|
||||
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
|
||||
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
|
||||
@@ -208,7 +247,7 @@ export const doNPC포상 = (ai: GeneralAI) => {
|
||||
if (!canUseGeneral(general)) {
|
||||
continue;
|
||||
}
|
||||
let required = getCrewGoldCost(ai, general, 3 * 1.1);
|
||||
let required = getCrewGoldCost(ai, general, 3, 1.1);
|
||||
if (ai.world.currentYear > ai.startYear + 5) {
|
||||
required = Math.max(required, warMinimum);
|
||||
}
|
||||
@@ -222,10 +261,23 @@ export const doNPC포상 = (ai: GeneralAI) => {
|
||||
continue;
|
||||
}
|
||||
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
||||
candidates.push([
|
||||
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
|
||||
weightBase - index,
|
||||
]);
|
||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) {
|
||||
process.stdout.write(
|
||||
`AI_REWARD_TRACE ${JSON.stringify({
|
||||
engine: 'core',
|
||||
actor: ai.general.id,
|
||||
target: general.id,
|
||||
resource: resKey,
|
||||
nationResource: nation[resKey],
|
||||
targetResource: general[resKey],
|
||||
required,
|
||||
enough,
|
||||
maxResourceActionAmount: ai.maxResourceActionAmount,
|
||||
amount,
|
||||
})}\n`
|
||||
);
|
||||
}
|
||||
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, weightBase - index]);
|
||||
}
|
||||
for (const [index, general] of civilGenerals.entries()) {
|
||||
if (general[resKey] >= civilMinimum) {
|
||||
@@ -239,14 +291,11 @@ export const doNPC포상 = (ai: GeneralAI) => {
|
||||
continue;
|
||||
}
|
||||
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
||||
candidates.push([
|
||||
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
|
||||
weightBase - index,
|
||||
]);
|
||||
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, weightBase - index]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, candidates);
|
||||
return pickResourceCandidate(ai, 'award', candidates, 'NPC포상');
|
||||
};
|
||||
|
||||
export const doNPC몰수 = (ai: GeneralAI) => {
|
||||
@@ -254,7 +303,7 @@ export const doNPC몰수 = (ai: GeneralAI) => {
|
||||
if (!nation) {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
|
||||
const candidates: ResourceCandidate[] = [];
|
||||
const resourceMap: Array<[ResourceName, number, number, number]> = [
|
||||
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
|
||||
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
|
||||
@@ -269,7 +318,7 @@ export const doNPC몰수 = (ai: GeneralAI) => {
|
||||
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
|
||||
break;
|
||||
}
|
||||
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
|
||||
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, amount]);
|
||||
}
|
||||
|
||||
const nationDelta = nationMinimum * 1.5 - nation[resKey];
|
||||
@@ -294,9 +343,9 @@ export const doNPC몰수 = (ai: GeneralAI) => {
|
||||
break;
|
||||
}
|
||||
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
||||
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
|
||||
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, amount]);
|
||||
}
|
||||
}
|
||||
|
||||
return pickWeightedCandidate(ai, candidates);
|
||||
return pickResourceCandidate(ai, 'seizure', candidates, 'NPC몰수');
|
||||
};
|
||||
|
||||
@@ -474,9 +474,11 @@ const buildNationUpdate = (
|
||||
color: nation.color,
|
||||
capitalCityId: nation.capitalCityId,
|
||||
chiefGeneralId: nation.chiefGeneralId,
|
||||
gold: nation.gold,
|
||||
rice: nation.rice,
|
||||
tech: typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) ? Math.trunc(nation.meta.tech) : 0,
|
||||
gold: toLegacyDatabaseInt(nation.gold),
|
||||
rice: toLegacyDatabaseInt(nation.rice),
|
||||
// Ref persists nation.tech as FLOAT. Domestic research commonly produces
|
||||
// tenths, and truncating here changes the following month's state/power.
|
||||
tech: typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) ? nation.meta.tech : 0,
|
||||
level: nation.level,
|
||||
typeCode: nation.typeCode,
|
||||
meta: asJson({
|
||||
@@ -755,11 +757,11 @@ export const createDatabaseTurnHooks = async (
|
||||
color: nation.color,
|
||||
capitalCityId: nation.capitalCityId,
|
||||
chiefGeneralId: nation.chiefGeneralId,
|
||||
gold: nation.gold,
|
||||
rice: nation.rice,
|
||||
gold: toLegacyDatabaseInt(nation.gold),
|
||||
rice: toLegacyDatabaseInt(nation.rice),
|
||||
tech:
|
||||
typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech)
|
||||
? Math.trunc(nation.meta.tech)
|
||||
? nation.meta.tech
|
||||
: 0,
|
||||
level: nation.level,
|
||||
typeCode: nation.typeCode,
|
||||
|
||||
@@ -57,7 +57,18 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
||||
let generalPartial = false;
|
||||
let nextCheckpoint: TurnCheckpoint | undefined = undefined;
|
||||
|
||||
const dueGenerals = this.world.listDueGenerals(targetTime, checkpoint);
|
||||
const previousLastTurnTime = this.world.getState().lastTurnTime;
|
||||
const firstTickTime = getNextTickTime(previousLastTurnTime, this.tickMinutes);
|
||||
// Ref processes `turntime < monthlyBoundary` before the monthly turn. A
|
||||
// general exactly on the boundary therefore runs only after that month
|
||||
// has advanced, on the daemon's following pass.
|
||||
const useStrictGeneralCutoff =
|
||||
firstTickTime.getTime() === targetTime.getTime() || targetTime.getTime() <= previousLastTurnTime.getTime();
|
||||
const generalCutoff =
|
||||
useStrictGeneralCutoff
|
||||
? new Date(targetTime.getTime() - 1)
|
||||
: targetTime;
|
||||
const dueGenerals = this.world.listDueGenerals(generalCutoff, checkpoint);
|
||||
for (const general of dueGenerals) {
|
||||
if (processedGenerals >= budget.maxGenerals || isBudgetExpired()) {
|
||||
partial = true;
|
||||
|
||||
@@ -233,15 +233,76 @@ const mergeTriggerState = (
|
||||
meta: { ...base.meta, ...(patch.meta ?? {}) },
|
||||
});
|
||||
|
||||
const applyGeneralPatch = (base: TurnGeneral, patch: Partial<TurnGeneral>): TurnGeneral => ({
|
||||
...base,
|
||||
...patch,
|
||||
stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats,
|
||||
role: patch.role ? mergeRole(base.role, patch.role) : base.role,
|
||||
triggerState: patch.triggerState ? mergeTriggerState(base.triggerState, patch.triggerState) : base.triggerState,
|
||||
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
|
||||
const toLegacyDatabaseInt = (value: number): number => {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
|
||||
};
|
||||
|
||||
const LEGACY_INTEGER_GENERAL_META_KEYS = [
|
||||
'leadership_exp',
|
||||
'strength_exp',
|
||||
'intel_exp',
|
||||
'dex1',
|
||||
'dex2',
|
||||
'dex3',
|
||||
'dex4',
|
||||
'dex5',
|
||||
'explevel',
|
||||
'dedlevel',
|
||||
'killturn',
|
||||
'myset',
|
||||
] as const;
|
||||
|
||||
const normalizeGeneralMetaDatabaseIntegers = (meta: TurnGeneral['meta']): TurnGeneral['meta'] => {
|
||||
const normalized = { ...meta };
|
||||
for (const key of LEGACY_INTEGER_GENERAL_META_KEYS) {
|
||||
const value = normalized[key];
|
||||
if (typeof value === 'number') {
|
||||
normalized[key] = toLegacyDatabaseInt(value);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
// Ref writes these values to MariaDB integer columns after every general turn.
|
||||
// Keeping fractional action results in memory until the monthly flush changes
|
||||
// later aggregation (notably nation power), even if the eventual DB rows look
|
||||
// identical after they are rounded.
|
||||
const normalizeGeneralDatabaseIntegers = (general: TurnGeneral): TurnGeneral => ({
|
||||
...general,
|
||||
nationId: toLegacyDatabaseInt(general.nationId),
|
||||
cityId: toLegacyDatabaseInt(general.cityId),
|
||||
troopId: toLegacyDatabaseInt(general.troopId),
|
||||
stats: {
|
||||
leadership: toLegacyDatabaseInt(general.stats.leadership),
|
||||
strength: toLegacyDatabaseInt(general.stats.strength),
|
||||
intelligence: toLegacyDatabaseInt(general.stats.intelligence),
|
||||
},
|
||||
experience: toLegacyDatabaseInt(general.experience),
|
||||
dedication: toLegacyDatabaseInt(general.dedication),
|
||||
officerLevel: toLegacyDatabaseInt(general.officerLevel),
|
||||
injury: toLegacyDatabaseInt(general.injury),
|
||||
gold: toLegacyDatabaseInt(general.gold),
|
||||
rice: toLegacyDatabaseInt(general.rice),
|
||||
crew: toLegacyDatabaseInt(general.crew),
|
||||
crewTypeId: toLegacyDatabaseInt(general.crewTypeId),
|
||||
train: toLegacyDatabaseInt(general.train),
|
||||
atmos: toLegacyDatabaseInt(general.atmos),
|
||||
age: toLegacyDatabaseInt(general.age),
|
||||
npcState: toLegacyDatabaseInt(general.npcState),
|
||||
meta: normalizeGeneralMetaDatabaseIntegers(general.meta),
|
||||
});
|
||||
|
||||
const applyGeneralPatch = (base: TurnGeneral, patch: Partial<TurnGeneral>): TurnGeneral =>
|
||||
normalizeGeneralDatabaseIntegers({
|
||||
...base,
|
||||
...patch,
|
||||
stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats,
|
||||
role: patch.role ? mergeRole(base.role, patch.role) : base.role,
|
||||
triggerState: patch.triggerState ? mergeTriggerState(base.triggerState, patch.triggerState) : base.triggerState,
|
||||
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
|
||||
});
|
||||
|
||||
const applyCityPatch = (base: City, patch: Partial<City>): City => ({
|
||||
...base,
|
||||
...patch,
|
||||
@@ -690,7 +751,7 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
const worldKillturn = resolveWorldKillturn(this.state.meta);
|
||||
const normalized = normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime);
|
||||
const ensured = ensureGeneralKillturn(normalized, worldKillturn);
|
||||
const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn));
|
||||
this.generals.set(general.id, ensured);
|
||||
this.dirtyGeneralIds.add(general.id);
|
||||
this.createdGeneralIds.add(general.id);
|
||||
@@ -994,10 +1055,10 @@ export class InMemoryTurnWorld {
|
||||
|
||||
const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule);
|
||||
if (!result.deleted?.general) {
|
||||
const nextGeneral = {
|
||||
const nextGeneral = normalizeGeneralDatabaseIntegers({
|
||||
...(result.general ?? currentGeneral),
|
||||
turnTime: nextTurnAt,
|
||||
};
|
||||
});
|
||||
this.generals.set(nextGeneral.id, nextGeneral);
|
||||
this.dirtyGeneralIds.add(nextGeneral.id);
|
||||
}
|
||||
@@ -1067,7 +1128,7 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
const worldKillturn = resolveWorldKillturn(this.state.meta);
|
||||
const normalized = normalizeGeneralTurnTime({ ...createdGeneral }, this.state.lastTurnTime);
|
||||
const ensured = ensureGeneralKillturn(normalized, worldKillturn);
|
||||
const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn));
|
||||
this.generals.set(createdGeneral.id, ensured);
|
||||
this.dirtyGeneralIds.add(createdGeneral.id);
|
||||
this.createdGeneralIds.add(createdGeneral.id);
|
||||
|
||||
@@ -21,6 +21,9 @@ import type { InMemoryTurnWorld, TurnCalendarHandler, TurnCalendarContext } from
|
||||
import { resolveAppliedNationRate } from './nationTaxRate.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
|
||||
const DEFAULT_BASE_GOLD = 0;
|
||||
const DEFAULT_BASE_RICE = 2_000;
|
||||
|
||||
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
@@ -97,8 +100,6 @@ const pushLogs = (world: InMemoryTurnWorld, logs: ReturnType<ActionLogger['flush
|
||||
}
|
||||
};
|
||||
|
||||
const roundResource = (value: number): number => Math.round(value);
|
||||
|
||||
const applyIncomeOutcome = (
|
||||
current: number,
|
||||
income: number,
|
||||
@@ -144,7 +145,11 @@ const processIncomeForNation = (
|
||||
: getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
|
||||
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
|
||||
|
||||
const incomeValue = roundResource(income);
|
||||
// Ref calculates the payout ratio from the pre-persistence income value.
|
||||
// Half-unit income (for example 943.5) is therefore not rounded before
|
||||
// salaries are distributed, even though the integer nation column is
|
||||
// rounded when the final state is flushed to MariaDB.
|
||||
const incomeValue = income;
|
||||
const originOutcome = getOutcome(100, nationGenerals);
|
||||
const bill = resolveNationBill(nation);
|
||||
const outcome = Math.round((bill / 100) * originOutcome);
|
||||
@@ -167,6 +172,14 @@ const processIncomeForNation = (
|
||||
type === 'gold' ? `이번 수입은 금 <C>${incomeText}</>입니다.` : `이번 수입은 쌀 <C>${incomeText}</>입니다.`;
|
||||
for (const general of nationGenerals) {
|
||||
const pay = Math.round(getBill(general.dedication) * ratio);
|
||||
if (
|
||||
process.env.SEED_PARITY_MONTHLY_RESOURCE_TRACE === '1' &&
|
||||
(process.env.AI_TRACE_GENERAL_IDS ?? '').split(',').includes(String(general.id))
|
||||
) {
|
||||
process.stdout.write(
|
||||
`MONTHLY_RESOURCE_CORE ${JSON.stringify({ action: 'ProcessIncome', type, generalId: general.id, current: general[type], pay, ratio, originOutcome })}\n`
|
||||
);
|
||||
}
|
||||
if (type === 'gold') {
|
||||
world.updateGeneral(general.id, { gold: general.gold + pay });
|
||||
} else {
|
||||
@@ -197,8 +210,8 @@ export const createIncomeHandler = (options: {
|
||||
nationTraits: Map<string, NationTraitModule>;
|
||||
}): IncomeHandler => {
|
||||
const constValues = asRecord(options.scenarioConfig.const);
|
||||
const baseGold = resolveNumber(constValues, ['baseGold', 'basegold'], 0);
|
||||
const baseRice = resolveNumber(constValues, ['baseRice', 'baserice'], 0);
|
||||
const baseGold = resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD);
|
||||
const baseRice = resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE);
|
||||
|
||||
const runResource = (type: 'gold' | 'rice'): void => {
|
||||
const world = options.getWorld();
|
||||
@@ -217,6 +230,9 @@ export const createIncomeHandler = (options: {
|
||||
}
|
||||
|
||||
for (const nation of nations) {
|
||||
if (nation.id <= 0) {
|
||||
continue;
|
||||
}
|
||||
const nationGenerals = byNation.get(nation.id) ?? [];
|
||||
const officerCounts = buildOfficerCountMap(nationGenerals);
|
||||
processIncomeForNation(
|
||||
|
||||
@@ -241,6 +241,11 @@ export const applyInitialChangeCityEvents = <T extends CitySeed>(
|
||||
throw new Error('Only unconditional initial events are supported.');
|
||||
}
|
||||
for (const rawAction of rawEvent.slice(1)) {
|
||||
if (Array.isArray(rawAction) && rawAction[0] === 'NoticeToHistoryLog') {
|
||||
// Initial history logging is handled by the install history
|
||||
// boundary; it has no city-state effect here.
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(rawAction) || rawAction[0] !== 'ChangeCity') {
|
||||
throw new Error('Only ChangeCity initial actions are supported.');
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ export const createRaiseDisasterHandler = (options: {
|
||||
const securityRatio = clamp(city.security / city.securityMax / 0.8, 0, 1);
|
||||
const affectRatio = isGood ? 1.01 + securityRatio * 0.04 : 0.8 + securityRatio * 0.15;
|
||||
const trust = typeof city.meta.trust === 'number' ? city.meta.trust : 0;
|
||||
const storedTrust = Math.fround(isGood ? Math.min(trust * affectRatio, 100) : trust * affectRatio);
|
||||
world.updateCity(city.id, {
|
||||
state: picked.stateCode,
|
||||
population: roundLegacyIntegerColumn(
|
||||
@@ -158,9 +159,25 @@ export const createRaiseDisasterHandler = (options: {
|
||||
),
|
||||
meta: {
|
||||
...city.meta,
|
||||
trust: isGood ? Math.min(trust * affectRatio, 100) : trust * affectRatio,
|
||||
// Ref assigns the SQL expression to a MariaDB FLOAT
|
||||
// column at each disaster event boundary.
|
||||
trust: storedTrust,
|
||||
},
|
||||
});
|
||||
if ((process.env.CORE_AI_TRACE_CITY_IDS?.split(',') ?? []).includes(String(city.id))) {
|
||||
process.stdout.write(
|
||||
`MONTHLY_FLOAT_TRACE ${JSON.stringify({
|
||||
engine: 'core',
|
||||
cityId: city.id,
|
||||
year: environment.year,
|
||||
month: environment.month,
|
||||
isGood,
|
||||
inputTrust: trust,
|
||||
affectRatio,
|
||||
storedTrust,
|
||||
})}\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (isGood) {
|
||||
continue;
|
||||
|
||||
@@ -86,7 +86,9 @@ export const createRandomizeCityTradeRateHandler = (options: {
|
||||
)
|
||||
);
|
||||
|
||||
for (const city of world.listCities()) {
|
||||
// Ref's `SELECT city, level FROM city` walks the primary city key.
|
||||
// Make RNG consumption independent from PostgreSQL snapshot/insertion order.
|
||||
for (const city of world.listCities().sort((left, right) => left.id - right.id)) {
|
||||
const probability = CITY_TRADE_PROBABILITY_BY_LEVEL[city.level];
|
||||
if (probability === undefined) {
|
||||
throw new Error(`Unsupported city level for RandomizeCityTradeRate: ${city.level} (cityId=${city.id})`);
|
||||
@@ -226,6 +228,11 @@ export const createMonthlyEventHandler = (options: {
|
||||
const year = targetCode === 'pre_month' ? context.previousYear : context.currentYear;
|
||||
const month = targetCode === 'pre_month' ? context.previousMonth : context.currentMonth;
|
||||
const remainingNationCount = world.listNations().filter((nation) => nation.id > 0).length;
|
||||
// Ref does not write game_env.turntime until every event and
|
||||
// postUpdateMonthly step has completed. Event actions therefore see
|
||||
// the previous monthly boundary even after turnDate() has advanced
|
||||
// year/month. Generated general turn times depend on this distinction.
|
||||
const legacyTurnTime = new Date(context.turnTime.getTime() - world.getState().tickSeconds * 1_000);
|
||||
|
||||
for (const event of world.listEvents(targetCode)) {
|
||||
const environment: MonthlyEventEnvironment = {
|
||||
@@ -233,7 +240,7 @@ export const createMonthlyEventHandler = (options: {
|
||||
month,
|
||||
startyear: options.startYear,
|
||||
currentEventID: event.id,
|
||||
turnTime: context.turnTime,
|
||||
turnTime: legacyTurnTime,
|
||||
};
|
||||
if (!evaluateCondition(event.condition, environment, remainingNationCount)) {
|
||||
continue;
|
||||
|
||||
@@ -249,6 +249,12 @@ export const createUpdateNationLevelHandler = (options: {
|
||||
const hiddenSeed = resolveHiddenSeed(world);
|
||||
|
||||
for (const nation of world.listNations().sort((left, right) => left.id - right.id)) {
|
||||
// Legacy persists only founded nations in `nation`; Core also keeps
|
||||
// an id=0 sentinel so neutral cities can retain a Nation reference.
|
||||
// The sentinel must never participate in title promotion.
|
||||
if (nation.id <= 0) {
|
||||
continue;
|
||||
}
|
||||
const cityCount = cityCounts.get(nation.id) ?? 0;
|
||||
let newLevel = 0;
|
||||
for (let level = 0; level < NATION_LEVEL_CITY_COUNTS.length; level += 1) {
|
||||
|
||||
@@ -7,6 +7,11 @@ import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'
|
||||
const MAX_AVAILABLE_WAR_SETTING_COUNT = 10;
|
||||
const MONTHLY_AVAILABLE_WAR_SETTING_INCREMENT = 2;
|
||||
|
||||
export const roundLegacyNationPowerValue = (value: number): number => {
|
||||
const stabilized = Number(value.toPrecision(15));
|
||||
return stabilized >= 0 ? Math.floor(stabilized + 0.5) : Math.ceil(stabilized - 0.5);
|
||||
};
|
||||
|
||||
const readNumber = (value: unknown, fallback = 0): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
@@ -32,15 +37,16 @@ const calculateNationPower = (
|
||||
): {
|
||||
power: number;
|
||||
totalCrew: number;
|
||||
trace: Record<string, number>;
|
||||
} => {
|
||||
const nation = world.getNationById(nationId);
|
||||
if (!nation) {
|
||||
return { power: 0, totalCrew: 0 };
|
||||
return { power: 0, totalCrew: 0, trace: {} };
|
||||
}
|
||||
const generals = world.listGenerals().filter((general) => general.nationId === nationId);
|
||||
const suppliedCities = world.listCities().filter((city) => city.nationId === nationId && city.supplyState === 1);
|
||||
const generalResources = generals.reduce((sum, general) => sum + general.gold + general.rice, 0);
|
||||
const resourcePower = Math.round((nation.gold + nation.rice + generalResources) / 100);
|
||||
const resourcePower = roundLegacyNationPowerValue((nation.gold + nation.rice + generalResources) / 100);
|
||||
const techPower = readNumber(asRecord(nation.meta).tech);
|
||||
|
||||
let cityPower = 0;
|
||||
@@ -62,7 +68,7 @@ const calculateNationPower = (
|
||||
city.defenceMax,
|
||||
0
|
||||
);
|
||||
cityPower = maximum > 0 ? Math.round((population * current) / maximum / 100) : 0;
|
||||
cityPower = maximum > 0 ? roundLegacyNationPowerValue((population * current) / maximum / 100) : 0;
|
||||
}
|
||||
|
||||
let generalPower = 0;
|
||||
@@ -90,16 +96,29 @@ const calculateNationPower = (
|
||||
totalCrew += general.crew;
|
||||
}
|
||||
|
||||
const power = Math.round(
|
||||
const power = roundLegacyNationPowerValue(
|
||||
(resourcePower +
|
||||
techPower +
|
||||
cityPower +
|
||||
generalPower +
|
||||
Math.round(dexterityPower / 1000) +
|
||||
Math.round(experiencePower / 100)) /
|
||||
roundLegacyNationPowerValue(dexterityPower / 1000) +
|
||||
roundLegacyNationPowerValue(experiencePower / 100)) /
|
||||
10
|
||||
);
|
||||
return { power, totalCrew };
|
||||
return {
|
||||
power,
|
||||
totalCrew,
|
||||
trace: {
|
||||
resourcePower,
|
||||
techPower,
|
||||
cityPower,
|
||||
generalPower,
|
||||
dexterityPowerRaw: dexterityPower / 1000,
|
||||
dexterityPower: roundLegacyNationPowerValue(dexterityPower / 1000),
|
||||
experiencePowerRaw: experiencePower / 100,
|
||||
experiencePower: roundLegacyNationPowerValue(experiencePower / 100),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): number => {
|
||||
@@ -110,10 +129,25 @@ const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): number => {
|
||||
citiesByNation.set(city.nationId, names);
|
||||
}
|
||||
|
||||
const nations = world.listNations().sort((left, right) => left.id - right.id);
|
||||
// Core keeps an internal nation 0 record, while Ref's nation table/query
|
||||
// starts at 1. It must not consume a monthly RNG draw or receive power.
|
||||
const nations = world
|
||||
.listNations()
|
||||
.filter((nation) => nation.id > 0)
|
||||
.sort((left, right) => left.id - right.id);
|
||||
for (const nation of nations) {
|
||||
const calculated = calculateNationPower(world, nation.id);
|
||||
const power = Math.round(calculated.power * rng.nextRange(0.95, 1.05));
|
||||
const multiplier = rng.nextRange(0.95, 1.05);
|
||||
const power = roundLegacyNationPowerValue(calculated.power * multiplier);
|
||||
const traceIds = (process.env.SEED_PARITY_TRACE_NATION_POWER_IDS ?? '')
|
||||
.split(',')
|
||||
.map((value) => Number(value.trim()))
|
||||
.filter(Number.isFinite);
|
||||
if (traceIds.includes(nation.id)) {
|
||||
process.stdout.write(
|
||||
`NATION_POWER_TRACE ${JSON.stringify({ nationId: nation.id, ...calculated.trace, basePower: calculated.power, multiplier, power })}\n`
|
||||
);
|
||||
}
|
||||
const meta = asRecord(nation.meta);
|
||||
const previousMax = asRecord(meta.max_power);
|
||||
const previousCities = Array.isArray(previousMax.maxCities)
|
||||
|
||||
@@ -7,7 +7,12 @@ import { resolveAppliedNationRate } from './nationTaxRate.js';
|
||||
|
||||
type SemiAnnualResource = 'gold' | 'rice';
|
||||
|
||||
const roundLegacyIntegerColumn = (value: number): number => Math.round(value);
|
||||
const roundLegacyIntegerColumn = (value: number): number => {
|
||||
// MariaDB evaluates the decimal rate expression before ROUND(). Binary
|
||||
// arithmetic can instead produce values such as 2029.4999999999998.
|
||||
const stabilized = Number(value.toPrecision(15));
|
||||
return stabilized >= 0 ? Math.floor(stabilized + 0.5) : Math.ceil(stabilized - 0.5);
|
||||
};
|
||||
|
||||
const parseResource = (args: readonly unknown[]): SemiAnnualResource => {
|
||||
const resource = args[0];
|
||||
@@ -24,6 +29,8 @@ const resolveBasePopulationIncrease = (world: InMemoryTurnWorld): number => {
|
||||
|
||||
const decayDomesticValue = (value: number): number => roundLegacyIntegerColumn(value * 0.99);
|
||||
|
||||
export const storeLegacySemiAnnualTrust = (value: number): number => Math.fround(Math.max(0, Math.min(100, value)));
|
||||
|
||||
const applyResourceMaintenance = (value: number, ratios: readonly [number, number][]): number => {
|
||||
if (value <= 1_000) {
|
||||
return value;
|
||||
@@ -81,6 +88,9 @@ export const createProcessSemiAnnualHandler = (options: {
|
||||
|
||||
const basePopulationIncrease = resolveBasePopulationIncrease(world);
|
||||
for (const nation of world.listNations()) {
|
||||
if (nation.id <= 0) {
|
||||
continue;
|
||||
}
|
||||
const rate = resolveAppliedNationRate(nation.meta);
|
||||
let populationRatio = (30 - rate) / 200;
|
||||
const trait = options.nationTraits?.get(nation.typeCode);
|
||||
@@ -115,7 +125,10 @@ export const createProcessSemiAnnualHandler = (options: {
|
||||
wall: roundLegacyIntegerColumn(Math.min(city.wallMax, city.wall * (1 + genericRatio))),
|
||||
meta: {
|
||||
...city.meta,
|
||||
trust: Math.max(0, Math.min(100, trust + trustDiff)),
|
||||
// Ref's UPDATE persists trust to a MariaDB FLOAT before
|
||||
// the next monthly action reads it. Core stores this
|
||||
// field in JSON, so emulate that binary32 boundary here.
|
||||
trust: storeLegacySemiAnnualTrust(trust + trustDiff),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -124,6 +137,14 @@ export const createProcessSemiAnnualHandler = (options: {
|
||||
for (const general of world.listGenerals()) {
|
||||
const current = general[resource];
|
||||
const next = applyResourceMaintenance(current, [[10_000, 0.97]]);
|
||||
if (
|
||||
process.env.SEED_PARITY_MONTHLY_RESOURCE_TRACE === '1' &&
|
||||
(process.env.AI_TRACE_GENERAL_IDS ?? '').split(',').includes(String(general.id))
|
||||
) {
|
||||
process.stdout.write(
|
||||
`MONTHLY_RESOURCE_CORE ${JSON.stringify({ action: 'ProcessSemiAnnual', resource, generalId: general.id, current, next })}\n`
|
||||
);
|
||||
}
|
||||
if (next !== current) {
|
||||
world.updateGeneral(general.id, { [resource]: next });
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ export const createProcessWarIncomeHandler = (options: {
|
||||
for (const city of cities) {
|
||||
const dead = asNumber(city.meta.dead, 0);
|
||||
world.updateCity(city.id, {
|
||||
// MariaDB rounds the SQL expression when assigning it to
|
||||
// Ref's integer pop column (including an exact .5 result).
|
||||
population: Math.round(city.population + dead * 0.2),
|
||||
meta: {
|
||||
...city.meta,
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import {
|
||||
createIncomeActionContext,
|
||||
getGoldIncome,
|
||||
getOutcome,
|
||||
getRiceIncome,
|
||||
getWallIncome,
|
||||
getWarGoldIncome,
|
||||
type City,
|
||||
type CityIncomeSource,
|
||||
type Nation,
|
||||
type NationIncomeContext,
|
||||
type NationTraitModule,
|
||||
type ScenarioConfig,
|
||||
type TurnCommandEnv,
|
||||
type UnitSetDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnCalendarContext, TurnCalendarHandler, InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import type { City, Nation } from '@sammo-ts/logic';
|
||||
import { joinYearMonth, readNumber } from './ai/aiUtils.js';
|
||||
import { readNumber } from './ai/aiUtils.js';
|
||||
import { AutorunNationPolicy } from './ai/policies.js';
|
||||
|
||||
const calcNationDevelopedRate = (cities: City[]): { pop: number; all: number } => {
|
||||
if (cities.length === 0) {
|
||||
@@ -51,15 +69,129 @@ const resolveNpcTaxRate = (cities: City[]): number => {
|
||||
|
||||
const shouldUpdateRate = (month: number): boolean => month === 6 || month === 12;
|
||||
|
||||
const isNpcMonarchNation = (nation: Nation, world: InMemoryTurnWorld): boolean => {
|
||||
if (!nation.capitalCityId || !nation.chiefGeneralId) {
|
||||
return false;
|
||||
const toIncomeCity = (city: City): CityIncomeSource => ({
|
||||
id: city.id,
|
||||
population: city.population,
|
||||
populationMax: city.populationMax,
|
||||
agriculture: city.agriculture,
|
||||
agricultureMax: city.agricultureMax,
|
||||
commerce: city.commerce,
|
||||
commerceMax: city.commerceMax,
|
||||
security: city.security,
|
||||
securityMax: city.securityMax,
|
||||
trust: readNumber(asRecord(city.meta).trust, 50),
|
||||
supplyState: city.supplyState,
|
||||
defence: city.defence,
|
||||
defenceMax: city.defenceMax,
|
||||
wall: city.wall,
|
||||
wallMax: city.wallMax,
|
||||
meta: asRecord(city.meta),
|
||||
});
|
||||
|
||||
const buildOfficerCounts = (world: InMemoryTurnWorld, nationId: number): Map<number, number> => {
|
||||
const result = new Map<number, number>();
|
||||
for (const general of world.listGenerals()) {
|
||||
if (general.nationId !== nationId || general.officerLevel < 2 || general.officerLevel > 4) continue;
|
||||
const officerCity = readNumber(asRecord(general.meta).officer_city, 0);
|
||||
if (officerCity > 0 && general.cityId === officerCity) {
|
||||
result.set(officerCity, (result.get(officerCity) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const chief = world.getGeneralById(nation.chiefGeneralId);
|
||||
return Boolean(chief && chief.npcState >= 2);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const createNpcTaxHandler = (options: { getWorld: () => InMemoryTurnWorld | null }): TurnCalendarHandler => {
|
||||
const clampBill = (value: number): number => Math.max(20, Math.min(200, Math.trunc(value)));
|
||||
|
||||
const resolveNpcMonarch = (nation: Nation, world: InMemoryTurnWorld) => {
|
||||
const chief = nation.chiefGeneralId
|
||||
? world.getGeneralById(nation.chiefGeneralId)
|
||||
: world
|
||||
.listGenerals()
|
||||
.find((general) => general.nationId === nation.id && general.officerLevel === 12) ?? null;
|
||||
return chief && chief.npcState >= 2 ? chief : null;
|
||||
};
|
||||
|
||||
type NpcFinanceOptions = {
|
||||
commandEnv?: TurnCommandEnv;
|
||||
scenarioConfig?: ScenarioConfig;
|
||||
unitSet?: UnitSetDefinition;
|
||||
nationTraits?: ReadonlyMap<string, NationTraitModule>;
|
||||
};
|
||||
|
||||
export const calculateNpcNationFinance = (
|
||||
world: InMemoryTurnWorld,
|
||||
nation: Nation,
|
||||
currentMonth: number,
|
||||
options: NpcFinanceOptions
|
||||
): Nation['meta'] | null => {
|
||||
if (!shouldUpdateRate(currentMonth)) {
|
||||
return null;
|
||||
}
|
||||
const chief = resolveNpcMonarch(nation, world);
|
||||
if (!chief) return null;
|
||||
const cities = world.listCities();
|
||||
const rawNationCities = cities.filter((city) => city.nationId === nation.id && city.supplyState > 0);
|
||||
const rate = resolveNpcTaxRate(rawNationCities);
|
||||
// Ref chooses the default rate during the ruler turn even when a newly
|
||||
// founded nation has not supplied its first city yet. In that state it
|
||||
// leaves the existing bill untouched because there is no income basis.
|
||||
if (rawNationCities.length === 0) {
|
||||
return { ...nation.meta, rate };
|
||||
}
|
||||
if (!options.commandEnv || !options.scenarioConfig) {
|
||||
return { ...nation.meta, rate };
|
||||
}
|
||||
const trait = options.nationTraits?.get(nation.typeCode);
|
||||
const actionContext = createIncomeActionContext(nation);
|
||||
const incomeContext: NationIncomeContext = {
|
||||
rate,
|
||||
...(trait?.onCalcNationalIncome
|
||||
? { modifyIncome: (type, amount) => trait.onCalcNationalIncome!(actionContext, type, amount) }
|
||||
: {}),
|
||||
};
|
||||
const nationCities = rawNationCities.map(toIncomeCity);
|
||||
const officerCounts = buildOfficerCounts(world, nation.id);
|
||||
const generals = world
|
||||
.listGenerals()
|
||||
// Ref's GeneralAI::$nationGenerals is built with `no != current ruler`.
|
||||
// chooseGoldBillRate therefore omits the ruler's own stipend from the
|
||||
// outcome used to choose bill, despite a dead local append that looks
|
||||
// as if it intended to include the ruler.
|
||||
.filter((general) => general.nationId === nation.id && general.id !== chief.id && general.npcState !== 5);
|
||||
const outcome = Math.max(1, getOutcome(100, generals));
|
||||
const policy = new AutorunNationPolicy({
|
||||
general: chief,
|
||||
aiOptions: null,
|
||||
nationPolicy: asRecord(nation.meta).npc_nation_policy as Record<string, unknown> | null,
|
||||
serverPolicy: asRecord(world.getState().meta).npc_nation_policy as Record<string, unknown> | null,
|
||||
nation,
|
||||
env: options.commandEnv,
|
||||
scenarioConfig: options.scenarioConfig,
|
||||
...(options.unitSet ? { unitSet: options.unitSet } : {}),
|
||||
});
|
||||
const income =
|
||||
currentMonth === 12
|
||||
? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level) +
|
||||
getWarGoldIncome(incomeContext, nationCities)
|
||||
: getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level) +
|
||||
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level);
|
||||
const currentResource = currentMonth === 12 ? nation.gold : nation.rice;
|
||||
const requiredResource = currentMonth === 12 ? policy.reqNationGold : policy.reqNationRice;
|
||||
let bill = Math.trunc((income / outcome) * 90);
|
||||
if (currentResource + income - outcome > requiredResource * 2) {
|
||||
const moreBill = ((currentResource + income - requiredResource * 2) / outcome) * 80;
|
||||
if (moreBill > bill) bill = Math.trunc((moreBill + bill) / 2);
|
||||
}
|
||||
return { ...nation.meta, rate, bill: clampBill(bill) };
|
||||
};
|
||||
|
||||
export const createNpcTaxHandler = (options: {
|
||||
getWorld: () => InMemoryTurnWorld | null;
|
||||
commandEnv?: TurnCommandEnv;
|
||||
scenarioConfig?: ScenarioConfig;
|
||||
unitSet?: UnitSetDefinition;
|
||||
nationTraits?: ReadonlyMap<string, NationTraitModule>;
|
||||
}): TurnCalendarHandler => {
|
||||
return {
|
||||
onMonthChanged: (context: TurnCalendarContext) => {
|
||||
if (!shouldUpdateRate(context.currentMonth)) {
|
||||
@@ -69,30 +201,9 @@ export const createNpcTaxHandler = (options: { getWorld: () => InMemoryTurnWorld
|
||||
if (!world) {
|
||||
return;
|
||||
}
|
||||
const worldState = world.getState();
|
||||
const meta = worldState.meta ?? {};
|
||||
const initYear = readNumber(meta.initYear, NaN);
|
||||
const initMonth = readNumber(meta.initMonth, NaN);
|
||||
const hasInit = Number.isFinite(initYear) && Number.isFinite(initMonth);
|
||||
const monthsSinceStart = hasInit
|
||||
? joinYearMonth(context.currentYear, context.currentMonth) - joinYearMonth(initYear, initMonth)
|
||||
: null;
|
||||
const cities = world.listCities();
|
||||
for (const nation of world.listNations()) {
|
||||
if (!isNpcMonarchNation(nation, world)) {
|
||||
continue;
|
||||
}
|
||||
const nationCities = cities.filter((city) => city.nationId === nation.id && city.supplyState > 0);
|
||||
let rate = resolveNpcTaxRate(nationCities);
|
||||
if (monthsSinceStart !== null && monthsSinceStart <= 4) {
|
||||
rate = Math.min(rate, 10);
|
||||
}
|
||||
world.updateNation(nation.id, {
|
||||
meta: {
|
||||
...nation.meta,
|
||||
rate,
|
||||
},
|
||||
});
|
||||
const nextMeta = calculateNpcNationFinance(world, nation, context.currentMonth, options);
|
||||
if (nextMeta) world.updateNation(nation.id, { meta: nextMeta });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -129,7 +129,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
||||
]),
|
||||
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT),
|
||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL),
|
||||
maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255),
|
||||
maxStatLevel: resolveNumber(constValues, ['maxLevel'], config.stat.max),
|
||||
maxDedicationLevel: resolveNumber(constValues, ['maxDedLevel'], 30),
|
||||
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
|
||||
techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5),
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ScenarioConfig,
|
||||
ScenarioMeta,
|
||||
Troop,
|
||||
GeneralTurnCommandKey,
|
||||
TurnCommandProfile,
|
||||
TurnCommandEnv,
|
||||
UnitSetDefinition,
|
||||
@@ -58,6 +59,7 @@ import { buildFrontStatePatches } from './frontStateHandler.js';
|
||||
import { buildActionContext } from './reservedTurnActionContext.js';
|
||||
import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
|
||||
import type { AiReservedTurnProvider } from './ai/types.js';
|
||||
import { withCanonicalArgumentAliases } from './ai/aiUtils.js';
|
||||
import { rankMetaKey } from './rankData.js';
|
||||
import {
|
||||
hasScenarioStaticEventHandler,
|
||||
@@ -66,6 +68,7 @@ import {
|
||||
} from './scenarioStaticEvents.js';
|
||||
|
||||
const DEFAULT_ACTION = '휴식';
|
||||
const AI_INTERNAL_GENERAL_ACTION_KEYS = ['che_NPC능동'] as const satisfies readonly GeneralTurnCommandKey[];
|
||||
|
||||
const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
|
||||
'che_소집해제',
|
||||
@@ -96,6 +99,12 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
|
||||
'che_첩보',
|
||||
'che_임관',
|
||||
'che_상업투자',
|
||||
// These legacy classes inherit che_상업투자::run(), including its
|
||||
// unconditional checkStatChange() tail.
|
||||
'che_농지개간',
|
||||
'che_수비강화',
|
||||
'che_성벽보수',
|
||||
'che_치안강화',
|
||||
'che_장비매매',
|
||||
'che_장수대상임관',
|
||||
'che_징병',
|
||||
@@ -682,21 +691,6 @@ class WorldStateView implements StateView {
|
||||
|
||||
const extractArgsRecord = (value: unknown): Record<string, unknown> => asRecord(value);
|
||||
|
||||
const withCanonicalArgumentAliases = (args: Record<string, unknown>): Record<string, unknown> => {
|
||||
const normalized = { ...args };
|
||||
for (const [legacyKey, canonicalKey] of [
|
||||
['destCityID', 'destCityId'],
|
||||
['destNationID', 'destNationId'],
|
||||
['destGeneralID', 'destGeneralId'],
|
||||
['destTroopID', 'destTroopId'],
|
||||
] as const) {
|
||||
if (normalized[canonicalKey] === undefined && normalized[legacyKey] !== undefined) {
|
||||
normalized[canonicalKey] = normalized[legacyKey];
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const buildConstraintContext = (
|
||||
general: TurnGeneral,
|
||||
city: City | undefined,
|
||||
@@ -737,6 +731,11 @@ export const createReservedTurnHandler = async (options: {
|
||||
commandEnv?: TurnCommandEnv;
|
||||
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
|
||||
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
|
||||
calculateNpcNationFinance?: (
|
||||
world: InMemoryTurnWorld,
|
||||
nation: Nation,
|
||||
currentMonth: number
|
||||
) => Nation['meta'] | null;
|
||||
onActionResolved?: (payload: {
|
||||
kind: 'nation' | 'general';
|
||||
generalId: number;
|
||||
@@ -774,6 +773,17 @@ export const createReservedTurnHandler = async (options: {
|
||||
};
|
||||
const generalModuleLoader = new GeneralTurnCommandLoader();
|
||||
const nationModuleLoader = new NationTurnCommandLoader();
|
||||
// NPC AI emits a few engine-internal commands that are intentionally not
|
||||
// exposed by the scenario's player command profile. Keep their definitions
|
||||
// available to AI resolution without adding them to the public profile.
|
||||
for (const key of AI_INTERNAL_GENERAL_ACTION_KEYS) {
|
||||
const module = await generalModuleLoader.load(key);
|
||||
if (!generalDefinitions.has(key)) {
|
||||
generalDefinitions.set(key, module.commandSpec.createDefinition(env));
|
||||
}
|
||||
seenActionKeys.add(key);
|
||||
applyActionContextBuilder(module);
|
||||
}
|
||||
for (const key of commandProfile.general) {
|
||||
if (seenActionKeys.has(key)) {
|
||||
continue;
|
||||
@@ -830,6 +840,9 @@ export const createReservedTurnHandler = async (options: {
|
||||
|
||||
return {
|
||||
execute(context): GeneralTurnResult {
|
||||
// Legacy reads the current game_env.develcost for every command.
|
||||
// Scenario const is only a fallback; the value changes with year.
|
||||
env.develCost = readMetaNumber(asRecord(context.world.meta), 'develcost', env.develCost);
|
||||
const worldRef = options.getWorld();
|
||||
const worldOverlay = worldRef ? createWorldOverlay(worldRef) : null;
|
||||
const worldView = worldOverlay?.view ?? worldRef;
|
||||
@@ -1034,6 +1047,12 @@ export const createReservedTurnHandler = async (options: {
|
||||
specificContext = baseContext;
|
||||
}
|
||||
const actionContext = specificContext ?? baseContext;
|
||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) {
|
||||
const tracedContext = actionContext as ActionContextBase & { destCity?: City; destGeneral?: TurnGeneral };
|
||||
process.stdout.write(
|
||||
`AI_ACTION_INPUT_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, actionArgs, destCityId: tracedContext.destCity?.id, destGeneralId: tracedContext.destGeneral?.id })}\n`
|
||||
);
|
||||
}
|
||||
const executionDefinition = definition as unknown as {
|
||||
getPreReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
||||
getPostReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
||||
@@ -1115,9 +1134,29 @@ export const createReservedTurnHandler = async (options: {
|
||||
},
|
||||
actionArgs
|
||||
);
|
||||
if (
|
||||
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id)) &&
|
||||
resolution.patches
|
||||
) {
|
||||
process.stdout.write(
|
||||
`AI_ACTION_PATCH_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, patches: resolution.patches })}\n`
|
||||
);
|
||||
}
|
||||
for (const troopId of resolution.deletedTroopIds ?? []) {
|
||||
commandDeletedTroopIds.add(troopId);
|
||||
}
|
||||
for (const plan of resolution.reservedGeneralTurnPlans ?? []) {
|
||||
for (let turnIdx = 0; turnIdx < plan.joinTurn; turnIdx += 1) {
|
||||
options.reservedTurns.setGeneralTurn(plan.generalId, turnIdx, {
|
||||
action: 'che_견문',
|
||||
args: {},
|
||||
});
|
||||
}
|
||||
options.reservedTurns.setGeneralTurn(plan.generalId, plan.joinTurn, {
|
||||
action: 'che_임관',
|
||||
args: { destNationId: plan.destNationId },
|
||||
});
|
||||
}
|
||||
|
||||
currentGeneral = resolution.general as TurnGeneral;
|
||||
currentCity = resolution.city ?? currentCity;
|
||||
@@ -1559,6 +1598,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
|
||||
let hasReservedTurn = false;
|
||||
let sharedAi: GeneralAI | undefined;
|
||||
if (!isBlocked && currentNation && currentGeneral.officerLevel >= 5) {
|
||||
let nationCommand = options.reservedTurns.getNationTurn(
|
||||
currentNation.id,
|
||||
@@ -1570,7 +1610,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
let nationAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
|
||||
if (worldView && shouldUseAi(currentGeneral, context.world)) {
|
||||
const ai = new GeneralAI({
|
||||
sharedAi = new GeneralAI({
|
||||
general: currentGeneral,
|
||||
city: currentCity,
|
||||
nation: currentNation,
|
||||
@@ -1587,13 +1627,66 @@ export const createReservedTurnHandler = async (options: {
|
||||
generalFallback,
|
||||
nationFallback,
|
||||
});
|
||||
const ai = sharedAi;
|
||||
const candidate = ai.chooseNationTurn(nationCommand);
|
||||
if (candidate) {
|
||||
if (
|
||||
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(
|
||||
String(currentGeneral.id)
|
||||
)
|
||||
) {
|
||||
process.stdout.write(
|
||||
`AI_NATION_CANDIDATE_TRACE ${JSON.stringify({ generalId: currentGeneral.id, candidate })}\n`
|
||||
);
|
||||
}
|
||||
nationCommand = { action: candidate.action, args: candidate.args };
|
||||
}
|
||||
const promotion = ai.consumePromotionPatches();
|
||||
for (const entry of promotion.generals) {
|
||||
const promotedGeneral = worldOverlay?.view.getGeneralById(entry.generalId);
|
||||
const patch = {
|
||||
officerLevel: entry.officerLevel,
|
||||
...(promotedGeneral
|
||||
? { meta: { ...promotedGeneral.meta, officer_city: entry.officerCity } }
|
||||
: {}),
|
||||
};
|
||||
patches.generals.push({ id: entry.generalId, patch });
|
||||
worldOverlay?.applyGeneralPatch(entry.generalId, patch);
|
||||
if (entry.generalId === currentGeneral.id) {
|
||||
currentGeneral = applyGeneralPatch(currentGeneral, patch);
|
||||
}
|
||||
}
|
||||
if (promotion.nationMeta && currentNation) {
|
||||
currentNation = { ...currentNation, meta: promotion.nationMeta as Nation['meta'] };
|
||||
worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta });
|
||||
}
|
||||
if (currentNation && currentGeneral.officerLevel === 12 && options.calculateNpcNationFinance) {
|
||||
const baseWorld = options.getWorld();
|
||||
const financeMeta = baseWorld
|
||||
? options.calculateNpcNationFinance(baseWorld, currentNation, context.world.currentMonth)
|
||||
: null;
|
||||
if (financeMeta) {
|
||||
currentNation = { ...currentNation, meta: financeMeta as Nation['meta'] };
|
||||
worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta });
|
||||
}
|
||||
}
|
||||
nationAiState = ai.getDebugState();
|
||||
}
|
||||
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
|
||||
if (
|
||||
worldView &&
|
||||
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))
|
||||
) {
|
||||
process.stdout.write(
|
||||
`AI_DIPLOMACY_TRACE ${JSON.stringify({
|
||||
generalId: currentGeneral.id,
|
||||
stage: 'after-nation-action',
|
||||
entries: worldView
|
||||
.listDiplomacy()
|
||||
.filter((entry) => entry.fromNationId === currentGeneral.nationId && entry.state <= 1),
|
||||
})}\n`
|
||||
);
|
||||
}
|
||||
options.onActionResolved?.({
|
||||
kind: 'nation',
|
||||
generalId: currentGeneral.id,
|
||||
@@ -1618,23 +1711,25 @@ export const createReservedTurnHandler = async (options: {
|
||||
let generalAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
|
||||
let generalAutorunMode = false;
|
||||
if (!isBlocked && worldView && shouldUseAi(currentGeneral, context.world)) {
|
||||
const ai = new GeneralAI({
|
||||
general: currentGeneral,
|
||||
city: currentCity,
|
||||
nation: currentNation,
|
||||
world: context.world,
|
||||
worldRef: worldView,
|
||||
reservedTurnProvider,
|
||||
scenarioConfig: options.scenarioConfig,
|
||||
scenarioMeta: options.scenarioMeta,
|
||||
map: options.map,
|
||||
unitSet: options.unitSet,
|
||||
commandEnv: env,
|
||||
generalDefinitions,
|
||||
nationDefinitions,
|
||||
generalFallback,
|
||||
nationFallback,
|
||||
});
|
||||
const ai =
|
||||
sharedAi ??
|
||||
new GeneralAI({
|
||||
general: currentGeneral,
|
||||
city: currentCity,
|
||||
nation: currentNation,
|
||||
world: context.world,
|
||||
worldRef: worldView,
|
||||
reservedTurnProvider,
|
||||
scenarioConfig: options.scenarioConfig,
|
||||
scenarioMeta: options.scenarioMeta,
|
||||
map: options.map,
|
||||
unitSet: options.unitSet,
|
||||
commandEnv: env,
|
||||
generalDefinitions,
|
||||
nationDefinitions,
|
||||
generalFallback,
|
||||
nationFallback,
|
||||
});
|
||||
const candidate = ai.chooseGeneralTurn(generalCommand);
|
||||
if (candidate) {
|
||||
generalAutorunMode =
|
||||
@@ -1642,6 +1737,18 @@ export const createReservedTurnHandler = async (options: {
|
||||
JSON.stringify(candidate.args ?? {}) !== JSON.stringify(generalCommand.args ?? {});
|
||||
generalCommand = { action: candidate.action, args: candidate.args };
|
||||
}
|
||||
const aiMetaPatch = ai.consumePersistentGeneralMetaPatch();
|
||||
if (Object.keys(aiMetaPatch.set).length > 0 || aiMetaPatch.unset.length > 0) {
|
||||
const nextMeta = { ...currentGeneral.meta } as Record<string, unknown>;
|
||||
for (const key of aiMetaPatch.unset) {
|
||||
delete nextMeta[key];
|
||||
}
|
||||
currentGeneral = {
|
||||
...currentGeneral,
|
||||
meta: { ...nextMeta, ...aiMetaPatch.set } as TurnGeneral['meta'],
|
||||
};
|
||||
worldOverlay?.syncGeneral(currentGeneral);
|
||||
}
|
||||
generalAiState = ai.getDebugState();
|
||||
}
|
||||
const generalResult = isBlocked
|
||||
@@ -1854,6 +1961,12 @@ export const createReservedTurnHandler = async (options: {
|
||||
},
|
||||
};
|
||||
|
||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) {
|
||||
process.stdout.write(
|
||||
`AI_GENERAL_PRE_APPLY_TRACE ${JSON.stringify({ engine: 'core', generalId: currentGeneral.id, stats: currentGeneral.stats, experience: currentGeneral.experience, dedication: currentGeneral.dedication, meta: { leadership_exp: currentGeneral.meta.leadership_exp, strength_exp: currentGeneral.meta.strength_exp, intel_exp: currentGeneral.meta.intel_exp, dex1: currentGeneral.meta.dex1, dex2: currentGeneral.meta.dex2, dex3: currentGeneral.meta.dex3, dex4: currentGeneral.meta.dex4, dex5: currentGeneral.meta.dex5 } })}\n`
|
||||
);
|
||||
}
|
||||
|
||||
const result: GeneralTurnResult = {
|
||||
general: currentGeneral,
|
||||
city: currentCity,
|
||||
|
||||
@@ -460,6 +460,20 @@ export class InMemoryReservedTurnStore {
|
||||
this.dirtyGeneralIds.add(generalId);
|
||||
}
|
||||
|
||||
setGeneralTurn(generalId: number, turnIdx: number, entry: ReservedTurnEntry): void {
|
||||
if (turnIdx < 0 || turnIdx >= this.maxGeneralTurns) {
|
||||
return;
|
||||
}
|
||||
const turns = this.getGeneralTurns(generalId).slice();
|
||||
turns[turnIdx] = {
|
||||
action: normalizeAction(entry.action),
|
||||
args: normalizeArgs(entry.args),
|
||||
};
|
||||
this.generalTurns.set(generalId, turns);
|
||||
this.pendingGeneralInitializationIds.delete(generalId);
|
||||
this.dirtyGeneralIds.add(generalId);
|
||||
}
|
||||
|
||||
ensureNationTurns(nationId: number, officerLevel: number): void {
|
||||
const key = buildNationKey(nationId, officerLevel);
|
||||
this.getNationTurns(nationId, officerLevel);
|
||||
|
||||
@@ -44,7 +44,9 @@ const safeJsonParse = <T>(raw: string | null): T | null => {
|
||||
|
||||
const resolveTermSeconds = (tickSeconds: number): number => {
|
||||
const turnMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
||||
return Math.min(120, Math.max(5, turnMinutes)) * 60;
|
||||
// Ref calcTournamentTerm() receives the turn length in minutes but returns
|
||||
// that clamped numeric value as tournament seconds.
|
||||
return Math.min(120, Math.max(5, turnMinutes));
|
||||
};
|
||||
|
||||
const readPattern = (world: InMemoryTurnWorld, config: Record<string, unknown>): number[] => {
|
||||
@@ -57,17 +59,6 @@ const readPattern = (world: InMemoryTurnWorld, config: Record<string, unknown>):
|
||||
);
|
||||
};
|
||||
|
||||
const shuffledDefaultPattern = (
|
||||
hiddenSeed: string | number,
|
||||
previousYear: number,
|
||||
previousMonth: number
|
||||
): number[] =>
|
||||
new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
simpleSerialize(hiddenSeed, 'monthly', previousYear, previousMonth, 'tournamentPattern')
|
||||
)
|
||||
).shuffle([0, 0, 1, 2, 3]);
|
||||
|
||||
export const createTournamentAutoStartHandler = (options: {
|
||||
profileName: string;
|
||||
getWorld: () => InMemoryTurnWorld | null;
|
||||
@@ -114,10 +105,9 @@ export const createTournamentAutoStartHandler = (options: {
|
||||
}
|
||||
|
||||
const pattern = readPattern(world, config);
|
||||
const resolvedPattern =
|
||||
pattern.length > 0
|
||||
? pattern
|
||||
: shuffledDefaultPattern(hiddenSeed, context.previousYear, context.previousMonth);
|
||||
// The deterministic Ref comparison branch replaces PHP's global
|
||||
// shuffle() with this same already-advanced monthly RNG.
|
||||
const resolvedPattern = pattern.length > 0 ? pattern : rng.shuffle([0, 0, 1, 2, 3]);
|
||||
const type = resolvedPattern.pop() ?? 0;
|
||||
world.updateWorldMeta({ tournamentPattern: resolvedPattern });
|
||||
const now = options.now?.() ?? new Date();
|
||||
@@ -133,8 +123,14 @@ export const createTournamentAutoStartHandler = (options: {
|
||||
openYear: context.currentYear,
|
||||
openMonth: context.currentMonth,
|
||||
termSeconds,
|
||||
nextAt: new Date(now.getTime() + termSeconds * 1_000).toISOString(),
|
||||
bettingId: undefined,
|
||||
// Ref startTournament() passes calcTournamentTerm()'s seconds
|
||||
// value to DateInterval's minute field. Preserve that historical
|
||||
// initial enrollment delay; later tournament phases use seconds.
|
||||
nextAt: new Date(now.getTime() + termSeconds * 60_000).toISOString(),
|
||||
bettingId:
|
||||
typeof previousState?.bettingId === 'number' && Number.isFinite(previousState.bettingId)
|
||||
? previousState.bettingId + 1
|
||||
: 1,
|
||||
bettingCloseAt: undefined,
|
||||
winnerId: undefined,
|
||||
bettingSettled: false,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { createGatewayProfileGate } from './gatewayProfileGate.js';
|
||||
import { composeCalendarHandlers } from './calendarHandlers.js';
|
||||
import { createIncomeHandler } from './incomeHandler.js';
|
||||
import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js';
|
||||
import { calculateNpcNationFinance } from './npcTaxHandler.js';
|
||||
import { createMonthlyBoundaryPreHandler } from './monthlyBoundaryPreHandler.js';
|
||||
import { createMonthlyWanderHandler } from './monthlyWanderHandler.js';
|
||||
import {
|
||||
@@ -471,6 +472,10 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
onTournamentRollConsumed: (consumed) => {
|
||||
monthlyTournamentRollConsumed = consumed;
|
||||
},
|
||||
// Deterministic/manual runtimes must schedule the tournament from the
|
||||
// same clock that advances the game world. Production still falls
|
||||
// back to the system clock.
|
||||
now: () => new Date(options.clock?.nowMs() ?? Date.now()),
|
||||
});
|
||||
const yearbookHandler = createYearbookHandler({
|
||||
profileName: options.profileName ?? options.profile,
|
||||
@@ -508,6 +513,13 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
getWorld: () => worldRef,
|
||||
commandProfile,
|
||||
commandEnv: monthlyCommandEnv,
|
||||
calculateNpcNationFinance: (financeWorld, nation, currentMonth) =>
|
||||
calculateNpcNationFinance(financeWorld, nation, currentMonth, {
|
||||
commandEnv: monthlyCommandEnv,
|
||||
scenarioConfig: snapshot.scenarioConfig,
|
||||
unitSet: snapshot.unitSet,
|
||||
nationTraits: nationTraitMap,
|
||||
}),
|
||||
getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys,
|
||||
onActionResolved: options.onActionResolved,
|
||||
})),
|
||||
|
||||
@@ -389,18 +389,28 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
inheritanceByUser.set(row.userId, bucket);
|
||||
}
|
||||
const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row]));
|
||||
const generals = generalRows.map((row) =>
|
||||
mapGeneralRow(
|
||||
row,
|
||||
ranksByGeneral.get(row.id) ?? [],
|
||||
row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [],
|
||||
accessByGeneral.get(row.id)
|
||||
// MariaDB legacy scans these tables in their primary-key order. Prisma
|
||||
// findMany() does not promise an order, and Map insertion order can
|
||||
// otherwise leak into monthly RNG and AI candidate traversal.
|
||||
const generals = generalRows
|
||||
.map((row) =>
|
||||
mapGeneralRow(
|
||||
row,
|
||||
ranksByGeneral.get(row.id) ?? [],
|
||||
row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [],
|
||||
accessByGeneral.get(row.id)
|
||||
)
|
||||
)
|
||||
);
|
||||
const cities = cityRows.map(mapCityRow);
|
||||
const nations = nationRows.map(mapNationRow);
|
||||
const diplomacy = diplomacyRows.map(mapDiplomacyRow);
|
||||
const troops = troopRows.map(mapTroopRow);
|
||||
.sort((left, right) => left.id - right.id);
|
||||
const cities = cityRows.map(mapCityRow).sort((left, right) => left.id - right.id);
|
||||
const nations = nationRows.map(mapNationRow).sort((left, right) => left.id - right.id);
|
||||
const diplomacy = diplomacyRows
|
||||
.map(mapDiplomacyRow)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.fromNationId - right.fromNationId || left.toNationId - right.toNationId
|
||||
);
|
||||
const troops = troopRows.map(mapTroopRow).sort((left, right) => left.id - right.id);
|
||||
|
||||
const worldConfig = asRecord(worldState.config);
|
||||
const scenarioConfig = mapScenarioConfig(worldState.config);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { City, General, Nation } from '@sammo-ts/logic';
|
||||
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
|
||||
|
||||
import type { GeneralAI } from '../src/turn/ai/generalAi.js';
|
||||
import { resolveLegacyAiStats } from '../src/turn/ai/generalAi/core.js';
|
||||
import { withCanonicalArgumentAliases } from '../src/turn/ai/aiUtils.js';
|
||||
import { do일반내정, do전쟁내정 } from '../src/turn/ai/generalAi/general/devActions.js';
|
||||
import { do금쌀구매 } from '../src/turn/ai/generalAi/general/economyActions.js';
|
||||
import { do국가선택, do중립 } from '../src/turn/ai/generalAi/general/politicsActions.js';
|
||||
import { do거병, do건국, do국가선택, do중립 } from '../src/turn/ai/generalAi/general/politicsActions.js';
|
||||
import { do징병 } from '../src/turn/ai/generalAi/general/recruitActions.js';
|
||||
import { do전투준비, do출병 } from '../src/turn/ai/generalAi/general/warActions.js';
|
||||
import { do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js';
|
||||
import { doNPC몰수, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.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후방발령 } from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js';
|
||||
|
||||
type Candidate = {
|
||||
action: string;
|
||||
@@ -74,7 +78,24 @@ const makeRng = (bools: boolean[] = [], choices: unknown[] = []): ScriptedRng =>
|
||||
};
|
||||
};
|
||||
|
||||
const baseGeneral = (): General => ({
|
||||
const singleActionModuleStack = (
|
||||
module: NonNullable<GeneralAI['commandEnv']['generalActionModules']>[number]
|
||||
): NonNullable<GeneralAI['commandEnv']['generalActionModules']> => {
|
||||
const noOp = {};
|
||||
return createRefOrderedActionStack({
|
||||
nation: noOp,
|
||||
officer: noOp,
|
||||
domestic: noOp,
|
||||
war: noOp,
|
||||
personality: module,
|
||||
crewType: null,
|
||||
inheritance: noOp,
|
||||
scenario: null,
|
||||
items: [],
|
||||
});
|
||||
};
|
||||
|
||||
const baseGeneral = (): General & { turnTime: Date } => ({
|
||||
id: 1,
|
||||
name: '가상장수',
|
||||
nationId: 1,
|
||||
@@ -99,6 +120,7 @@ const baseGeneral = (): General => ({
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 2,
|
||||
turnTime: new Date('0190-01-01T00:00:00Z'),
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 100, fullLeadership: 70 },
|
||||
});
|
||||
@@ -155,6 +177,7 @@ const makeAi = (
|
||||
nations?: Nation[];
|
||||
generals?: General[];
|
||||
disabledPolicyActions?: string[];
|
||||
generalActionModules?: NonNullable<GeneralAI['commandEnv']['generalActionModules']>;
|
||||
} = {}
|
||||
): GeneralAI => {
|
||||
const general = {
|
||||
@@ -278,6 +301,7 @@ const makeAi = (
|
||||
maxTechLevel: 10,
|
||||
techLevelIncYear: 5,
|
||||
initialAllowedTechLevel: 1,
|
||||
generalActionModules: overrides.generalActionModules ?? [],
|
||||
},
|
||||
aiConst: {
|
||||
baseGold: 1000,
|
||||
@@ -352,6 +376,109 @@ const makeAi = (
|
||||
* selection and RNG-sensitive gates, not TypeScript implementation details.
|
||||
*/
|
||||
describe('legacy NPC AI final-decision parity', () => {
|
||||
it('normalizes legacy uppercase destination IDs before AI constraint checks', () => {
|
||||
expect(
|
||||
withCanonicalArgumentAliases({
|
||||
destGeneralID: 2,
|
||||
destCityID: 3,
|
||||
destNationID: 4,
|
||||
destTroopID: 5,
|
||||
})
|
||||
).toMatchObject({
|
||||
destGeneralId: 2,
|
||||
destCityId: 3,
|
||||
destNationId: 4,
|
||||
destTroopId: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the legacy nation-level leadership bonus for officers', () => {
|
||||
const ruler = { ...baseGeneral(), officerLevel: 12, injury: 0 };
|
||||
const nation = { ...baseNation(), level: 1 };
|
||||
|
||||
expect(resolveLegacyAiStats(ruler, nation, 255)).toMatchObject({
|
||||
fullLeadership: 72,
|
||||
effectiveLeadership: 72,
|
||||
});
|
||||
expect(resolveLegacyAiStats({ ...ruler, officerLevel: 5 }, nation, 255)).toMatchObject({
|
||||
fullLeadership: 71,
|
||||
effectiveLeadership: 71,
|
||||
});
|
||||
expect(resolveLegacyAiStats({ ...ruler, officerLevel: 1 }, nation, 255)).toMatchObject({
|
||||
fullLeadership: 70,
|
||||
effectiveLeadership: 70,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
['Core scenario name', '강유'],
|
||||
['Ref stored name', 'ⓝ강유'],
|
||||
])('uses the full ruler name and Ref nation-type/color RNG order: %s', (_label, name) => {
|
||||
const rng = makeRng([], ['che_음양가', 19]);
|
||||
const ai = makeAi({ general: { name }, rng });
|
||||
ai.aiConst.availableNationTypes = ['che_도적', 'che_음양가'];
|
||||
|
||||
expect(do건국(ai)).toMatchObject({
|
||||
action: 'che_건국',
|
||||
args: { nationName: '㉿강유', nationType: 'che_음양가', colorType: 19 },
|
||||
});
|
||||
});
|
||||
|
||||
it('counts a wandering-nation ruler on a neutral city as occupying the uprising radius', () => {
|
||||
const rng = makeRng([false, true]);
|
||||
const currentCity = { ...baseCity(), id: 1, nationId: 0, level: 1 };
|
||||
const rulerCity = { ...baseCity(), id: 2, nationId: 0, level: 5 };
|
||||
const ruler = { ...baseGeneral(), id: 2, nationId: 9, cityId: 2, officerLevel: 12 };
|
||||
const ai = {
|
||||
general: { ...baseGeneral(), nationId: 0, cityId: 1, npcState: 2, meta: {} },
|
||||
city: currentCity,
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
defaults: {},
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '현재',
|
||||
level: 1,
|
||||
region: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
connections: [2],
|
||||
max: {},
|
||||
initial: {},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '군주',
|
||||
level: 5,
|
||||
region: 1,
|
||||
position: { x: 1, y: 0 },
|
||||
connections: [1],
|
||||
max: {},
|
||||
initial: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
worldRef: {
|
||||
listCities: () => [currentCity, rulerCity],
|
||||
listGenerals: () => [ruler],
|
||||
getCityById: (id: number) => (id === 1 ? currentCity : id === 2 ? rulerCity : null),
|
||||
},
|
||||
generalPolicy: { can: (name: string) => name === '건국' },
|
||||
rng,
|
||||
aiConst: { defaultStatNpcMax: 100, chiefStatMin: 70 },
|
||||
world: { currentYear: 179, meta: { initYear: 179 } },
|
||||
startYear: 179,
|
||||
buildGeneralCandidate: (action: string, args: Record<string, unknown>, reason: string) => ({
|
||||
action,
|
||||
args,
|
||||
reason,
|
||||
}),
|
||||
} as unknown as GeneralAI;
|
||||
|
||||
expect(do거병(ai)).toBeNull();
|
||||
expect(rng.bools).toEqual([true]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 0],
|
||||
[0, 2],
|
||||
@@ -386,6 +513,30 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('applies legacy personality cost modifiers before halving recruit crew', () => {
|
||||
const ai = makeAi({
|
||||
dipState: 2,
|
||||
general: {
|
||||
gold: 1_000,
|
||||
rice: 10_000,
|
||||
meta: { killturn: 100, fullLeadership: 70, rank_killcrew: 0, rank_deathcrew: 1 },
|
||||
},
|
||||
generalActionModules: singleActionModuleStack(
|
||||
{
|
||||
eventHandlers: {},
|
||||
onCalcDomestic: (_context, turnType, varType, value) =>
|
||||
turnType === '징병' && varType === 'cost' ? value * 1.2 : value,
|
||||
}
|
||||
),
|
||||
rng: makeRng([], [0, 0]),
|
||||
});
|
||||
|
||||
expect(do징병(ai)).toMatchObject({
|
||||
action: 'che_징병',
|
||||
args: { crewType: 1, amount: 3_500 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 0],
|
||||
[0, 2000],
|
||||
@@ -433,6 +584,68 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(agriculture[1]).toBe(420);
|
||||
});
|
||||
|
||||
it('coerces fractional nation tech to an integer before the legacy modulo', () => {
|
||||
const rng = makeRng([false], [0]);
|
||||
const ai = makeAi({
|
||||
dipState: 1,
|
||||
genType: 6,
|
||||
general: {
|
||||
stats: { leadership: 73, strength: 44, intelligence: 98 },
|
||||
meta: {
|
||||
killturn: 100,
|
||||
effectiveLeadership: 73,
|
||||
effectiveStrength: 69,
|
||||
effectiveIntelligence: 109,
|
||||
},
|
||||
},
|
||||
city: {
|
||||
population: 75_098,
|
||||
populationMax: 108_500,
|
||||
},
|
||||
nation: { meta: { tech: 564.87353515625 } },
|
||||
year: 185,
|
||||
rng,
|
||||
});
|
||||
|
||||
do전쟁내정(ai);
|
||||
const weights = rng.weightedPairs.at(-1)!;
|
||||
const technology = weights.find(([candidate]) => (candidate as Candidate).action === 'che_기술연구')!;
|
||||
expect(technology[1]).toBeCloseTo(109 / (565 / 3000), 12);
|
||||
});
|
||||
|
||||
it('uses injury-adjusted legacy stats when weighting domestic choices', () => {
|
||||
const rng = makeRng([], [2]);
|
||||
const ai = makeAi({
|
||||
genType: 5,
|
||||
general: {
|
||||
stats: { leadership: 68, strength: 71, intelligence: 40 },
|
||||
meta: {
|
||||
killturn: 100,
|
||||
effectiveLeadership: 68,
|
||||
effectiveStrength: 81,
|
||||
effectiveIntelligence: 58,
|
||||
},
|
||||
},
|
||||
city: {
|
||||
population: 100_000,
|
||||
populationMax: 293_700,
|
||||
defence: 2000,
|
||||
defenceMax: 5900,
|
||||
wall: 2000,
|
||||
wallMax: 6300,
|
||||
security: 1000,
|
||||
securityMax: 4000,
|
||||
meta: { trust: 50, trade: 100 },
|
||||
},
|
||||
rng,
|
||||
});
|
||||
|
||||
expect(do일반내정(ai)?.action).toBe('che_수비강화');
|
||||
const weights = rng.weightedPairs.at(-1)!;
|
||||
const defence = weights.find(([candidate]) => (candidate as Candidate).action === 'che_수비강화')!;
|
||||
expect(defence[1]).toBeCloseTo(238.95);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1500, 400, null],
|
||||
[10_000, 1000, 'che_군량매매'],
|
||||
@@ -443,6 +656,48 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(do금쌀구매(ai)?.action ?? null).toBe(expected);
|
||||
});
|
||||
|
||||
it('uses the global command cap rather than the nation-specific reward cap for trade', () => {
|
||||
const ai = makeAi({ general: { gold: 355, rice: 3177 } });
|
||||
ai.maxResourceActionAmount = 1200;
|
||||
|
||||
// 명령 parser가 이 원시 수량을 Ref처럼 백 단위 1400으로 반올림한다.
|
||||
expect(do금쌀구매(ai)?.args).toEqual({ buyRice: false, amount: 1411 });
|
||||
});
|
||||
|
||||
it('uses full leadership and the unit rice price for the trade reserve estimate', () => {
|
||||
const ai = makeAi({
|
||||
general: {
|
||||
gold: 4000,
|
||||
rice: 2000,
|
||||
stats: { leadership: 10, strength: 70, intelligence: 70 },
|
||||
meta: { killturn: 100, fullLeadership: 70 },
|
||||
},
|
||||
});
|
||||
const crewType = ai.unitSet?.crewTypes?.[0];
|
||||
if (!crewType) throw new Error('missing test crew type');
|
||||
crewType.cost = 9;
|
||||
crewType.rice = 20;
|
||||
|
||||
expect(do금쌀구매(ai)?.action).toBe('che_군량매매');
|
||||
});
|
||||
|
||||
it('applies domestic action cost modifiers to the trade recruit reserve estimate', () => {
|
||||
const ai = makeAi({
|
||||
general: { gold: 900, rice: 100 },
|
||||
generalActionModules: singleActionModuleStack({
|
||||
eventHandlers: {},
|
||||
onCalcDomestic: (_context, turnType, varType, value) =>
|
||||
turnType === '징병' && varType === 'cost' ? value * 2 : value,
|
||||
}),
|
||||
});
|
||||
const crewType = ai.unitSet?.crewTypes?.[0];
|
||||
if (!crewType) throw new Error('missing test crew type');
|
||||
crewType.cost = 9;
|
||||
crewType.rice = 9;
|
||||
|
||||
expect(do금쌀구매(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it('randomly chooses between supply and search when national resources are sufficient', () => {
|
||||
const ai = makeAi({ rng: makeRng([], [1]) });
|
||||
expect(do중립(ai)?.action).toBe('che_인재탐색');
|
||||
@@ -481,6 +736,16 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(do국가선택(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not count the Core-only neutral nation as a legacy nation', () => {
|
||||
const ai = makeAi({
|
||||
general: { nationId: 0 },
|
||||
year: 181,
|
||||
nations: [{ ...baseNation(), id: 0, name: '재야' }],
|
||||
rng: makeRng([true]),
|
||||
});
|
||||
expect(do국가선택(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, 4, 100, 100, 2000, null],
|
||||
[true, 3, 100, 100, 2000, null],
|
||||
@@ -519,6 +784,32 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(do후방워프(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it('uses full leadership for the legacy rear-warp recruitment floor', () => {
|
||||
const ai = makeAi({
|
||||
dipState: 4,
|
||||
general: {
|
||||
crew: 0,
|
||||
stats: { leadership: 10, strength: 70, intelligence: 70 },
|
||||
meta: { killturn: 100, fullLeadership: 100 },
|
||||
},
|
||||
city: { population: 10_000 },
|
||||
});
|
||||
ai.categorizeNationCities = () => {
|
||||
const candidate = {
|
||||
...baseCity(),
|
||||
id: 2,
|
||||
population: 35_000,
|
||||
populationMax: 50_000,
|
||||
dev: 0.7,
|
||||
important: 0,
|
||||
};
|
||||
ai.backupCities = { 2: candidate };
|
||||
ai.supplyCities = { 2: candidate };
|
||||
};
|
||||
|
||||
expect(do후방워프(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it('categorizes generals before weighting a front-line warp destination', () => {
|
||||
const ai = makeAi({
|
||||
dipState: 4,
|
||||
@@ -537,6 +828,32 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(categorizedGenerals).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the legacy empty city-general counts when weighting a domestic warp', () => {
|
||||
const ai = makeAi({ rng: makeRng([false, true]) });
|
||||
ai.categorizeNationCities = () => {
|
||||
ai.supplyCities = {
|
||||
1: { ...baseCity(), dev: 1, important: 0, generals: {} },
|
||||
2: {
|
||||
...baseCity(),
|
||||
id: 2,
|
||||
agriculture: 1000,
|
||||
commerce: 1000,
|
||||
security: 1000,
|
||||
defence: 1000,
|
||||
wall: 1000,
|
||||
dev: 0.1,
|
||||
important: 0,
|
||||
generals: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
ai.categorizeNationGeneral = () => {
|
||||
throw new Error('Ref does not categorize generals in do내정워프');
|
||||
};
|
||||
|
||||
expect(do내정워프(ai)?.action).toBe('che_NPC능동');
|
||||
});
|
||||
|
||||
it('awards a resource-poor civil user general like the legacy nation AI', () => {
|
||||
const ai = makeAi();
|
||||
const civilGeneral = {
|
||||
@@ -553,6 +870,116 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
expect(do유저장포상(ai)?.action).toBe('che_포상');
|
||||
});
|
||||
|
||||
it('consumes the legacy reward draw before a selected command fails constraints', () => {
|
||||
const rng = makeRng();
|
||||
const ai = makeAi({ rng, blockedActions: ['che_포상'] });
|
||||
const civilGeneral = {
|
||||
...baseGeneral(),
|
||||
id: 2,
|
||||
npcState: 0,
|
||||
gold: 0,
|
||||
rice: 20_000,
|
||||
meta: { killturn: 100, fullLeadership: 70 },
|
||||
turnTime: new Date('0190-01-01T00:00:00Z'),
|
||||
};
|
||||
ai.userGenerals = { 2: civilGeneral };
|
||||
ai.userWarGenerals = {};
|
||||
|
||||
expect(do유저장포상(ai)).toBeNull();
|
||||
expect(rng.weightedPairs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps Ref multiplication order at an exact NPC reward resource boundary', () => {
|
||||
const rng = makeRng();
|
||||
const ai = makeAi({
|
||||
year: 185,
|
||||
startYear: 180,
|
||||
nation: { rice: 12_088, meta: { tech: 1_011.416320800781 } },
|
||||
rng,
|
||||
});
|
||||
ai.nationPolicy.reqNationRice = 10_000;
|
||||
ai.nationPolicy.reqNpcWarRice = 4_000;
|
||||
ai.maxResourceActionAmount = 2_400;
|
||||
const candidate = (id: number, leadership: number, rice: number) => ({
|
||||
...baseGeneral(),
|
||||
id,
|
||||
stats: { ...baseGeneral().stats, leadership },
|
||||
rice,
|
||||
crewTypeId: 1,
|
||||
meta: { killturn: 100, fullLeadership: leadership },
|
||||
});
|
||||
ai.npcWarGenerals = {
|
||||
180: candidate(180, 88, 3_005),
|
||||
743: candidate(743, 80, 3_036),
|
||||
};
|
||||
ai.npcCivilGenerals = {};
|
||||
|
||||
expect(doNPC포상(ai)).toMatchObject({
|
||||
action: 'che_포상',
|
||||
args: { destGeneralId: 180, isGold: false },
|
||||
});
|
||||
expect(rng.weightedPairs[0]?.[0]?.[0]).toMatchObject({ destGeneralId: 180 });
|
||||
expect(rng.weightedPairs[0]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes no-population recruitment specialists before NPC rear assignment draws RNG', () => {
|
||||
const rng = makeRng([], [0, 0]);
|
||||
const specialist = {
|
||||
...baseGeneral(),
|
||||
id: 2,
|
||||
cityId: 2,
|
||||
crew: 0,
|
||||
role: { ...baseGeneral().role, specialWar: 'che_징병' },
|
||||
turnTime: new Date('0190-01-01T00:00:00Z'),
|
||||
};
|
||||
const ai = makeAi({
|
||||
dipState: 4,
|
||||
rng,
|
||||
generals: [baseGeneral(), specialist],
|
||||
generalActionModules: singleActionModuleStack(
|
||||
{
|
||||
eventHandlers: {},
|
||||
onCalcDomestic: (context, turnType, varType, value) =>
|
||||
context.general.id === 2 && turnType === '징집인구' && varType === 'score' ? 0 : value,
|
||||
}
|
||||
),
|
||||
});
|
||||
ai.frontCities = { 1: { ...baseCity(), frontState: 3, dev: 1, important: 1 } };
|
||||
ai.supplyCities = {
|
||||
2: {
|
||||
...baseCity(),
|
||||
id: 2,
|
||||
population: 40_000,
|
||||
populationMax: 100_000,
|
||||
dev: 1,
|
||||
important: 1,
|
||||
},
|
||||
3: { ...baseCity(), id: 3, population: 100_000, dev: 1, important: 1 },
|
||||
};
|
||||
ai.backupCities = { 3: ai.supplyCities[3]! };
|
||||
ai.npcWarGenerals = { 2: specialist };
|
||||
|
||||
expect(doNPC후방발령(ai)).toBeNull();
|
||||
expect(rng.choices).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('draws the NPC front-assignment general before the weighted destination city', () => {
|
||||
const rng = makeRng([], [1, 20]);
|
||||
const first = { ...baseGeneral(), id: 2, crew: 3000, train: 100, atmos: 100 };
|
||||
const second = { ...baseGeneral(), id: 3, crew: 3000, train: 100, atmos: 100 };
|
||||
const ai = makeAi({ dipState: 4, rng });
|
||||
ai.npcWarGenerals = { 2: first, 3: second };
|
||||
ai.nationCities = { 1: { ...baseCity(), dev: 1, important: 1 } };
|
||||
ai.frontCities = {
|
||||
20: { ...baseCity(), id: 20, frontState: 2, dev: 1, important: 1 },
|
||||
};
|
||||
|
||||
expect(doNPC전방발령(ai)).toMatchObject({
|
||||
action: 'che_발령',
|
||||
args: { destGeneralId: 3, destCityId: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
it('seizes a small war-NPC surplus while the treasury is below 1.5x reserve', () => {
|
||||
const ai = makeAi({ nation: { gold: 12_000, rice: 100_000 } });
|
||||
const warGeneral = {
|
||||
|
||||
@@ -152,6 +152,49 @@ const makeState = (): TurnWorldState => ({
|
||||
});
|
||||
|
||||
describe('legacy general-turn execution contract', () => {
|
||||
it('quantizes integer general columns at each in-memory DB mutation boundary', async () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(makeGeneral()),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
});
|
||||
|
||||
harness.world.updateGeneral(1, {
|
||||
experience: 10.5,
|
||||
dedication: 20.49,
|
||||
gold: 1_000.5,
|
||||
rice: 1_000.49,
|
||||
meta: { killturn: 24, dex4: 100.5, intel_exp: 29.5 },
|
||||
});
|
||||
|
||||
expect(harness.world.getGeneralById(1)).toMatchObject({
|
||||
experience: 11,
|
||||
dedication: 20,
|
||||
gold: 1_001,
|
||||
rice: 1_000,
|
||||
meta: { killturn: 24, dex4: 101, intel_exp: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it('applies inherited domestic stat progression after farming', async () => {
|
||||
const general = makeGeneral({ meta: { killturn: 24, intel_exp: 29 } });
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(general),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
});
|
||||
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_농지개간', args: {} };
|
||||
|
||||
await harness.runOneTick();
|
||||
|
||||
expect(harness.world.getGeneralById(1)).toMatchObject({
|
||||
stats: { leadership: 80, strength: 70, intelligence: 61 },
|
||||
meta: { intel_exp: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('runs injury recovery and troop rice consumption before the reserved command', async () => {
|
||||
const general = makeGeneral({ injury: 25, crew: 200, rice: 1 });
|
||||
const harness = await createTurnTestHarness({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LogCategory, LogFormat, LogScope, type City, type MapDefinition, type N
|
||||
|
||||
import { createIncomeHandler } from '../src/turn/incomeHandler.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { calculateNpcNationFinance } from '../src/turn/npcTaxHandler.js';
|
||||
import {
|
||||
createNewYearHandler,
|
||||
createNoticeToHistoryLogHandler,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
createResetOfficerLockHandler,
|
||||
} from '../src/turn/monthlyCoreEventAction.js';
|
||||
import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const map: MapDefinition = {
|
||||
@@ -318,4 +320,148 @@ describe('core monthly event actions at the real month boundary', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps fractional income for the legacy salary payout ratio', async () => {
|
||||
const nation = buildNation();
|
||||
nation.gold = 0;
|
||||
nation.meta = { ...nation.meta, rate: 15, rate_tmp: 15, bill: 100 };
|
||||
const world = buildWorld(
|
||||
[
|
||||
{
|
||||
id: 4,
|
||||
targetCode: 'month',
|
||||
priority: 1,
|
||||
condition: true,
|
||||
action: [['ProcessIncome', 'gold']],
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
(getWorld) => {
|
||||
const incomeHandler = createIncomeHandler({
|
||||
getWorld,
|
||||
scenarioConfig: {
|
||||
stat: {
|
||||
total: 300,
|
||||
min: 10,
|
||||
max: 100,
|
||||
npcTotal: 150,
|
||||
npcMax: 50,
|
||||
npcMin: 10,
|
||||
chiefMin: 70,
|
||||
},
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: { baseGold: 0, baseRice: 0 },
|
||||
environment: { mapName: map.id, unitSet: 'default' },
|
||||
},
|
||||
nationTraits: new Map(),
|
||||
});
|
||||
return new Map([['ProcessIncome', createProcessIncomeActionHandler(incomeHandler)]]);
|
||||
},
|
||||
{
|
||||
currentMonth: 12,
|
||||
generals: [buildGeneral(1, 1, 3)],
|
||||
nations: [nation],
|
||||
cities: [buildCity()],
|
||||
}
|
||||
);
|
||||
|
||||
await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z'));
|
||||
|
||||
expect(world.getNationById(1)?.meta.prev_income_gold).toBe(157.5);
|
||||
});
|
||||
|
||||
it('uses the Ref default nation resource floors when scenario const omits them', async () => {
|
||||
const nation = buildNation();
|
||||
nation.rice = 0;
|
||||
const general = buildGeneral(1, 1, 3);
|
||||
const world = buildWorld(
|
||||
[
|
||||
{
|
||||
id: 5,
|
||||
targetCode: 'month',
|
||||
priority: 1,
|
||||
condition: true,
|
||||
action: [['ProcessIncome', 'rice']],
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
(getWorld) => {
|
||||
const incomeHandler = createIncomeHandler({
|
||||
getWorld,
|
||||
scenarioConfig: {
|
||||
stat: {
|
||||
total: 300,
|
||||
min: 10,
|
||||
max: 100,
|
||||
npcTotal: 150,
|
||||
npcMax: 50,
|
||||
npcMin: 10,
|
||||
chiefMin: 70,
|
||||
},
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: map.id, unitSet: 'default' },
|
||||
},
|
||||
nationTraits: new Map(),
|
||||
});
|
||||
return new Map([['ProcessIncome', createProcessIncomeActionHandler(incomeHandler)]]);
|
||||
},
|
||||
{
|
||||
currentMonth: 6,
|
||||
generals: [general],
|
||||
nations: [nation],
|
||||
cities: [buildCity()],
|
||||
}
|
||||
);
|
||||
|
||||
await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z'));
|
||||
|
||||
expect(world.getNationById(1)?.rice).toBe(2_000);
|
||||
expect(world.getGeneralById(1)?.rice).toBe(general.rice);
|
||||
});
|
||||
|
||||
it('excludes the NPC ruler stipend when choosing the nation bill like Ref', () => {
|
||||
const config: TurnWorldSnapshot['scenarioConfig'] = {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: { baseGold: 0, baseRice: 0 },
|
||||
environment: { mapName: map.id, unitSet: 'default' },
|
||||
};
|
||||
const buildFinanceWorld = (chiefDedication: number) => {
|
||||
const chief = buildGeneral(1, 1, 3);
|
||||
chief.npcState = 2;
|
||||
chief.officerLevel = 12;
|
||||
chief.dedication = chiefDedication;
|
||||
const subordinate = buildGeneral(2, 1, 3);
|
||||
subordinate.npcState = 2;
|
||||
return buildWorld([], () => new Map(), {
|
||||
currentMonth: 12,
|
||||
generals: [chief, subordinate],
|
||||
nations: [buildNation()],
|
||||
cities: [buildCity()],
|
||||
});
|
||||
};
|
||||
const options = { scenarioConfig: config, commandEnv: buildCommandEnv(config) };
|
||||
const lowChiefWorld = buildFinanceWorld(1);
|
||||
const highChiefWorld = buildFinanceWorld(100_000);
|
||||
|
||||
const lowChiefBill = calculateNpcNationFinance(
|
||||
lowChiefWorld,
|
||||
lowChiefWorld.getNationById(1)!,
|
||||
12,
|
||||
options
|
||||
)?.bill;
|
||||
const highChiefBill = calculateNpcNationFinance(
|
||||
highChiefWorld,
|
||||
highChiefWorld.getNationById(1)!,
|
||||
12,
|
||||
options
|
||||
)?.bill;
|
||||
|
||||
expect(lowChiefBill).toBeTypeOf('number');
|
||||
expect(highChiefBill).toBe(lowChiefBill);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,7 +140,9 @@ describe('monthly event pipeline', () => {
|
||||
[
|
||||
'Trace',
|
||||
(args, environment) => {
|
||||
trace.push(`${String(args[0])}:${environment.year}-${environment.month}`);
|
||||
trace.push(
|
||||
`${String(args[0])}:${environment.year}-${environment.month}:${environment.turnTime.toISOString()}`
|
||||
);
|
||||
},
|
||||
],
|
||||
]);
|
||||
@@ -176,7 +178,11 @@ describe('monthly event pipeline', () => {
|
||||
|
||||
await world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
|
||||
|
||||
expect(trace).toEqual(['pre:189-12', 'month-high:190-1', 'month-low:190-1']);
|
||||
expect(trace).toEqual([
|
||||
'pre:189-12:0189-12-31T23:50:00.000Z',
|
||||
'month-high:190-1:0189-12-31T23:50:00.000Z',
|
||||
'month-low:190-1:0189-12-31T23:50:00.000Z',
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports logic conditions and persists DeleteEvent through dirty state', async () => {
|
||||
@@ -236,7 +242,7 @@ describe('monthly event pipeline', () => {
|
||||
},
|
||||
],
|
||||
actions,
|
||||
[buildCity(1, 1), buildCity(2, 4), buildCity(3, 5), buildCity(4, 6), buildCity(5, 7), buildCity(6, 8)]
|
||||
[buildCity(6, 8), buildCity(1, 1), buildCity(2, 4), buildCity(3, 5), buildCity(4, 6), buildCity(5, 7)]
|
||||
);
|
||||
actions.set(
|
||||
'RandomizeCityTradeRate',
|
||||
@@ -254,12 +260,12 @@ describe('monthly event pipeline', () => {
|
||||
marker: city.meta.marker,
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: 6, trade: 102, marker: 6 },
|
||||
{ id: 1, trade: null, marker: 1 },
|
||||
{ id: 2, trade: null, marker: 2 },
|
||||
{ id: 3, trade: 101, marker: 3 },
|
||||
{ id: 4, trade: 100, marker: 4 },
|
||||
{ id: 5, trade: 105, marker: 5 },
|
||||
{ id: 6, trade: 102, marker: 6 },
|
||||
]);
|
||||
expect(world.peekDirtyState().cities.map((city) => city.id)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
@@ -386,7 +392,7 @@ describe('monthly event pipeline', () => {
|
||||
wall: 81,
|
||||
meta: { trade: 100, marker: 1 },
|
||||
});
|
||||
expect(damagedCity?.meta.trust).toBeCloseTo(79.6);
|
||||
expect(damagedCity?.meta.trust).toBe(Math.fround(79.6));
|
||||
expect(world.getGeneralById(1)).toMatchObject({ injury: 0, crew: 99, atmos: 50, train: 51 });
|
||||
expect(world.getGeneralById(2)).toMatchObject({ injury: 7, crew: 97, atmos: 49, train: 50 });
|
||||
expect(world.getGeneralById(3)).toMatchObject({ injury: 80, crew: 97, atmos: 49, train: 50 });
|
||||
|
||||
@@ -50,7 +50,7 @@ const buildCity = (id: number, nationId = 1, level = 4): City => ({
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const buildNation = (level: number): Nation => ({
|
||||
const buildNation = (level: number, patch: Partial<Nation> = {}): Nation => ({
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#000000',
|
||||
@@ -62,6 +62,7 @@ const buildNation = (level: number): Nation => ({
|
||||
level,
|
||||
typeCode: 'che_중립',
|
||||
meta: { marker: 1 },
|
||||
...patch,
|
||||
});
|
||||
|
||||
const buildGeneral = (id: number, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
@@ -113,6 +114,7 @@ const buildHarness = async (
|
||||
itemModules?: ItemModule[];
|
||||
configConst?: Record<string, unknown>;
|
||||
additionalOccupiedCounts?: Map<string, number>;
|
||||
neutralCityCount?: number;
|
||||
} = {}
|
||||
) => {
|
||||
const state: TurnWorldState = {
|
||||
@@ -143,8 +145,27 @@ const buildHarness = async (
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
generals: [buildGeneral(1), buildGeneral(2, { meta: { killturn: 850, belong: 30 } })],
|
||||
cities: Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)),
|
||||
nations: [buildNation(nationLevel)],
|
||||
cities: [
|
||||
...Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)),
|
||||
...Array.from({ length: options.neutralCityCount ?? 0 }, (_, index) =>
|
||||
buildCity(cityCount + index + 1, 0)
|
||||
),
|
||||
],
|
||||
nations: [
|
||||
...(options.neutralCityCount
|
||||
? [
|
||||
buildNation(0, {
|
||||
id: 0,
|
||||
name: '재야',
|
||||
capitalCityId: null,
|
||||
chiefGeneralId: null,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
buildNation(nationLevel),
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [event],
|
||||
@@ -236,6 +257,15 @@ describe('UpdateNationLevel monthly action', () => {
|
||||
expect(world.peekDirtyState().logs).toEqual([]);
|
||||
});
|
||||
|
||||
it('never promotes the Core-only neutral nation sentinel from neutral cities', async () => {
|
||||
const { world, handler } = await buildHarness(0, 0, { neutralCityCount: 21 });
|
||||
|
||||
await handler([], { year: 193, month: 2, startyear: 190, currentEventID: 1, turnTime: new Date() }, event);
|
||||
|
||||
expect(world.getNationById(0)).toMatchObject({ level: 0, gold: 0, rice: 0 });
|
||||
expect(world.peekDirtyState().logs).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets both rename permissions only on promotion to emperor', async () => {
|
||||
const { world, handler } = await buildHarness(6, 21);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createMonthlyNationCountHandler,
|
||||
createMonthlyNationStatsHandler,
|
||||
createMonthlyWarSettingHandler,
|
||||
roundLegacyNationPowerValue,
|
||||
} from '../src/turn/monthlyNationStatsHandler.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
@@ -107,6 +108,10 @@ const buildNation = (
|
||||
});
|
||||
|
||||
describe('monthly nation statistics boundary', () => {
|
||||
it('stabilizes MariaDB decimal half boundaries before rounding', () => {
|
||||
expect(roundLegacyNationPowerValue(342.49999999999994)).toBe(343);
|
||||
});
|
||||
|
||||
it('matches the fixed legacy power, maxima, war-setting count, and final general cache', async () => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { City, Nation, NationTraitModule } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createProcessSemiAnnualHandler } from '../src/turn/monthlySemiAnnualAction.js';
|
||||
import {
|
||||
createProcessSemiAnnualHandler,
|
||||
storeLegacySemiAnnualTrust,
|
||||
} from '../src/turn/monthlySemiAnnualAction.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const buildCity = (id: number, patch: Partial<City> = {}): City => ({
|
||||
@@ -151,6 +154,11 @@ const environment = {
|
||||
};
|
||||
|
||||
describe('ProcessSemiAnnual monthly action', () => {
|
||||
it('stores the adjusted trust at the MariaDB FLOAT boundary', () => {
|
||||
expect(storeLegacySemiAnnualTrust(88.30675 + 10)).toBe(Math.fround(98.30675));
|
||||
expect(storeLegacySemiAnnualTrust(88.30675 + 10)).not.toBe(98.30675);
|
||||
});
|
||||
|
||||
it('preserves the global popIncrease order, neutral double decay, supplied filtering, and nation trait', async () => {
|
||||
const { world, handler } = buildHarness({
|
||||
configConst: { basePopIncreaseAmount: 15_000 },
|
||||
@@ -241,6 +249,17 @@ describe('ProcessSemiAnnual monthly action', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rounds decimal half boundaries like MariaDB ROUND', async () => {
|
||||
const { world, handler } = buildHarness({
|
||||
cities: [buildCity(1, { defence: 2_000, wall: 2_000 })],
|
||||
nations: [buildNation(1, { meta: { rate: 15 } })],
|
||||
});
|
||||
|
||||
await handler(['gold'], environment, event);
|
||||
|
||||
expect(world.getCityById(1)).toMatchObject({ defence: 2_030, wall: 2_030 });
|
||||
});
|
||||
|
||||
it('applies strict legacy resource thresholds to generals and nations for either resource', async () => {
|
||||
const amounts = [1_000, 1_001, 10_000, 10_001, 100_000, 100_001];
|
||||
const { world, handler } = buildHarness({
|
||||
|
||||
@@ -120,7 +120,7 @@ describeDb('scenario database seed', () => {
|
||||
expect(worldState?.config).toMatchObject({ tournamentTrig: true });
|
||||
expect(generalCount).toBeGreaterThan(0);
|
||||
const seededGeneral = await prisma.general.findFirst();
|
||||
expect(seededGeneral?.startAge).toBe(seededGeneral?.age);
|
||||
expect(seededGeneral?.startAge).toBe(20);
|
||||
expect(seededGeneral?.meta).toEqual(
|
||||
expect.objectContaining({
|
||||
specage: expect.any(Number),
|
||||
@@ -239,6 +239,7 @@ describeDb('scenario database seed', () => {
|
||||
expect(config.tournamentTrig).toBe(false);
|
||||
|
||||
const meta = (worldState.meta ?? {}) as Record<string, unknown>;
|
||||
expect(meta.develcost).toBe((worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2);
|
||||
const autorun = (meta.autorun_user ?? {}) as Record<string, unknown>;
|
||||
const autorunOptions = (autorun.options ?? {}) as Record<string, unknown>;
|
||||
expect(autorunOptions.develop).toBe(true);
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('monthly tournament auto start', () => {
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 2,
|
||||
termSeconds: 600,
|
||||
termSeconds: 10,
|
||||
nextAt: '2026-07-25T00:10:00.000Z',
|
||||
});
|
||||
expect(world.getState().meta.tournamentPattern).toEqual([0, 1, 2]);
|
||||
@@ -218,8 +218,8 @@ describe('monthly tournament auto start', () => {
|
||||
const second = await run();
|
||||
expect(second).toEqual(first);
|
||||
expect(first).toEqual({
|
||||
tournamentState: expect.objectContaining({ type: 1 }),
|
||||
remainingPattern: [2, 0, 0, 3],
|
||||
tournamentState: expect.objectContaining({ type: 0, bettingId: 1 }),
|
||||
remainingPattern: [1, 3, 2, 0],
|
||||
});
|
||||
} finally {
|
||||
random.mockRestore();
|
||||
|
||||
@@ -280,12 +280,11 @@ describe('TurnDaemonLifecycle', () => {
|
||||
await loop;
|
||||
});
|
||||
|
||||
it('runs scheduled turn based on queue front and checkpoint context', async () => {
|
||||
it('catches up through the observed clock with queue and checkpoint context', async () => {
|
||||
const turnTermMinutes = 10;
|
||||
const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);
|
||||
const generalTurnQueue = [addMinutes(lastTurnTime, 5), addMinutes(lastTurnTime, 20)];
|
||||
const nextTickTime = getNextTickTime(lastTurnTime, turnTermMinutes);
|
||||
const expectedRunTimeMs = Math.min(nextTickTime.getTime(), generalTurnQueue[0]!.getTime());
|
||||
const expectedRunTimeMs = addMinutes(lastTurnTime, 30).getTime();
|
||||
const checkpoint = {
|
||||
turnTime: lastTurnTime.toISOString(),
|
||||
generalId: 101,
|
||||
@@ -358,12 +357,11 @@ describe('TurnDaemonLifecycle', () => {
|
||||
await loop;
|
||||
});
|
||||
|
||||
it('runs scheduled turn when tick boundary arrives before queue front', async () => {
|
||||
it('catches up through the observed clock when tick boundary precedes the queue front', async () => {
|
||||
const turnTermMinutes = 10;
|
||||
const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);
|
||||
const generalTurnQueue = [addMinutes(lastTurnTime, 15), addMinutes(lastTurnTime, 30)];
|
||||
const nextTickTime = getNextTickTime(lastTurnTime, turnTermMinutes);
|
||||
const expectedRunTimeMs = Math.min(nextTickTime.getTime(), generalTurnQueue[0]!.getTime());
|
||||
const expectedRunTimeMs = addMinutes(lastTurnTime, 30).getTime();
|
||||
const checkpoint = {
|
||||
turnTime: lastTurnTime.toISOString(),
|
||||
generalId: 102,
|
||||
|
||||
@@ -150,13 +150,23 @@ describe('InMemoryTurnProcessor ordering', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await processor.run(addMinutes(baseTime, 30), {
|
||||
const budget = {
|
||||
budgetMs: 1000,
|
||||
maxGenerals: 10,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
};
|
||||
|
||||
expect(executed).toEqual([2, 3, 1]);
|
||||
const boundaryResult = await processor.run(addMinutes(baseTime, 10), budget);
|
||||
expect(boundaryResult.processedTurns).toBe(1);
|
||||
expect(executed).toEqual([]);
|
||||
|
||||
const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget);
|
||||
expect(tiedGeneralResult.processedTurns).toBe(0);
|
||||
expect(executed).toEqual([2, 3]);
|
||||
|
||||
await processor.run(addMinutes(baseTime, 30), budget);
|
||||
|
||||
expect(executed).toEqual([2, 3, 1, 2, 3]);
|
||||
expect(world.getNextGeneralId()).toBe(4);
|
||||
expect(world.getNextGeneralId()).toBe(5);
|
||||
expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 });
|
||||
|
||||
@@ -107,6 +107,11 @@ export interface GeneralActionOutcome<TriggerState extends GeneralTriggerState =
|
||||
effects: GeneralActionEffect<TriggerState>[];
|
||||
completed?: boolean;
|
||||
deletedTroopIds?: number[];
|
||||
reservedGeneralTurnPlans?: Array<{
|
||||
generalId: number;
|
||||
joinTurn: number;
|
||||
destNationId: number;
|
||||
}>;
|
||||
alternative?: {
|
||||
commandKey: string;
|
||||
args: unknown;
|
||||
@@ -148,6 +153,11 @@ export interface GeneralActionResolution {
|
||||
args: unknown;
|
||||
};
|
||||
deletedTroopIds?: number[];
|
||||
reservedGeneralTurnPlans?: Array<{
|
||||
generalId: number;
|
||||
joinTurn: number;
|
||||
destNationId: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const createGeneralPatchEffect = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||
@@ -391,6 +401,9 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
||||
effects: pendingEffects,
|
||||
...(outcome?.alternative ? { alternative: outcome.alternative } : {}),
|
||||
...(outcome?.deletedTroopIds?.length ? { deletedTroopIds: outcome.deletedTroopIds } : {}),
|
||||
...(outcome?.reservedGeneralTurnPlans?.length
|
||||
? { reservedGeneralTurnPlans: outcome.reservedGeneralTurnPlans }
|
||||
: {}),
|
||||
};
|
||||
if (nextWorld.city) {
|
||||
resolution.city = nextWorld.city as City;
|
||||
|
||||
@@ -158,6 +158,8 @@ const DEFAULT_AFTER_CONFIG = {
|
||||
techLevelIncYear: 5,
|
||||
initialAllowedTechLevel: 1,
|
||||
defaultCityWall: 1000,
|
||||
baseGold: 0,
|
||||
baseRice: 2000,
|
||||
};
|
||||
|
||||
const resolveNumber = (record: Record<string, unknown>, keys: string[], fallback: number): number => {
|
||||
@@ -205,6 +207,8 @@ export const buildWarConfig = (scenarioConfig: ScenarioConfig, unitSet: UnitSetD
|
||||
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_WAR_CONFIG.maxAtmosByCommand),
|
||||
maxTrainByWar: resolveNumber(constValues, ['maxTrainByWar'], DEFAULT_WAR_CONFIG.maxTrainByWar),
|
||||
maxAtmosByWar: resolveNumber(constValues, ['maxAtmosByWar'], DEFAULT_WAR_CONFIG.maxAtmosByWar),
|
||||
maxGeneralStat: resolveNumber(constValues, ['maxLevel'], 255),
|
||||
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
|
||||
castleCrewTypeId,
|
||||
armTypes: {
|
||||
footman: 1,
|
||||
@@ -233,9 +237,11 @@ export const buildWarAftermathConfig = (
|
||||
),
|
||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
|
||||
defaultCityWall: resolveNumber(constValues, ['defaultCityWall'], DEFAULT_AFTER_CONFIG.defaultCityWall),
|
||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_AFTER_CONFIG.baseGold),
|
||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_AFTER_CONFIG.baseRice),
|
||||
castleCrewTypeId,
|
||||
joinMode: 'full',
|
||||
joinRuinedNpcProbability: resolveNumber(constValues, ['joinRuinedNPCProp'], 0.1),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
||||
import type { ActionContextBuilder, ActionContextBase } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
|
||||
|
||||
export interface UprisingArgs {}
|
||||
@@ -30,8 +31,12 @@ export interface UprisingContext extends ActionContextBase {
|
||||
}
|
||||
|
||||
const ACTION_NAME = '거병';
|
||||
const formatHourMinute = (date: Date): string =>
|
||||
`${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`;
|
||||
const formatHourMinute = (date: unknown): string => {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return '00:00';
|
||||
}
|
||||
return `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const truncateLegacyWidth = (value: string, maxWidth: number): string => {
|
||||
let result = '';
|
||||
@@ -52,6 +57,11 @@ export class ActionDefinition<
|
||||
> implements GeneralActionDefinition<TriggerState, UprisingArgs> {
|
||||
public readonly key = 'che_거병';
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(env: TurnCommandEnv) {
|
||||
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||
}
|
||||
getInheritanceActiveActionAmount(): number {
|
||||
return 1;
|
||||
}
|
||||
@@ -89,15 +99,6 @@ export class ActionDefinition<
|
||||
nationName = '㉥' + nationName;
|
||||
}
|
||||
|
||||
const npcNationPolicy =
|
||||
general.npcState >= 2
|
||||
? {
|
||||
values: {
|
||||
minNPCRecruitCityPopulation: 0,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const newNation: Nation = {
|
||||
id: newNationId,
|
||||
name: nationName,
|
||||
@@ -116,7 +117,6 @@ export class ActionDefinition<
|
||||
surlimit: 72,
|
||||
secretlimit: uprisingCtx.scenarioId >= 1000 ? 1 : 3,
|
||||
gennum: 1,
|
||||
...(npcNationPolicy ? { npc_nation_policy: npcNationPolicy } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -158,8 +158,8 @@ export class ActionDefinition<
|
||||
createGeneralPatchEffect<TriggerState>({
|
||||
nationId: newNationId,
|
||||
officerLevel: 12,
|
||||
experience: (general.experience || 0) + 100,
|
||||
dedication: (general.dedication || 0) + 100,
|
||||
experience: (general.experience || 0) + this.pipeline.onCalcStat(context, 'experience', 100),
|
||||
dedication: (general.dedication || 0) + this.pipeline.onCalcStat(context, 'dedication', 100),
|
||||
meta: {
|
||||
...general.meta,
|
||||
belong: 1,
|
||||
@@ -200,5 +200,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
category: '전략',
|
||||
reqArg: false,
|
||||
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||
};
|
||||
|
||||
@@ -23,19 +23,16 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import {
|
||||
FOUNDING_ARGS_SCHEMA,
|
||||
getNationTypeDisplayName,
|
||||
NATION_COLORS,
|
||||
type FoundingArgs,
|
||||
} from './foundingShared.js';
|
||||
import { FOUNDING_ARGS_SCHEMA, getNationTypeDisplayName, NATION_COLORS, type FoundingArgs } from './foundingShared.js';
|
||||
|
||||
const ACTION_NAME = '건국';
|
||||
interface FoundingResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState>
|
||||
extends GeneralActionResolveContext<TriggerState> {
|
||||
interface FoundingResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
currentYearMonth?: number;
|
||||
initYearMonth?: number;
|
||||
}
|
||||
@@ -45,6 +42,12 @@ export class ActionDefinition<
|
||||
> implements GeneralActionDefinition<TriggerState, FoundingArgs> {
|
||||
public readonly key = 'che_건국';
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(env: TurnCommandEnv) {
|
||||
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||
}
|
||||
|
||||
getInheritanceActiveActionAmount(): number {
|
||||
return 1;
|
||||
}
|
||||
@@ -74,10 +77,7 @@ export class ActionDefinition<
|
||||
];
|
||||
}
|
||||
|
||||
resolve(
|
||||
context: FoundingResolveContext<TriggerState>,
|
||||
args: FoundingArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
resolve(context: FoundingResolveContext<TriggerState>, args: FoundingArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
const nation = context.nation!;
|
||||
const cityId = general.cityId!;
|
||||
@@ -119,14 +119,11 @@ export class ActionDefinition<
|
||||
scope: LogScope.GENERAL,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
context.addLog(
|
||||
`<Y>${general.name}</>${josaGeneralYi} <D><b>${args.nationName}</b></>${josaNationUl} 건국`,
|
||||
{
|
||||
category: LogCategory.HISTORY,
|
||||
scope: LogScope.NATION,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}
|
||||
);
|
||||
context.addLog(`<Y>${general.name}</>${josaGeneralYi} <D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
|
||||
category: LogCategory.HISTORY,
|
||||
scope: LogScope.NATION,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
|
||||
tryApplyUniqueLottery(context, {
|
||||
acquireType: '건국',
|
||||
@@ -157,8 +154,8 @@ export class ActionDefinition<
|
||||
cityId
|
||||
),
|
||||
createGeneralPatchEffect<TriggerState>({
|
||||
experience: (general.experience || 0) + 1000,
|
||||
dedication: (general.dedication || 0) + 1000,
|
||||
experience: (general.experience || 0) + this.pipeline.onCalcStat(context, 'experience', 1000),
|
||||
dedication: (general.dedication || 0) + this.pipeline.onCalcStat(context, 'dedication', 1000),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -186,5 +183,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
colorType: 'number',
|
||||
},
|
||||
argsSchema: FOUNDING_ARGS_SCHEMA,
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { increaseMetaNumber } from '@sammo-ts/logic/war/utils.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
|
||||
export interface SightseeingArgs {}
|
||||
|
||||
@@ -172,6 +173,11 @@ export class ActionDefinition<
|
||||
> implements GeneralActionDefinition<TriggerState, SightseeingArgs> {
|
||||
public readonly key = 'che_견문';
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
public constructor(env: TurnCommandEnv) {
|
||||
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||
}
|
||||
|
||||
parseArgs(_raw: unknown): SightseeingArgs | null {
|
||||
void _raw;
|
||||
@@ -231,7 +237,7 @@ export class ActionDefinition<
|
||||
general.injury = Math.min(80, general.injury + delta);
|
||||
}
|
||||
|
||||
general.experience += exp;
|
||||
general.experience += this.pipeline.onCalcStat(context, 'experience', exp);
|
||||
|
||||
context.addLog(message);
|
||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||
@@ -248,5 +254,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
category: '개인',
|
||||
reqArg: false,
|
||||
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||
};
|
||||
|
||||
@@ -17,10 +17,12 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
|
||||
export interface TradeEnvironment {
|
||||
exchangeFee?: number;
|
||||
maxResourceActionAmount?: number;
|
||||
generalActionModules?: TurnCommandEnv['generalActionModules'];
|
||||
}
|
||||
|
||||
const ACTION_NAME = '군량매매';
|
||||
@@ -37,9 +39,11 @@ export class ActionDefinition<
|
||||
public readonly key = 'che_군량매매';
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly env: TradeEnvironment;
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(env: TradeEnvironment = {}) {
|
||||
this.env = env;
|
||||
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): TradeArgs | null {
|
||||
@@ -126,8 +130,8 @@ export class ActionDefinition<
|
||||
}
|
||||
|
||||
// 경험치 및 명성 증가
|
||||
general.experience += 30;
|
||||
general.dedication += 50;
|
||||
general.experience += this.pipeline.onCalcStat(context, 'experience', 30);
|
||||
general.dedication += this.pipeline.onCalcStat(context, 'dedication', 50);
|
||||
const weightedStats = [
|
||||
['leadership_exp', general.stats.leadership],
|
||||
['strength_exp', general.stats.strength],
|
||||
@@ -167,5 +171,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
createDefinition: (env: TurnCommandEnv) =>
|
||||
new ActionDefinition({
|
||||
maxResourceActionAmount: env.maxResourceActionAmount,
|
||||
generalActionModules: env.generalActionModules,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
|
||||
export interface ReturnArgs {}
|
||||
|
||||
@@ -41,6 +42,11 @@ export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionResolver<TriggerState, ReturnArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(generalActionModules?: TurnCommandEnv['generalActionModules']) {
|
||||
this.pipeline = new GeneralActionPipeline(generalActionModules ?? []);
|
||||
}
|
||||
|
||||
resolve(context: ReturnResolveContext<TriggerState>, _args: ReturnArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
@@ -116,8 +122,8 @@ export class ActionResolver<
|
||||
// We can just use standard MONTH format for now.
|
||||
});
|
||||
|
||||
const exp = 70;
|
||||
const ded = 100;
|
||||
const exp = this.pipeline.onCalcStat(context, 'experience', 70);
|
||||
const ded = this.pipeline.onCalcStat(context, 'dedication', 100);
|
||||
|
||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||
|
||||
@@ -159,8 +165,8 @@ export class ActionDefinition<
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor() {
|
||||
this.resolver = new ActionResolver();
|
||||
constructor(env: Pick<TurnCommandEnv, 'generalActionModules'> = {}) {
|
||||
this.resolver = new ActionResolver(env.generalActionModules);
|
||||
}
|
||||
|
||||
parseArgs(_raw: unknown): ReturnArgs | null {
|
||||
@@ -192,5 +198,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
category: '군사',
|
||||
reqArg: false,
|
||||
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||
};
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
} from './che_상업투자.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import { clamp } from 'es-toolkit';
|
||||
import {
|
||||
addLegacyStoredFloat,
|
||||
readLegacyStoredFloat,
|
||||
toLegacyStoredFloat,
|
||||
} from '@sammo-ts/logic/compat/legacyFloat.js';
|
||||
|
||||
export interface TechResearchArgs {}
|
||||
|
||||
@@ -55,7 +60,17 @@ const readTech = (nation: Nation): number => {
|
||||
};
|
||||
|
||||
// 레거시 nation.tech는 MariaDB FLOAT이며 다음 명령 재조회 시 6자리 유효숫자로 양자화된다.
|
||||
const toLegacyStoredTech = (value: number): number => Number(Math.fround(value).toPrecision(6));
|
||||
// Ref stores tech in a MariaDB FLOAT column. FLOAT applies binary32
|
||||
// quantization on write; its six-significant-digit text rendering happens
|
||||
// only when the value is read, not on every update.
|
||||
export const toLegacyStoredTech = toLegacyStoredFloat;
|
||||
|
||||
// mysqli renders a MariaDB FLOAT with six significant decimal digits before
|
||||
// PHP performs the next command's arithmetic. Model that read boundary, then
|
||||
// model the binary32 write boundary separately.
|
||||
export const readLegacyStoredTech = readLegacyStoredFloat;
|
||||
|
||||
export const addLegacyStoredTech = addLegacyStoredFloat;
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
@@ -107,10 +122,21 @@ export class ActionDefinition<
|
||||
techScore /= 4;
|
||||
}
|
||||
|
||||
const generalCount = Math.max(context.nationGeneralCount, this.env.initialNationGenLimit);
|
||||
if (
|
||||
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(context.general.id)) ||
|
||||
(process.env.CORE_AI_TRACE_NATION_IDS?.split(',') ?? []).includes(String(context.nation.id))
|
||||
) {
|
||||
process.stdout.write(
|
||||
`AI_ACTION_PATCH_TRACE ${JSON.stringify({ engine: 'core-tech', generalId: context.general.id, nationId: context.nation.id, currentTech, techScore, nationGeneralCount: context.nationGeneralCount, generalCount, delta: techScore / generalCount })}\n`
|
||||
);
|
||||
}
|
||||
|
||||
context.nation.meta = {
|
||||
...context.nation.meta,
|
||||
tech: toLegacyStoredTech(
|
||||
currentTech + techScore / Math.max(context.nationGeneralCount, this.env.initialNationGenLimit)
|
||||
tech: addLegacyStoredTech(
|
||||
currentTech,
|
||||
techScore / generalCount
|
||||
),
|
||||
};
|
||||
context.general.gold = Math.max(0, context.general.gold - result.costGold);
|
||||
|
||||
@@ -15,6 +15,8 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import { resolveStartYear } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
|
||||
export interface RandomAppointmentArgs {}
|
||||
|
||||
@@ -36,6 +38,8 @@ export interface RandomAppointmentResolveContext<
|
||||
}
|
||||
|
||||
const ACTION_NAME = '무작위 국가로 임관';
|
||||
const LEGACY_INITIAL_NATION_GEN_LIMIT = 10;
|
||||
const LEGACY_DEFAULT_MAX_GENERAL = 500;
|
||||
|
||||
const TALK_LIST = [
|
||||
'어쩌다 보니',
|
||||
@@ -138,6 +142,11 @@ export class ActionDefinition<
|
||||
> {
|
||||
public readonly key = 'che_랜덤임관';
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(env: TurnCommandEnv) {
|
||||
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||
}
|
||||
getInheritanceActiveActionAmount(): number {
|
||||
return 1;
|
||||
}
|
||||
@@ -236,7 +245,7 @@ export class ActionDefinition<
|
||||
nationId: destNation.id,
|
||||
officerLevel: 1,
|
||||
cityId: destCityId,
|
||||
experience: general.experience + expGain,
|
||||
experience: general.experience + this.pipeline.onCalcStat(context, 'experience', expGain),
|
||||
meta,
|
||||
}),
|
||||
createNationPatchEffect(
|
||||
@@ -294,8 +303,16 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
|
||||
const constValues = asRecord(options.scenarioConfig.const);
|
||||
const worldMeta = asRecord(options.world.meta);
|
||||
const initialNationGenLimit = resolveNumber(constValues, ['initialNationGenLimit'], 0);
|
||||
const defaultMaxGeneral = resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0);
|
||||
const initialNationGenLimit = resolveNumber(
|
||||
constValues,
|
||||
['initialNationGenLimit'],
|
||||
LEGACY_INITIAL_NATION_GEN_LIMIT
|
||||
);
|
||||
const defaultMaxGeneral = resolveNumber(
|
||||
constValues,
|
||||
['defaultMaxGeneral', 'maxGeneral'],
|
||||
LEGACY_DEFAULT_MAX_GENERAL
|
||||
);
|
||||
const genLimit = relYear < 3 && initialNationGenLimit > 0 ? initialNationGenLimit : defaultMaxGeneral;
|
||||
|
||||
const generals = worldRef.listGenerals();
|
||||
@@ -314,7 +331,11 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
}
|
||||
}
|
||||
|
||||
const nations = worldRef.listNations();
|
||||
// Ref's GROUP BY result is observed in ascending nation id order. The
|
||||
// weighted draw is order-sensitive, while newly founded Core nations can
|
||||
// remain in action/creation order, so make the legacy candidate order
|
||||
// explicit before consuming the command RNG.
|
||||
const nations = [...worldRef.listNations()].sort((left, right) => left.id - right.id);
|
||||
const candidateNations: Array<CandidateNation> = [];
|
||||
|
||||
for (const nation of nations) {
|
||||
@@ -373,5 +394,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
key: 'che_랜덤임관',
|
||||
category: '전략',
|
||||
reqArg: false,
|
||||
createDefinition: () => new ActionDefinition(),
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||
};
|
||||
|
||||
@@ -25,6 +25,8 @@ interface ProcureContext<
|
||||
const ACTION_NAME = '물자조달';
|
||||
const ACTION_KEY = 'che_물자조달';
|
||||
|
||||
export const roundLegacyAccumulatedInteger = (current: number, delta: number): number => Math.round(current + delta);
|
||||
|
||||
export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionResolver<TriggerState, ProcureArgs> {
|
||||
@@ -96,13 +98,17 @@ export class ActionResolver<
|
||||
score = Math.round(score);
|
||||
|
||||
// 6. Calculate Exp/Dedication
|
||||
const exp = (score * 0.7) / 3;
|
||||
const ded = (score * 1.0) / 3;
|
||||
const exp = this.pipeline.onCalcStat(context, 'experience', (score * 0.7) / 3);
|
||||
const ded = this.pipeline.onCalcStat(context, 'dedication', (score * 1.0) / 3);
|
||||
|
||||
// 7. Update General
|
||||
// 레거시는 부동소수점 증가분을 INT column에 저장할 때 반올림한다.
|
||||
const nextExp = general.experience + Math.round(exp);
|
||||
const nextDed = general.dedication + Math.round(ded);
|
||||
// Ref adds the floating delta to the current value first and MariaDB
|
||||
// rounds the accumulated value when it writes the INT column. Rounding
|
||||
// the delta separately changes cancellation cases such as
|
||||
// 4554 + (45 * 0.7 / 3): the delta is 10.499999999999998, while the
|
||||
// accumulated binary value is exactly 4564.5 and persists as 4565.
|
||||
const nextExp = roundLegacyAccumulatedInteger(general.experience, exp);
|
||||
const nextDed = roundLegacyAccumulatedInteger(general.dedication, ded);
|
||||
|
||||
let appliedScore = score;
|
||||
if (context.city && [1, 3].includes(context.city.frontState)) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { clamp } from 'es-toolkit';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js';
|
||||
|
||||
export interface BoostMoraleArgs {}
|
||||
|
||||
@@ -85,8 +86,10 @@ export class ActionDefinition<
|
||||
? this.env.maxAtmosByCommand
|
||||
: DEFAULT_MAX_ATMOS;
|
||||
const delta = this.env.atmosDelta && this.env.atmosDelta > 0 ? this.env.atmosDelta : DEFAULT_ATMOS_DELTA;
|
||||
const leadership = this.pipeline.onCalcStat(context, 'leadership', general.stats.leadership);
|
||||
const score = Math.round((leadership * 100 * delta) / general.crew);
|
||||
const leadership = finalizeLegacyStat(
|
||||
this.pipeline.onCalcStat(context, 'leadership', applyLegacyInjury(general.stats.leadership, general.injury))
|
||||
);
|
||||
const score = Math.round(((leadership * 100) / general.crew) * delta);
|
||||
const nextAtmos = clamp(general.atmos + score, 0, maxAtmos);
|
||||
const applied = nextAtmos - general.atmos;
|
||||
const costGold = this.env.costGold ?? Math.round(general.crew / 100);
|
||||
@@ -95,15 +98,20 @@ export class ActionDefinition<
|
||||
general.atmos = nextAtmos;
|
||||
general.train = trainSideEffect;
|
||||
general.gold = Math.max(0, general.gold - costGold);
|
||||
general.experience += 100;
|
||||
general.dedication += 70;
|
||||
general.experience += this.pipeline.onCalcStat(context, 'experience', 100);
|
||||
general.dedication += this.pipeline.onCalcStat(context, 'dedication', 70);
|
||||
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
|
||||
general.meta.leadership_exp = leadershipExp + 1;
|
||||
const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId);
|
||||
if (crewType) {
|
||||
const dexKey = `dex${crewType.armType}`;
|
||||
const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0;
|
||||
general.meta[dexKey] = dex + applied;
|
||||
const armType = crewType.armType === 0 ? 5 : crewType.armType;
|
||||
if (armType >= 0) {
|
||||
const dexKey = `dex${armType}`;
|
||||
const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0;
|
||||
const typeMultiplier = armType === 4 || armType === 5 ? 0.9 : 1;
|
||||
general.meta[dexKey] =
|
||||
dex + this.pipeline.onCalcStat(context, 'addDex', applied * typeMultiplier, { armType });
|
||||
}
|
||||
}
|
||||
|
||||
context.addLog(`사기치가 <C>${applied}</> 상승했습니다.`);
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface InvestmentConfig {
|
||||
useCityTrust?: boolean;
|
||||
scaleSuccessByTrust?: boolean;
|
||||
roundCriticalScore?: boolean;
|
||||
costMultiplier?: number;
|
||||
}
|
||||
|
||||
export interface InvestmentEnvironment {
|
||||
@@ -181,7 +182,7 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
||||
gold: number;
|
||||
rice: number;
|
||||
} {
|
||||
const baseGold = this.env.develCost;
|
||||
const baseGold = this.env.develCost * (this.config.costMultiplier ?? 1);
|
||||
const gold = Math.round(this.pipeline.onCalcDomestic(context, this.config.actionKey, 'cost', baseGold));
|
||||
return { gold, rice: 0 };
|
||||
}
|
||||
@@ -300,8 +301,8 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
||||
appliedFrontDebuff = true;
|
||||
}
|
||||
|
||||
const exp = rewardScore * 0.7;
|
||||
const dedication = rewardScore;
|
||||
const exp = this.pipeline.onCalcStat(context, 'experience', rewardScore * 0.7);
|
||||
const dedication = this.pipeline.onCalcStat(context, 'dedication', rewardScore);
|
||||
|
||||
return {
|
||||
pick,
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
import { createGeneralPatchEffect, createCityPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { z } from 'zod';
|
||||
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
@@ -46,10 +47,6 @@ const ARGS_SCHEMA = z.object({
|
||||
});
|
||||
export type AgitateArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||
|
||||
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
|
||||
// 재조회된다. 메모리 상태도 같은 persistence 경계로 정규화한다.
|
||||
const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6));
|
||||
|
||||
export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionResolver<TriggerState, AgitateArgs> {
|
||||
@@ -98,8 +95,9 @@ export class ActionResolver<
|
||||
}
|
||||
general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1;
|
||||
const newSecu = Math.max(0, destCity.security - result.agriDamage);
|
||||
const currentTrust = typeof destCity.meta.trust === 'number' ? destCity.meta.trust : 50;
|
||||
const newTrust = toLegacyStoredTrust(Math.max(0, currentTrust - result.commDamage));
|
||||
const currentTrust =
|
||||
typeof destCity.meta.trust === 'number' ? readLegacyCityTrust(destCity.meta.trust) : 50;
|
||||
const newTrust = storeLegacyCityTrust(Math.max(0, currentTrust - result.commDamage));
|
||||
|
||||
// Log
|
||||
const commandName = ACTION_NAME;
|
||||
|
||||
@@ -43,8 +43,8 @@ export class ActionDefinition<
|
||||
const crewUp = this.pipeline.onCalcDomestic(context, '징집인구', 'score', general.crew);
|
||||
general.crew = 0;
|
||||
city.population += Math.trunc(crewUp);
|
||||
general.experience += 70;
|
||||
general.dedication += 100;
|
||||
general.experience += this.pipeline.onCalcStat(context, 'experience', 70);
|
||||
general.dedication += this.pipeline.onCalcStat(context, 'dedication', 100);
|
||||
|
||||
context.addLog(`병사들을 <R>소집해제</>하였습니다.`);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-t
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
|
||||
export interface RecoveryArgs {}
|
||||
|
||||
@@ -21,9 +22,11 @@ export class ActionDefinition<
|
||||
public readonly key = 'che_요양';
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly env: RecoveryEnvironment;
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(env: RecoveryEnvironment = {}) {
|
||||
constructor(env: RecoveryEnvironment & Partial<Pick<TurnCommandEnv, 'generalActionModules'>> = {}) {
|
||||
this.env = env;
|
||||
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||
}
|
||||
|
||||
parseArgs(_raw: unknown): RecoveryArgs | null {
|
||||
@@ -46,8 +49,8 @@ export class ActionDefinition<
|
||||
// 직접 수정 (Immer Draft)
|
||||
general.injury = nextInjury;
|
||||
general.gold = Math.max(0, general.gold - costGold);
|
||||
general.experience += 10;
|
||||
general.dedication += 7;
|
||||
general.experience += this.pipeline.onCalcStat(context, 'experience', 10);
|
||||
general.dedication += this.pipeline.onCalcStat(context, 'dedication', 7);
|
||||
|
||||
context.addLog(`건강 회복을 위해 요양합니다.`);
|
||||
|
||||
@@ -63,5 +66,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
category: '개인',
|
||||
reqArg: false,
|
||||
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { formatDestCityConstraintFailure } from '../constraintFailure.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
|
||||
export interface MoveResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
@@ -44,6 +45,11 @@ export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionResolver<TriggerState, MoveArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(env: TurnCommandEnv) {
|
||||
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||
}
|
||||
|
||||
resolve(context: MoveResolveContext<TriggerState>, args: MoveArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
@@ -89,7 +95,7 @@ export class ActionResolver<
|
||||
if (isSelf) {
|
||||
nextGold = Math.max(0, nextGold - cost);
|
||||
nextAtmos = Math.max(20, nextAtmos - 5);
|
||||
nextExp += 50;
|
||||
nextExp += this.pipeline.onCalcStat(context, 'experience', 50);
|
||||
nextLeadershipExp += 1;
|
||||
}
|
||||
|
||||
@@ -123,8 +129,8 @@ export class ActionDefinition<
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor() {
|
||||
this.resolver = new ActionResolver();
|
||||
constructor(env: TurnCommandEnv) {
|
||||
this.resolver = new ActionResolver(env);
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): MoveArgs | null {
|
||||
@@ -171,7 +177,10 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
return {
|
||||
...base,
|
||||
map: options.map,
|
||||
develCost: options.scenarioConfig.const.develCost as number | undefined,
|
||||
develCost:
|
||||
typeof options.world.meta?.develcost === 'number'
|
||||
? options.world.meta.develcost
|
||||
: (options.scenarioConfig.const.develCost as number | undefined),
|
||||
moveGenerals: options.worldRef?.listGenerals() ?? [],
|
||||
};
|
||||
};
|
||||
@@ -182,5 +191,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
reqArg: true,
|
||||
availabilityArgs: { destCityId: 0 },
|
||||
argsSchema: ARGS_SCHEMA,
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||
};
|
||||
|
||||
@@ -51,11 +51,14 @@ export interface TalentScoutResolveContext<
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
retirementYear: number;
|
||||
worldSummary: TalentScoutWorldSummary;
|
||||
generalPool?: TalentScoutCandidate[];
|
||||
cityPool?: City[];
|
||||
existingGeneralNames: string[];
|
||||
createGeneralId: () => number;
|
||||
turnTermMinutes: number;
|
||||
turnTimeBase: Date;
|
||||
}
|
||||
|
||||
export interface TalentScoutEnvironment {
|
||||
@@ -97,6 +100,17 @@ const DEFAULT_MAX_AGE = 25;
|
||||
const DEFAULT_DEATH_MIN = 10;
|
||||
const DEFAULT_DEATH_MAX = 50;
|
||||
|
||||
export const normalizeLegacyGeneratedDex = (
|
||||
values: readonly [number, number, number, number, number]
|
||||
): [number, number, number, number, number] =>
|
||||
values.map((value) => Math.trunc(value)) as [number, number, number, number, number];
|
||||
|
||||
export const resolveLegacySpecialityAge = (
|
||||
retirementYear: number,
|
||||
age: number,
|
||||
divisor: number
|
||||
): number => Math.round((retirementYear - age) / divisor) + age;
|
||||
|
||||
const addMetaValue = (
|
||||
meta: Record<string, TriggerValue>,
|
||||
key: string,
|
||||
@@ -181,6 +195,32 @@ const legacyChoiceIndex = (rng: RandomGenerator, length: number): number => {
|
||||
const legacyChoice = <T>(rng: RandomGenerator, values: readonly T[]): T =>
|
||||
values[legacyChoiceIndex(rng, values.length)]!;
|
||||
|
||||
const NPC_NAME_PREFIXES = ['', 'ⓝ', 'ⓝ', 'ⓜ', 'ⓖ', '㉥', 'ⓤ', 'ⓞ'] as const;
|
||||
const NPC_STATE_NAME_PREFIXES: Readonly<Record<number, string>> = {
|
||||
0: '',
|
||||
1: 'ⓝ',
|
||||
2: 'ⓝ',
|
||||
3: 'ⓜ',
|
||||
4: 'ⓖ',
|
||||
5: '㉥',
|
||||
6: 'ⓤ',
|
||||
9: 'ⓞ',
|
||||
};
|
||||
const STORED_NAME_PREFIXES = new Set(Object.values(NPC_STATE_NAME_PREFIXES).filter(Boolean));
|
||||
|
||||
const restoreLegacyStoredName = (general: Pick<General, 'name' | 'npcState'>): string => {
|
||||
if (STORED_NAME_PREFIXES.has(general.name[0] ?? '')) {
|
||||
return general.name;
|
||||
}
|
||||
return `${NPC_STATE_NAME_PREFIXES[general.npcState] ?? ''}${general.name}`;
|
||||
};
|
||||
|
||||
const countLegacyNameDuplicates = (names: readonly string[], candidate: string): number =>
|
||||
NPC_NAME_PREFIXES.reduce(
|
||||
(total, prefix) => total + names.filter((name) => name.startsWith(`${prefix}${candidate}`)).length,
|
||||
0
|
||||
);
|
||||
|
||||
const resolveCandidate = (
|
||||
context: TalentScoutResolveContext,
|
||||
rng: RandomGenerator,
|
||||
@@ -261,6 +301,14 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
||||
);
|
||||
return this.pipeline.onCalcDomestic(context, ACTION_KEY, 'probability', base);
|
||||
}
|
||||
|
||||
adjustExperience(context: TalentScoutResolveContext<TriggerState>, value: number): number {
|
||||
return this.pipeline.onCalcStat(context, 'experience', value);
|
||||
}
|
||||
|
||||
adjustDedication(context: TalentScoutResolveContext<TriggerState>, value: number): number {
|
||||
return this.pipeline.onCalcStat(context, 'dedication', value);
|
||||
}
|
||||
}
|
||||
|
||||
// 인재탐색 실행 결과를 계산한다.
|
||||
@@ -297,8 +345,8 @@ export class ActionResolver<
|
||||
// 직접 수정 (Immer Draft)
|
||||
general.gold = nextGold;
|
||||
general.rice = nextRice;
|
||||
general.experience += expGain;
|
||||
general.dedication += dedGain;
|
||||
general.experience += this.command.adjustExperience(context, expGain);
|
||||
general.dedication += this.command.adjustDedication(context, dedGain);
|
||||
|
||||
if (!found) {
|
||||
const statKey = pickStatExpKey(context.rng, general);
|
||||
@@ -328,10 +376,23 @@ export class ActionResolver<
|
||||
const firstNames = this.env.randomGeneralFirstNames ?? ['가'];
|
||||
const middleNames = this.env.randomGeneralMiddleNames ?? [''];
|
||||
const lastNames = this.env.randomGeneralLastNames ?? ['가'];
|
||||
const generatedName = `${legacyChoice(context.rng, firstNames)}${legacyChoice(
|
||||
context.rng,
|
||||
middleNames
|
||||
)}${legacyChoice(context.rng, lastNames)}`;
|
||||
let generatedName: string;
|
||||
let duplicateLoopCount = 0;
|
||||
while (true) {
|
||||
generatedName = `${legacyChoice(context.rng, firstNames)}${legacyChoice(
|
||||
context.rng,
|
||||
middleNames
|
||||
)}${legacyChoice(context.rng, lastNames)}`;
|
||||
const duplicateCount = countLegacyNameDuplicates(context.existingGeneralNames, generatedName);
|
||||
if (duplicateCount === 0) {
|
||||
break;
|
||||
}
|
||||
if (duplicateLoopCount >= 99 || duplicateCount < 2) {
|
||||
generatedName += duplicateCount + 1;
|
||||
break;
|
||||
}
|
||||
duplicateLoopCount += 1;
|
||||
}
|
||||
const newGeneralId = context.createGeneralId();
|
||||
const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: generatedName };
|
||||
const affinity = randomRangeInt(context.rng, 1, 150);
|
||||
@@ -369,6 +430,10 @@ export class ActionResolver<
|
||||
} else {
|
||||
dex = [dexTotal / 4, dexTotal / 4, dexTotal / 4, dexTotal / 4, averageDex[4]];
|
||||
}
|
||||
// Ref passes the averages into GeneralBuilder::setDex(int ...), so PHP
|
||||
// truncates every component before persistence. Core's common integer
|
||||
// normalizer rounds instead, which changes exact half-point cases.
|
||||
dex = normalizeLegacyGeneratedDex(dex);
|
||||
const personality =
|
||||
resolvedCandidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']);
|
||||
const name = this.env.decorateName
|
||||
@@ -377,6 +442,9 @@ export class ActionResolver<
|
||||
const cityId = resolveSpawnCityId(context, context.rng, this.env);
|
||||
const turnSecond = randomRangeInt(context.rng, 0, context.turnTermMinutes * 60 - 1);
|
||||
const turnFraction = randomRangeInt(context.rng, 0, 999_999);
|
||||
const turnTime = new Date(
|
||||
context.turnTimeBase.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000)
|
||||
);
|
||||
const killturn =
|
||||
(deathYear - context.currentYear) * 12 + randomRangeInt(context.rng, 0, 11) + context.currentMonth - 1;
|
||||
const meta: GeneralMeta = {
|
||||
@@ -386,6 +454,16 @@ export class ActionResolver<
|
||||
affinity,
|
||||
birthYear,
|
||||
deathYear,
|
||||
specage: resolveLegacySpecialityAge(
|
||||
context.retirementYear,
|
||||
age,
|
||||
12
|
||||
),
|
||||
specage2: resolveLegacySpecialityAge(
|
||||
context.retirementYear,
|
||||
age,
|
||||
6
|
||||
),
|
||||
dex1: dex[0],
|
||||
dex2: dex[1],
|
||||
dex3: dex[2],
|
||||
@@ -397,27 +475,33 @@ export class ActionResolver<
|
||||
addMetaValue(meta, 'picture', resolvedCandidate.picture ?? null);
|
||||
addMetaValue(meta, 'text', resolvedCandidate.text ?? null);
|
||||
|
||||
const newGeneral = buildRecruitmentGeneral<TriggerState>({
|
||||
id: newGeneralId,
|
||||
name,
|
||||
nationId: 0,
|
||||
cityId,
|
||||
stats,
|
||||
officerLevel: 0,
|
||||
age,
|
||||
npcState: NPC_TYPE,
|
||||
gold: this.env.defaultNpcGold,
|
||||
rice: this.env.defaultNpcRice,
|
||||
experience: age * 100,
|
||||
dedication: age * 100,
|
||||
crewTypeId: this.env.defaultCrewTypeId,
|
||||
role: {
|
||||
personality,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
meta,
|
||||
});
|
||||
const newGeneral = {
|
||||
...buildRecruitmentGeneral<TriggerState>({
|
||||
id: newGeneralId,
|
||||
name,
|
||||
nationId: 0,
|
||||
cityId,
|
||||
stats,
|
||||
officerLevel: 0,
|
||||
age,
|
||||
npcState: NPC_TYPE,
|
||||
gold: this.env.defaultNpcGold,
|
||||
rice: this.env.defaultNpcRice,
|
||||
experience: age * 100,
|
||||
dedication: age * 100,
|
||||
crewTypeId: this.env.defaultCrewTypeId,
|
||||
role: {
|
||||
personality,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
meta,
|
||||
}),
|
||||
turnTime,
|
||||
bornYear: birthYear,
|
||||
deadYear: deathYear,
|
||||
affinity,
|
||||
};
|
||||
|
||||
const recruitVerb = '발견';
|
||||
const nameRa = JosaUtil.pick(name, '라');
|
||||
@@ -495,6 +579,10 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
|
||||
...base,
|
||||
currentYear: options.world.currentYear,
|
||||
currentMonth: options.world.currentMonth,
|
||||
retirementYear:
|
||||
typeof options.scenarioConfig.const.retirementYear === 'number'
|
||||
? options.scenarioConfig.const.retirementYear
|
||||
: 80,
|
||||
worldSummary: {
|
||||
...buildWorldSummary(options.worldRef),
|
||||
averageDex: (() => {
|
||||
@@ -511,9 +599,18 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
|
||||
) as [number, number, number, number, number];
|
||||
})(),
|
||||
},
|
||||
cityPool: options.worldRef?.listCities() ?? [],
|
||||
// Legacy CityHelper::getAllCities() is consumed in primary-key order.
|
||||
cityPool: [...(options.worldRef?.listCities() ?? [])].sort((left, right) => left.id - right.id),
|
||||
// Core keeps the initial scenario's display prefix separate from `name`,
|
||||
// while Ref persists it in `general.name`. Reconstruct it before executing
|
||||
// AbsGeneralPool::checkDuplicatedCnt semantics; the duplicated ⓝ prefix in
|
||||
// GeneralBuilder::$prefixList intentionally counts NPC matches twice.
|
||||
existingGeneralNames: options.worldRef?.listGenerals().map(restoreLegacyStoredName) ?? [],
|
||||
createGeneralId: options.createGeneralId,
|
||||
turnTermMinutes: Math.max(1, Math.round(options.world.tickSeconds / 60)),
|
||||
// GeneralBuilder::build() derives a new NPC turn from gameStor.turntime,
|
||||
// not from the scout's own reserved-turn timestamp.
|
||||
turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime,
|
||||
});
|
||||
|
||||
export const commandSpec: GeneralTurnCommandSpec = {
|
||||
|
||||
@@ -39,6 +39,7 @@ const CONFIG: InvestmentConfig = {
|
||||
frontDebuff: 1,
|
||||
useCityTrust: false,
|
||||
scaleSuccessByTrust: false,
|
||||
costMultiplier: 2,
|
||||
};
|
||||
|
||||
export class ActionDefinition<
|
||||
@@ -49,11 +50,7 @@ export class ActionDefinition<
|
||||
private readonly command: CommandResolver<TriggerState>;
|
||||
|
||||
constructor(env: TurnCommandEnv) {
|
||||
this.command = new CommandResolver(
|
||||
env.generalActionModules ?? [],
|
||||
{ ...env, develCost: env.develCost * 2 },
|
||||
CONFIG
|
||||
);
|
||||
this.command = new CommandResolver(env.generalActionModules ?? [], env, CONFIG);
|
||||
}
|
||||
|
||||
parseArgs(_raw: unknown): SettlementArgs | null {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from './che_상업투자.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import { clamp } from 'es-toolkit';
|
||||
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
|
||||
|
||||
export interface TrustActionArgs {}
|
||||
|
||||
@@ -40,26 +41,21 @@ const CONFIG: InvestmentConfig = {
|
||||
useCityTrust: false,
|
||||
scaleSuccessByTrust: false,
|
||||
roundCriticalScore: false,
|
||||
costMultiplier: 2,
|
||||
};
|
||||
|
||||
const readTrust = (city: City): number => {
|
||||
const trust = city.meta.trust;
|
||||
return typeof trust === 'number' && Number.isFinite(trust) ? trust : DEFAULT_TRUST;
|
||||
return typeof trust === 'number' && Number.isFinite(trust) ? readLegacyCityTrust(trust) : DEFAULT_TRUST;
|
||||
};
|
||||
|
||||
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
|
||||
// 재조회된다. 같은 턴의 후속 명령도 그 저장 경계를 보도록 정규화한다.
|
||||
const toLegacyStoredTrust = (value: number): number => Number(Math.fround(value).toPrecision(6));
|
||||
|
||||
const remainCityTrust = (): Constraint => ({
|
||||
name: 'remainCityTrust',
|
||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
||||
test: (ctx, view) => {
|
||||
const city = ctx.cityId !== undefined ? (view.get({ kind: 'city', id: ctx.cityId }) as City | null) : null;
|
||||
if (!city) return { kind: 'deny', reason: '도시 정보가 없습니다.' };
|
||||
return readTrust(city) >= 100
|
||||
? { kind: 'deny', reason: '주민 선정은 충분합니다.' }
|
||||
: { kind: 'allow' };
|
||||
return readTrust(city) >= 100 ? { kind: 'deny', reason: '주민 선정은 충분합니다.' } : { kind: 'allow' };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,11 +67,7 @@ export class ActionDefinition<
|
||||
private readonly command: CommandResolver<TriggerState>;
|
||||
|
||||
constructor(env: TurnCommandEnv) {
|
||||
this.command = new CommandResolver(
|
||||
env.generalActionModules ?? [],
|
||||
{ ...env, develCost: env.develCost * 2 },
|
||||
CONFIG
|
||||
);
|
||||
this.command = new CommandResolver(env.generalActionModules ?? [], env, CONFIG);
|
||||
}
|
||||
|
||||
parseArgs(_raw: unknown): TrustActionArgs | null {
|
||||
@@ -113,7 +105,7 @@ export class ActionDefinition<
|
||||
const trustDelta = result.score / 10;
|
||||
context.city.meta = {
|
||||
...context.city.meta,
|
||||
trust: toLegacyStoredTrust(clamp(readTrust(context.city) + trustDelta, 0, 100)),
|
||||
trust: storeLegacyCityTrust(clamp(readTrust(context.city) + trustDelta, 0, 100)),
|
||||
};
|
||||
context.general.rice = Math.max(0, context.general.rice - result.costGold);
|
||||
context.general.experience += result.exp;
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
isCrewTypeAvailable,
|
||||
} from '@sammo-ts/logic/world/unitSet.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
|
||||
import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js';
|
||||
|
||||
export interface RecruitEnvironment {
|
||||
costOffset?: number;
|
||||
@@ -58,6 +60,13 @@ const DEFAULT_MIN_POP = 30000;
|
||||
const DEFAULT_TRUST = 50;
|
||||
const MIN_CREW = 100;
|
||||
|
||||
// PHP round() compensates for the small binary drift around a half boundary.
|
||||
// Ref then converts the result to int through Util::round().
|
||||
export const roundLegacyRecruitCost = (value: number): number => {
|
||||
const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value));
|
||||
return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5);
|
||||
};
|
||||
|
||||
export const ARGS_SCHEMA = z.preprocess(
|
||||
(raw) => {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
@@ -114,10 +123,6 @@ const readCityTrust = (city: City, fallback: number): number => {
|
||||
return typeof trust === 'number' ? trust : fallback;
|
||||
};
|
||||
|
||||
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
|
||||
// 재조회된다. 메모리 상태도 같은 persistence 경계로 정규화한다.
|
||||
const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6));
|
||||
|
||||
const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => {
|
||||
const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0;
|
||||
return { ...meta, [key]: current + delta };
|
||||
@@ -223,8 +228,9 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
||||
}
|
||||
|
||||
resolveLeadership(context: RecruitCalcContext<TriggerState>): number {
|
||||
const base = context.general.stats.leadership;
|
||||
return Math.round(this.pipeline.onCalcStat(context, 'leadership', base));
|
||||
const general = context.general;
|
||||
const base = applyLegacyInjury(general.stats.leadership, general.injury);
|
||||
return finalizeLegacyStat(this.pipeline.onCalcStat(context, 'leadership', base));
|
||||
}
|
||||
|
||||
resolveCrewPlan(
|
||||
@@ -268,7 +274,7 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
||||
crewType ? { armType: crewType.armType } : undefined
|
||||
);
|
||||
return {
|
||||
gold: Math.round(adjustedGold * costOffset),
|
||||
gold: roundLegacyRecruitCost(adjustedGold * costOffset),
|
||||
rice: Math.round(adjustedRice),
|
||||
applied: plan.applied,
|
||||
requested: plan.requested,
|
||||
@@ -302,6 +308,14 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
||||
return Math.round(base);
|
||||
}
|
||||
|
||||
getStatGain(
|
||||
context: RecruitCalcContext<TriggerState>,
|
||||
statName: 'experience' | 'dedication',
|
||||
amount: number
|
||||
): number {
|
||||
return this.pipeline.onCalcStat(context, statName, amount);
|
||||
}
|
||||
|
||||
getDexGain(
|
||||
context: RecruitCalcContext<TriggerState>,
|
||||
armType: number,
|
||||
@@ -374,9 +388,9 @@ export class ActionResolver<
|
||||
const costOffset = this.env.costOffset ?? DEFAULT_COST_OFFSET;
|
||||
const recruitPop = this.command.getRecruitPopulation(context, appliedCrew);
|
||||
const nextPopulation = Math.max(city.population - recruitPop, 0);
|
||||
const baseTrust = readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST);
|
||||
const baseTrust = readLegacyCityTrust(readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST));
|
||||
const trustLoss = city.population > 0 ? (recruitPop / city.population / costOffset) * 100 : 0;
|
||||
const nextTrust = toLegacyStoredTrust(Math.max(baseTrust - trustLoss, 0));
|
||||
const nextTrust = storeLegacyCityTrust(Math.max(baseTrust - trustLoss, 0));
|
||||
|
||||
const actionName = this.env.actionName ?? ACTION_NAME;
|
||||
const [nextCrewTypeId, nextCrew, nextTrain, nextAtmos] =
|
||||
@@ -405,8 +419,13 @@ export class ActionResolver<
|
||||
|
||||
const nextGold = Math.max(0, general.gold - plan.gold);
|
||||
const nextRice = Math.max(0, general.rice - plan.rice);
|
||||
const expGain = Math.round(appliedCrew / 100);
|
||||
const dedGain = Math.round(appliedCrew / 100);
|
||||
// Ref rounds the raw crew-based reward first and then routes both
|
||||
// General::addExperience()/addDedication() through onCalcStat(). This
|
||||
// is observable for every successful recruit once seasonal rice pay
|
||||
// lets NPCs afford the command (personality fame modifiers are +/-10%).
|
||||
const baseStatGain = Math.round(appliedCrew / 100);
|
||||
const expGain = this.command.getStatGain(context, 'experience', baseStatGain);
|
||||
const dedGain = this.command.getStatGain(context, 'dedication', baseStatGain);
|
||||
const dexGain = this.command.getDexGain(context, crewType.armType, appliedCrew);
|
||||
|
||||
// 직접 수정 (Immer Draft)
|
||||
@@ -425,6 +444,12 @@ export class ActionResolver<
|
||||
general.experience += expGain;
|
||||
general.dedication += dedGain;
|
||||
general.meta = addMetaNumber(general.meta, 'leadership_exp', 1);
|
||||
// Ref persists the selected arm category in General.aux. GeneralAI
|
||||
// reuses it for the next recruitment instead of drawing a new category.
|
||||
general.meta = {
|
||||
...general.meta,
|
||||
armType: crewType.armType,
|
||||
};
|
||||
if (dexGain) {
|
||||
general.meta = addMetaNumber(general.meta, dexGain.key, dexGain.amount);
|
||||
}
|
||||
|
||||
@@ -33,10 +33,12 @@ import { resolveWarBattle } from '@sammo-ts/logic/war/engine.js';
|
||||
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
|
||||
import type { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
|
||||
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { increaseMetaNumber, simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import { buildNationFrontStatePatches } from '../../../diplomacy/frontState.js';
|
||||
import { formatDestCityConstraintFailure } from '../constraintFailure.js';
|
||||
import {
|
||||
buildWarAftermathConfig,
|
||||
@@ -55,7 +57,7 @@ export interface DispatchResolveContext<
|
||||
generals: General<TriggerState>[];
|
||||
unitSet: UnitSetDefinition;
|
||||
map?: MapDefinition;
|
||||
diplomacy?: Array<{ fromNationId: number; toNationId: number; state: number }>;
|
||||
diplomacy?: Array<{ fromNationId: number; toNationId: number; state: number; term: number }>;
|
||||
time: WarTimeContext;
|
||||
seedBase: string;
|
||||
warConfig: WarEngineConfig;
|
||||
@@ -172,23 +174,19 @@ const pickCandidateCity = (
|
||||
if (minDist === undefined) {
|
||||
return null;
|
||||
}
|
||||
const candidates: Array<[number, number]> = [];
|
||||
for (const dist of distances) {
|
||||
if (dist > minDist + 1) {
|
||||
break;
|
||||
}
|
||||
for (const entry of distanceList.get(dist) ?? []) {
|
||||
if (entry[1] !== attackerNationId) {
|
||||
candidates.push(entry);
|
||||
}
|
||||
const candidates = (distanceList.get(dist) ?? []).filter(([, nationId]) => nationId !== attackerNationId);
|
||||
if (candidates.length > 0) {
|
||||
// Ref breaks at the first distance layer containing an enemy. It
|
||||
// only considers minDist + 1 when the minDist layer has none.
|
||||
// RandUtil::choice() still consumes nextInt(0) for one candidate.
|
||||
const [cityId] = pickLegacyChoice(candidates);
|
||||
return { cityId, isEnemy: true, minDist };
|
||||
}
|
||||
}
|
||||
if (candidates.length > 0) {
|
||||
// Legacy RandUtil::choice() consumes nextInt(0) even when there is a
|
||||
// single candidate. Keep that observable RNG step for seed parity.
|
||||
const [cityId] = pickLegacyChoice(candidates);
|
||||
return { cityId, isEnemy: true, minDist };
|
||||
}
|
||||
const fallback = distanceList.get(minDist) ?? [];
|
||||
const friendly = fallback.filter(([, nationId]) => nationId === attackerNationId);
|
||||
if (friendly.length === 0) {
|
||||
@@ -257,6 +255,7 @@ export class ActionDefinition<
|
||||
private readonly warModules: ReadonlyArray<WarActionModule<TriggerState>>;
|
||||
private readonly nationTraitModules: Map<string, NationTraitModule>;
|
||||
private readonly generalModules: ReadonlyArray<GeneralActionModule<TriggerState>>;
|
||||
private readonly generalPipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(
|
||||
modules: ReadonlyArray<WarActionModule<TriggerState> | null | undefined> = [],
|
||||
@@ -266,6 +265,7 @@ export class ActionDefinition<
|
||||
this.warModules = modules.filter(Boolean) as ReadonlyArray<WarActionModule<TriggerState>>;
|
||||
this.nationTraitModules = new Map(nationTraitModules.map((module) => [module.key, module]));
|
||||
this.generalModules = generalModules.filter(Boolean) as ReadonlyArray<GeneralActionModule<TriggerState>>;
|
||||
this.generalPipeline = new GeneralActionPipeline(this.generalModules);
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): DispatchArgs | null {
|
||||
@@ -416,7 +416,16 @@ export class ActionDefinition<
|
||||
|
||||
const armType = resolveCrewTypeArm(unitSet, context.general.crewTypeId);
|
||||
if (armType !== null) {
|
||||
increaseMetaNumber(context.general.meta, `dex${armType}`, context.general.crew / 100);
|
||||
const typeMultiplier = armType === 4 || armType === 5 ? 0.9 : 1;
|
||||
const amount = this.generalPipeline.onCalcStat(
|
||||
context,
|
||||
'addDex',
|
||||
(context.general.crew / 100) * typeMultiplier,
|
||||
{ armType }
|
||||
);
|
||||
const dexKey = `dex${armType}`;
|
||||
const currentDex = context.general.meta[dexKey];
|
||||
context.general.meta[dexKey] = (typeof currentDex === 'number' ? currentDex : 0) + amount;
|
||||
}
|
||||
|
||||
const cities = context.cities.map(cloneCity);
|
||||
@@ -441,6 +450,10 @@ export class ActionDefinition<
|
||||
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 shouldTraceWar =
|
||||
traceGeneralIds.has(String(context.general.id)) ||
|
||||
defenderGenerals.some((general) => traceGeneralIds.has(String(general.id)));
|
||||
|
||||
const battle = resolveWarBattle({
|
||||
seed,
|
||||
@@ -461,6 +474,15 @@ export class ActionDefinition<
|
||||
})),
|
||||
defenderCity,
|
||||
defenderNation,
|
||||
...(shouldTraceWar
|
||||
? {
|
||||
trace: (event) => {
|
||||
process.stdout.write(
|
||||
`AI_WAR_TRACE ${JSON.stringify({ generalId: context.general.id, event })}\n`
|
||||
);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const aftermath = resolveWarAftermath({
|
||||
@@ -486,6 +508,36 @@ export class ActionDefinition<
|
||||
},
|
||||
});
|
||||
|
||||
// Ref ConquerCity() recalculates the fronts of every nation around the
|
||||
// captured city immediately. Later generals in the same monthly due
|
||||
// list therefore observe those refreshed values when choosing whether
|
||||
// to deploy. Preserve that ordering before snapshotting city effects.
|
||||
let frontStatePatches: Array<{ id: number; frontState: number }> = [];
|
||||
if (battle.conquered && context.map && context.diplomacy) {
|
||||
const connections = new Map(
|
||||
context.map.cities.map((city) => [city.id, city.connections ?? []] as const)
|
||||
);
|
||||
const nearbyCityIds = new Set([defenderCity.id, ...(connections.get(defenderCity.id) ?? [])]);
|
||||
const nearbyNationIds = new Set<number>([aftermath.conquest?.conquerNationId ?? attackerNation.id]);
|
||||
for (const city of cities) {
|
||||
if (nearbyCityIds.has(city.id) && city.nationId > 0) {
|
||||
nearbyNationIds.add(city.nationId);
|
||||
}
|
||||
}
|
||||
frontStatePatches = buildNationFrontStatePatches({
|
||||
cities,
|
||||
diplomacy: context.diplomacy,
|
||||
connections,
|
||||
nationIds: [...nearbyNationIds],
|
||||
});
|
||||
for (const patch of frontStatePatches) {
|
||||
const city = cities.find((candidate) => candidate.id === patch.id);
|
||||
if (city) {
|
||||
city.frontState = patch.frontState;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
||||
|
||||
for (const entry of battle.logs) {
|
||||
@@ -541,6 +593,9 @@ export class ActionDefinition<
|
||||
for (const [id, patch] of cityPatches) {
|
||||
effects.push(createCityPatchEffect(patch, id));
|
||||
}
|
||||
for (const patch of frontStatePatches) {
|
||||
effects.push(createCityPatchEffect({ frontState: patch.frontState }, patch.id));
|
||||
}
|
||||
for (const [id, patch] of nationPatches) {
|
||||
effects.push(createNationPatchEffect(patch, id));
|
||||
}
|
||||
@@ -555,7 +610,12 @@ export class ActionDefinition<
|
||||
|
||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||
|
||||
return { effects };
|
||||
return {
|
||||
effects,
|
||||
...(aftermath.conquest?.ruinedNpcJoinPlans.length
|
||||
? { reservedGeneralTurnPlans: aftermath.conquest.ruinedNpcJoinPlans }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,6 +636,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
const diplomacy = options.worldRef.listDiplomacy();
|
||||
const warConfig = buildWarConfig(options.scenarioConfig, options.unitSet);
|
||||
const aftermathConfig = buildWarAftermathConfig(options.scenarioConfig, warConfig.castleCrewTypeId);
|
||||
const joinModeRaw = options.world.meta?.join_mode ?? options.world.meta?.joinMode;
|
||||
aftermathConfig.joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full';
|
||||
return {
|
||||
...base,
|
||||
destCity,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
|
||||
const ACTION_NAME = '헌납';
|
||||
const ACTION_KEY = 'che_헌납';
|
||||
@@ -36,6 +37,8 @@ export class ActionResolver<
|
||||
> implements GeneralActionResolver<TriggerState, DonateArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
|
||||
constructor(private readonly pipeline: GeneralActionPipeline<TriggerState> = new GeneralActionPipeline([])) {}
|
||||
|
||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: DonateArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
const nation = context.nation;
|
||||
@@ -51,8 +54,8 @@ export class ActionResolver<
|
||||
|
||||
const realAmount = Math.max(0, Math.min(amount, currentRes));
|
||||
|
||||
const exp = 70;
|
||||
const ded = 100;
|
||||
const exp = this.pipeline.onCalcStat(context, 'experience', 70);
|
||||
const ded = this.pipeline.onCalcStat(context, 'dedication', 100);
|
||||
|
||||
const amountText = realAmount.toLocaleString();
|
||||
context.addLog(`${resName} <C>${amountText}</>을 헌납했습니다.`, {
|
||||
@@ -98,7 +101,9 @@ export class ActionDefinition<
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor(private readonly env: TurnCommandEnv) {
|
||||
this.resolver = new ActionResolver();
|
||||
this.resolver = new ActionResolver<TriggerState>(
|
||||
new GeneralActionPipeline<TriggerState>(env.generalActionModules ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): DonateArgs | null {
|
||||
|
||||
@@ -13,6 +13,8 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js';
|
||||
|
||||
export interface TrainingArgs {}
|
||||
|
||||
@@ -21,6 +23,7 @@ export interface TrainingEnvironment {
|
||||
maxTrainByCommand?: number;
|
||||
costGold?: number;
|
||||
unitSet?: TurnCommandEnv['unitSet'];
|
||||
generalActionModules?: TurnCommandEnv['generalActionModules'];
|
||||
}
|
||||
|
||||
const ACTION_NAME = '훈련';
|
||||
@@ -32,9 +35,11 @@ export class ActionDefinition<
|
||||
public readonly key = 'che_훈련';
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly env: TrainingEnvironment;
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(env: TrainingEnvironment = {}) {
|
||||
this.env = env;
|
||||
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||
}
|
||||
|
||||
parseArgs(_raw: unknown): TrainingArgs | null {
|
||||
@@ -70,10 +75,13 @@ export class ActionDefinition<
|
||||
? this.env.maxTrainByCommand
|
||||
: DEFAULT_MAX_TRAIN;
|
||||
const trainDelta = this.env.trainDelta && this.env.trainDelta > 0 ? this.env.trainDelta : 0;
|
||||
const leadership = finalizeLegacyStat(
|
||||
this.pipeline.onCalcStat(context, 'leadership', applyLegacyInjury(general.stats.leadership, general.injury))
|
||||
);
|
||||
const score = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.round((general.stats.leadership * 100 * trainDelta) / Math.max(general.crew, 1)),
|
||||
Math.round((leadership * 100 * trainDelta) / Math.max(general.crew, 1)),
|
||||
maxTrain - general.train
|
||||
)
|
||||
);
|
||||
@@ -81,15 +89,23 @@ export class ActionDefinition<
|
||||
|
||||
general.train += score;
|
||||
general.gold = Math.max(0, general.gold - costGold);
|
||||
general.experience += 100;
|
||||
general.dedication += 70;
|
||||
general.experience += this.pipeline.onCalcStat(context, 'experience', 100);
|
||||
general.dedication += this.pipeline.onCalcStat(context, 'dedication', 70);
|
||||
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
|
||||
general.meta.leadership_exp = leadershipExp + 1;
|
||||
const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId);
|
||||
if (crewType) {
|
||||
const dexKey = `dex${crewType.armType}`;
|
||||
const armType = crewType.armType === 0 ? 5 : crewType.armType;
|
||||
if (armType < 0) {
|
||||
context.addLog(`훈련치가 <C>${score.toLocaleString()}</> 상승했습니다.`);
|
||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||
return { effects: [] };
|
||||
}
|
||||
const dexKey = `dex${armType}`;
|
||||
const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0;
|
||||
general.meta[dexKey] = dex + score;
|
||||
const typeMultiplier = armType === 4 || armType === 5 ? 0.9 : 1;
|
||||
general.meta[dexKey] =
|
||||
dex + this.pipeline.onCalcStat(context, 'addDex', score * typeMultiplier, { armType });
|
||||
}
|
||||
|
||||
context.addLog(`훈련치가 <C>${score.toLocaleString()}</> 상승했습니다.`);
|
||||
@@ -112,5 +128,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
trainDelta: env.trainDelta,
|
||||
maxTrainByCommand: env.maxTrainByCommand,
|
||||
unitSet: env.unitSet,
|
||||
generalActionModules: env.generalActionModules,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js';
|
||||
|
||||
const ACTION_NAME = '맹훈련';
|
||||
const ACTION_KEY = 'cr_맹훈련';
|
||||
@@ -71,7 +72,9 @@ export class ActionDefinition<
|
||||
const maxTrain = this.env.maxTrainByCommand > 0 ? this.env.maxTrainByCommand : 100;
|
||||
const maxAtmos = this.env.maxAtmosByCommand > 0 ? this.env.maxAtmosByCommand : 100;
|
||||
|
||||
const leadership = this.pipeline.onCalcStat(context, 'leadership', general.stats.leadership);
|
||||
const leadership = finalizeLegacyStat(
|
||||
this.pipeline.onCalcStat(context, 'leadership', applyLegacyInjury(general.stats.leadership, general.injury))
|
||||
);
|
||||
const score = Math.round((leadership * 100 * trainDelta * 2) / (Math.max(general.crew, 1) * 3));
|
||||
const scoreText = score.toLocaleString('en-US');
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* MariaDB stores city.trust as FLOAT (binary32), while the PHP driver exposes
|
||||
* the value rounded to six significant decimal digits on the next read.
|
||||
* Keep those boundaries separate so later SQL expressions use binary32 state.
|
||||
*/
|
||||
export const storeLegacyCityTrust = (value: number): number => Math.fround(value);
|
||||
|
||||
export const readLegacyCityTrust = (value: number): number => Number(Math.fround(value).toPrecision(6));
|
||||
@@ -0,0 +1,3 @@
|
||||
export const applyLegacyInjury = (value: number, injury: number): number => value * ((100 - injury) / 100);
|
||||
|
||||
export const finalizeLegacyStat = (value: number): number => Math.trunc(value);
|
||||
@@ -0,0 +1,10 @@
|
||||
// MariaDB FLOAT stores binary32, but its text protocol exposes only six
|
||||
// significant decimal digits. Ref reads that text into PHP before every
|
||||
// command/battle update, so both boundaries are part of the game state.
|
||||
export const toLegacyStoredFloat = (value: number): number => Math.fround(value);
|
||||
|
||||
export const readLegacyStoredFloat = (value: number): number =>
|
||||
Number(Math.fround(value).toPrecision(6));
|
||||
|
||||
export const addLegacyStoredFloat = (current: number, delta: number): number =>
|
||||
toLegacyStoredFloat(readLegacyStoredFloat(current) + delta);
|
||||
@@ -18,7 +18,12 @@ const resolveCityGenerals = <TriggerState extends GeneralTriggerState>(
|
||||
const list = worldView.listGeneralsByCity
|
||||
? worldView.listGeneralsByCity(general.cityId)
|
||||
: worldView.listGenerals().filter((candidate) => candidate.cityId === general.cityId);
|
||||
return list.filter((candidate) => candidate.id !== general.id);
|
||||
// Ref reads patients from the primary-key-backed `general` table. Make
|
||||
// that observed order explicit so the 50% draws stay attached to the
|
||||
// same general even when Core's in-memory insertion order differs.
|
||||
return list
|
||||
.filter((candidate) => candidate.id !== general.id)
|
||||
.sort((left, right) => left.id - right.id);
|
||||
};
|
||||
|
||||
// 의술 특기의 도시 치료 트리거.
|
||||
|
||||
@@ -50,8 +50,11 @@ const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport)
|
||||
|
||||
const getDeadCounter = (city: City): number => getMetaNumber(city.meta, META_DEAD, 0);
|
||||
|
||||
const setDeadCounter = (city: City, value: number): void => {
|
||||
city.meta[META_DEAD] = round(value);
|
||||
const increaseDeadCounter = (city: City, delta: number): void => {
|
||||
// Ref binds each `dead + %i` increment as an integer before MariaDB adds
|
||||
// it. Truncate each 40/60 percent split independently; rounding the
|
||||
// accumulated counter changes monthly recovery and war income.
|
||||
city.meta[META_DEAD] = getDeadCounter(city) + Math.trunc(delta);
|
||||
};
|
||||
|
||||
const isSupplyCity = (city: City): boolean => {
|
||||
@@ -122,11 +125,16 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
|
||||
}
|
||||
|
||||
const divisor = Math.max(config.initialNationGenLimit, total);
|
||||
const tech = getMetaNumber(nation.meta, 'tech', 0) + gain / divisor;
|
||||
// Legacy MySQL FLOAT values are read back at the command boundary with
|
||||
// two-decimal precision. Preserve fractional accumulation without
|
||||
// converting the gain to an integer.
|
||||
nation.meta.tech = Math.round(tech * 100) / 100;
|
||||
const currentTech = getMetaNumber(nation.meta, 'tech', 0);
|
||||
const delta = gain / divisor;
|
||||
// Ref executes `tech + delta` inside MariaDB for battle gains, so the
|
||||
// arithmetic starts from the stored binary32 value without a PHP text read.
|
||||
nation.meta.tech = Math.fround(currentTech + delta);
|
||||
if ((process.env.CORE_WAR_TECH_TRACE_NATION_IDS?.split(',') ?? []).includes(String(nation.id))) {
|
||||
process.stdout.write(
|
||||
`WAR_TECH_TRACE ${JSON.stringify({ engine: 'core', nationId: nation.id, side: context.side, currentTech, baseGain, gain, total, effective, divisor, delta, storedTech: nation.meta.tech, attackerGeneralId: context.attackerReport.id })}\n`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => {
|
||||
@@ -247,10 +255,22 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
|
||||
let collapseRewardGold = 0;
|
||||
let collapseRewardRice = 0;
|
||||
const ruinedNpcJoinPlans: ConquerCityOutcome<TriggerState>['ruinedNpcJoinPlans'] = [];
|
||||
|
||||
// 국가 붕괴 시 자원 손실과 포상 정산.
|
||||
if (nationCollapsed && defenderNation) {
|
||||
const defenderGenerals = generals.filter((general) => general.nationId === defenderNationId);
|
||||
const defenderGenerals = generals
|
||||
.filter((general) => general.nationId === defenderNationId)
|
||||
.sort((lhs, rhs) => {
|
||||
// deleteNation() reads the non-lord rows in primary-key order,
|
||||
// then appends the lord object to the returned PHP array.
|
||||
const lhsIsLord = lhs.id === defenderNation.chiefGeneralId;
|
||||
const rhsIsLord = rhs.id === defenderNation.chiefGeneralId;
|
||||
if (lhsIsLord !== rhsIsLord) {
|
||||
return lhsIsLord ? 1 : -1;
|
||||
}
|
||||
return lhs.id - rhs.id;
|
||||
});
|
||||
let totalGoldLoss = 0;
|
||||
let totalRiceLoss = 0;
|
||||
|
||||
@@ -290,6 +310,21 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
);
|
||||
pushLoggers([generalLogger], logs);
|
||||
affectedGenerals.add(general);
|
||||
|
||||
if (config.joinMode !== 'onlyRandom') {
|
||||
// Ref attempts to build/send a scout message after every loss.
|
||||
// Message availability does not affect this draw.
|
||||
rng.nextBool(0.5);
|
||||
|
||||
const eligibleNpc = general.npcState >= 2 && general.npcState <= 8 && general.npcState !== 5;
|
||||
if (eligibleNpc && rng.nextBool(config.joinRuinedNpcProbability ?? 0.1)) {
|
||||
ruinedNpcJoinPlans.push({
|
||||
generalId: general.id,
|
||||
destNationId: attackerNation.id,
|
||||
joinTurn: rng.nextRangeInt(0, 12),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collapseRewardGold = Math.floor((Math.max(0, defenderNation.gold - config.baseGold) + totalGoldLoss) / 2);
|
||||
@@ -426,6 +461,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
nations: Array.from(affectedNations),
|
||||
cities: Array.from(affectedCities),
|
||||
generals: Array.from(affectedGenerals),
|
||||
ruinedNpcJoinPlans,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -447,10 +483,8 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
|
||||
|
||||
// 전투 사망자 누적: 공격/수비 도시로 분배.
|
||||
if (totalDead > 0) {
|
||||
const attackerCityDead = getDeadCounter(input.attackerCity) + totalDead * 0.4;
|
||||
const defenderCityDead = getDeadCounter(input.defenderCity) + totalDead * 0.6;
|
||||
setDeadCounter(input.attackerCity, attackerCityDead);
|
||||
setDeadCounter(input.defenderCity, defenderCityDead);
|
||||
increaseDeadCounter(input.attackerCity, totalDead * 0.4);
|
||||
increaseDeadCounter(input.defenderCity, totalDead * 0.6);
|
||||
affectedCities.add(input.attackerCity);
|
||||
affectedCities.add(input.defenderCity);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
|
||||
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
@@ -19,10 +19,7 @@ import type {
|
||||
import { getMetaNumber } from './utils.js';
|
||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.js';
|
||||
|
||||
const META_FULL_LEADERSHIP = 'fullLeadership';
|
||||
const META_FULL_STRENGTH = 'fullStrength';
|
||||
const META_FULL_INTELLIGENCE = 'fullIntelligence';
|
||||
const META_DEFENCE_TRAIN = 'defenceTrain';
|
||||
const META_DEFENCE_TRAIN = 'defence_train';
|
||||
|
||||
const defaultLoggerFactory = (options: { generalId?: number; nationId?: number }): ActionLogger =>
|
||||
new ActionLogger(options);
|
||||
@@ -101,18 +98,6 @@ const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry):
|
||||
return caller;
|
||||
};
|
||||
|
||||
const resolveFullStats = (
|
||||
general: General
|
||||
): {
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intelligence: number;
|
||||
} => ({
|
||||
leadership: getMetaNumber(general.meta, META_FULL_LEADERSHIP, general.stats.leadership),
|
||||
strength: getMetaNumber(general.meta, META_FULL_STRENGTH, general.stats.strength),
|
||||
intelligence: getMetaNumber(general.meta, META_FULL_INTELLIGENCE, general.stats.intelligence),
|
||||
});
|
||||
|
||||
const isSupplyCity = (city: City): boolean => {
|
||||
const supply = city.meta.supply;
|
||||
if (typeof supply === 'boolean') {
|
||||
@@ -153,9 +138,17 @@ export const computeBattleOrder = <TriggerState extends GeneralTriggerState>(
|
||||
return 0;
|
||||
}
|
||||
|
||||
const realStat = general.stats.leadership + general.stats.strength + general.stats.intelligence;
|
||||
const fullStats = resolveFullStats(general);
|
||||
const fullStat = fullStats.leadership + fullStats.strength + fullStats.intelligence;
|
||||
// Ref calls General::getLeadership/Strength/Intel() for every defender at
|
||||
// battle time. That applies injury, stat cross-adjustment, officer/items/
|
||||
// traits and integer conversion through the defender's action pipeline.
|
||||
const realStat =
|
||||
defender.getComputedStat('leadership', general.stats.leadership) +
|
||||
defender.getComputedStat('strength', general.stats.strength) +
|
||||
defender.getComputedStat('intelligence', general.stats.intelligence);
|
||||
const fullStat =
|
||||
defender.getComputedStat('leadership', general.stats.leadership, { withInjury: false }) +
|
||||
defender.getComputedStat('strength', general.stats.strength, { withInjury: false }) +
|
||||
defender.getComputedStat('intelligence', general.stats.intelligence, { withInjury: false });
|
||||
const totalStat = (realStat + fullStat) / 2;
|
||||
|
||||
const totalCrew = (general.crew / 1_000_000) * Math.pow(general.train * general.atmos, 1.5);
|
||||
@@ -330,6 +323,12 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
defenderUnits.push(cityUnit);
|
||||
}
|
||||
|
||||
const summarizeDefenderOrder = (unit: WarUnit<TriggerState>) => ({
|
||||
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : 0,
|
||||
order: computeBattleOrder<TriggerState>(unit, attackerUnit),
|
||||
});
|
||||
const defenderOrderBeforeSort = defenderUnits.map(summarizeDefenderOrder);
|
||||
|
||||
defenderUnits.sort(
|
||||
(lhs, rhs) =>
|
||||
computeBattleOrder<TriggerState>(rhs, attackerUnit) - computeBattleOrder<TriggerState>(lhs, attackerUnit)
|
||||
@@ -373,6 +372,10 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
details,
|
||||
});
|
||||
};
|
||||
emitTrace('defender_order', defender, {
|
||||
before: defenderOrderBeforeSort,
|
||||
after: defenderUnits.map(summarizeDefenderOrder),
|
||||
});
|
||||
emitTrace('battle_start', defender, { seed: input.seed ?? '' });
|
||||
|
||||
const attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN';
|
||||
@@ -399,7 +402,10 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
defender = cityUnit;
|
||||
cityUnit.setSiege();
|
||||
|
||||
const defenderRice = input.defenderNation?.rice ?? 0;
|
||||
// Ref builds a virtual neutral nation with 10,000 rice. Treating a
|
||||
// neutral city's missing Nation row as zero skips the entire siege
|
||||
// through the supply-retreat branch and changes every later war.
|
||||
const defenderRice = input.defenderNation?.rice ?? 10_000;
|
||||
if (isSupplyCity(input.defenderCity) && defenderRice <= 0) {
|
||||
attackerUnit.setOppose(defender);
|
||||
defender.setOppose(attackerUnit);
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface WarEngineConfig {
|
||||
maxTrainByWar: number;
|
||||
maxAtmosByWar: number;
|
||||
maxGeneralStat?: number;
|
||||
statUpgradeLimit?: number;
|
||||
castleCrewTypeId: number;
|
||||
armTypes: WarArmTypes;
|
||||
}
|
||||
@@ -138,6 +139,16 @@ export interface WarAftermathConfig {
|
||||
baseGold: number;
|
||||
baseRice: number;
|
||||
castleCrewTypeId: number;
|
||||
/** Legacy admin join mode. Ruined-nation scout/join draws are skipped in onlyRandom mode. */
|
||||
joinMode?: 'full' | 'onlyRandom';
|
||||
/** Probability that an eligible ruined NPC reserves a future appointment. */
|
||||
joinRuinedNpcProbability?: number;
|
||||
}
|
||||
|
||||
export interface RuinedNpcJoinPlan {
|
||||
generalId: number;
|
||||
destNationId: number;
|
||||
joinTurn: number;
|
||||
}
|
||||
|
||||
export interface WarAftermathTechContext {
|
||||
@@ -162,6 +173,7 @@ export interface ConquerCityOutcome<TriggerState extends GeneralTriggerState = G
|
||||
nations: Nation[];
|
||||
cities: City[];
|
||||
generals: General<TriggerState>[];
|
||||
ruinedNpcJoinPlans: RuinedNpcJoinPlan[];
|
||||
}
|
||||
|
||||
export interface WarAftermathInput<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
|
||||
@@ -322,7 +322,7 @@ export class WarUnitGeneral<
|
||||
}
|
||||
|
||||
const atmosMultiplier = this.isAttacker() ? 1.1 : 1.05;
|
||||
this.general.atmos = clamp(Math.round(this.general.atmos * atmosMultiplier), 0, this.config.maxAtmosByWar);
|
||||
this.general.atmos = clamp(this.general.atmos * atmosMultiplier, 0, this.config.maxAtmosByWar);
|
||||
|
||||
this.addStatExp(1);
|
||||
}
|
||||
@@ -384,7 +384,10 @@ export class WarUnitGeneral<
|
||||
nextExp *= 0.9;
|
||||
}
|
||||
const adjustedExp = this.actionPipeline.onCalcStat(this.getActionContext(), 'addDex', nextExp, { armType });
|
||||
this.general.meta[key] = base + adjustedExp;
|
||||
// PHP interpolates floats into MeekroDB SQL with precision=14 before
|
||||
// MariaDB rounds the integer dex column. Normalize this accumulated
|
||||
// battle value at the same boundary (for example ...499999999996 -> .5).
|
||||
this.general.meta[key] = Number((base + adjustedExp).toPrecision(14));
|
||||
}
|
||||
|
||||
public calcRiceConsumption(damage: number): number {
|
||||
@@ -529,5 +532,31 @@ export class WarUnitGeneral<
|
||||
this.general.rice = round(this.general.rice);
|
||||
this.general.experience = round(this.general.experience);
|
||||
this.general.dedication = round(this.general.dedication);
|
||||
|
||||
// Ref WarUnitGeneral::finishBattle() runs General::checkStatChange()
|
||||
// before persisting the participant. A battle can therefore consume
|
||||
// one accumulated stat-exp threshold even though che_출병 itself does
|
||||
// not have the generic command progression tail.
|
||||
const limit = this.config.statUpgradeLimit ?? 30;
|
||||
const maxStat = this.config.maxGeneralStat ?? 255;
|
||||
const entries = [
|
||||
['leadership', META_LEADERSHIP_EXP, '통솔'],
|
||||
['strength', META_STRENGTH_EXP, '무력'],
|
||||
['intelligence', META_INTEL_EXP, '지력'],
|
||||
] as const;
|
||||
for (const [statKey, expKey, label] of entries) {
|
||||
const statExp = getMetaNumber(this.general.meta, expKey);
|
||||
if (statExp < 0) {
|
||||
this.general.meta[expKey] = statExp + limit;
|
||||
this.general.stats[statKey] -= 1;
|
||||
this.logger.pushGeneralActionLog(`<R>${label}</>이 <C>1</> 떨어졌습니다!`, LogFormat.PLAIN);
|
||||
} else if (statExp >= limit) {
|
||||
if (this.general.stats[statKey] < maxStat) {
|
||||
this.general.stats[statKey] += 1;
|
||||
this.logger.pushGeneralActionLog(`<S>${label}</>이 <C>1</> 올랐습니다!`, LogFormat.PLAIN);
|
||||
}
|
||||
this.general.meta[expKey] = statExp - limit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,19 @@ export const clampMin = (value: number, min: number): number => (value < min ? m
|
||||
|
||||
export const clampMax = (value: number, max: number): number => (value > max ? max : value);
|
||||
|
||||
export const round = (value: number): number => Math.round(value);
|
||||
// PHP's round() compensates for small binary floating-point drift around a
|
||||
// half boundary and rounds halves away from zero. War state is persisted to
|
||||
// integer columns through legacy Util::round(), so Math.round() is not enough:
|
||||
// e.g. accumulated siege damage can produce 4159.499999999999, which PHP
|
||||
// rounds to 4160 while Math.round() returns 4159.
|
||||
export const round = (value: number): number => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value));
|
||||
return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5);
|
||||
};
|
||||
|
||||
export const getMetaNumber = (meta: Record<string, TriggerValue>, key: string, fallback = 0): number => {
|
||||
const value = meta[key];
|
||||
|
||||
@@ -8,6 +8,11 @@ import type {
|
||||
TriggerValue,
|
||||
} from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { ScenarioDefinition, ScenarioGeneral } from '@sammo-ts/logic/scenario/types.js';
|
||||
import {
|
||||
isDomesticTraitKey,
|
||||
isEventDomesticTraitKey,
|
||||
isWarTraitKey,
|
||||
} from '@sammo-ts/logic/actionModules/traits/index.js';
|
||||
import type {
|
||||
CitySeed,
|
||||
GeneralSeed,
|
||||
@@ -37,6 +42,9 @@ export interface ScenarioBootstrapOptions {
|
||||
* remains deterministic in tests and reset operations.
|
||||
*/
|
||||
hiddenSeed?: string | number;
|
||||
initialYear?: number;
|
||||
initialMonth?: number;
|
||||
turnTermMinutes?: number;
|
||||
}
|
||||
|
||||
export type ScenarioBootstrapWarningCode =
|
||||
@@ -72,11 +80,70 @@ const DEFAULT_GENERAL_GOLD = 1000;
|
||||
const DEFAULT_GENERAL_RICE = 1000;
|
||||
const DEFAULT_CREWTYPE_ID = 1100;
|
||||
const DEFAULT_GENERAL_KILLTURN = 24;
|
||||
const DEFAULT_AVAILABLE_PERSONALITIES = [
|
||||
'che_안전',
|
||||
'che_유지',
|
||||
'che_재간',
|
||||
'che_출세',
|
||||
'che_할거',
|
||||
'che_정복',
|
||||
'che_패권',
|
||||
'che_의협',
|
||||
'che_대의',
|
||||
'che_왕좌',
|
||||
];
|
||||
const DEFAULT_CITY_TRUST = 50;
|
||||
const DEFAULT_CITY_TRADE = 100;
|
||||
const DEFAULT_CITY_SUPPLY_STATE = 1;
|
||||
const DEFAULT_CITY_FRONT_STATE = 0;
|
||||
|
||||
const canonicalizeDomesticTrait = (raw: string | null | undefined): string | null => {
|
||||
if (!raw || raw === 'None') {
|
||||
return null;
|
||||
}
|
||||
if (isDomesticTraitKey(raw) || isEventDomesticTraitKey(raw)) {
|
||||
return raw;
|
||||
}
|
||||
const domesticKey = `che_${raw}`;
|
||||
if (isDomesticTraitKey(domesticKey)) {
|
||||
return domesticKey;
|
||||
}
|
||||
const eventKey = `che_event_${raw}`;
|
||||
if (isEventDomesticTraitKey(eventKey)) {
|
||||
return eventKey;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const canonicalizeWarTrait = (raw: string | null | undefined): string | null => {
|
||||
if (!raw || raw === 'None') {
|
||||
return null;
|
||||
}
|
||||
if (isWarTraitKey(raw)) {
|
||||
return raw;
|
||||
}
|
||||
const warKey = `che_${raw}`;
|
||||
return isWarTraitKey(warKey) ? warKey : null;
|
||||
};
|
||||
|
||||
// Scenario rows expose one legacy speciality column. GeneralBuilder tries the
|
||||
// domestic catalogue first, then the war catalogue, and persists the resolved
|
||||
// class code. Preserve unknown values in the domestic slot so custom scenario
|
||||
// packs retain their prior data even when their module is not installed here.
|
||||
const resolveScenarioTraits = (
|
||||
special: string | null,
|
||||
explicitWar: string | null | undefined
|
||||
): { specialDomestic: string | null; specialWar: string | null } => {
|
||||
const domestic = canonicalizeDomesticTrait(special);
|
||||
const inferredWar = domestic === null ? canonicalizeWarTrait(special) : null;
|
||||
const retainedDomestic = special && special !== 'None' ? special : null;
|
||||
const retainedWar = explicitWar && explicitWar !== 'None' ? explicitWar : null;
|
||||
return {
|
||||
specialDomestic: domestic ?? (inferredWar === null ? retainedDomestic : null),
|
||||
specialWar: canonicalizeWarTrait(explicitWar) ?? inferredWar ?? retainedWar,
|
||||
};
|
||||
};
|
||||
|
||||
const createScenarioMeta = (scenario: ScenarioDefinition): ScenarioMeta => ({
|
||||
title: scenario.title,
|
||||
startYear: scenario.startYear,
|
||||
@@ -93,6 +160,82 @@ const createEmptyTriggerState = (): GeneralTriggerState => ({
|
||||
meta: {},
|
||||
});
|
||||
|
||||
// GameConstBase::$defaultInitialEvents / $defaultEvents. Ref prepends these
|
||||
// rows to every scenario unless ignoreDefaultEvents is enabled; scenario JSON
|
||||
// only contains its additional rows.
|
||||
const LEGACY_DEFAULT_INITIAL_EVENTS: unknown[] = [
|
||||
[true, ['NoticeToHistoryLog', '<S>2년간 거병 및 건국이 가능합니다.</>', 6]],
|
||||
];
|
||||
|
||||
const LEGACY_DEFAULT_EVENTS: unknown[] = [
|
||||
['pre_month', 9_000, true, ['UpdateCitySupply'], ['ProcessWarIncome']],
|
||||
[
|
||||
'month',
|
||||
9_000,
|
||||
['Date', '==', null, 1],
|
||||
['MergeInheritPointRank'],
|
||||
['ProcessSemiAnnual', 'gold'],
|
||||
['ProcessIncome', 'gold'],
|
||||
['ResetOfficerLock'],
|
||||
['RaiseDisaster'],
|
||||
['RandomizeCityTradeRate'],
|
||||
['NewYear'],
|
||||
['AssignGeneralSpeciality'],
|
||||
],
|
||||
['month', 9_000, ['Date', '==', null, 4], ['ResetOfficerLock'], ['RaiseDisaster']],
|
||||
[
|
||||
'month',
|
||||
9_000,
|
||||
['Date', '==', null, 7],
|
||||
['MergeInheritPointRank'],
|
||||
['ProcessSemiAnnual', 'rice'],
|
||||
['ProcessIncome', 'rice'],
|
||||
['ResetOfficerLock'],
|
||||
['RaiseDisaster'],
|
||||
['RandomizeCityTradeRate'],
|
||||
],
|
||||
['month', 9_000, ['Date', '==', null, 10], ['ResetOfficerLock'], ['RaiseDisaster']],
|
||||
[
|
||||
'month',
|
||||
2_000,
|
||||
['DateRelative', '==', 1, 1],
|
||||
['NoticeToHistoryLog', '<S>2년 뒤 출병 제한이 풀립니다.</>', 6],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
[
|
||||
'month',
|
||||
2_000,
|
||||
['DateRelative', '==', 2, 1],
|
||||
['NoticeToHistoryLog', '<S>1년 뒤 출병 제한이 풀립니다.</>', 6],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
[
|
||||
'month',
|
||||
2_000,
|
||||
['DateRelative', '==', 2, 7],
|
||||
['NoticeToHistoryLog', '<S>6개월 뒤 출병 제한이 풀립니다. 병력을 준비해주세요.</>', 6],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
[
|
||||
'month',
|
||||
2_000,
|
||||
['DateRelative', '==', 3, 1],
|
||||
['NoticeToHistoryLog', '<S>출병 제한이 풀렸습니다.</>', 6],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
[
|
||||
'month',
|
||||
2_000,
|
||||
['DateRelative', '==', 4, 1],
|
||||
['NoticeToHistoryLog', '<S>이제부터 하야, 망명시 패널티가 적용됩니다.</>', 6],
|
||||
['AddGlobalBetray', 1, 0],
|
||||
['AddGlobalBetray', 1, 1],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
['month', 1_000, true, ['UpdateNationLevel'], ['ProvideNPCTroopLeader']],
|
||||
['united', 5_000, true, ['MergeInheritPointRank']],
|
||||
];
|
||||
|
||||
const addTriggerMeta = (
|
||||
meta: Record<string, TriggerValue>,
|
||||
key: string,
|
||||
@@ -194,6 +337,13 @@ const resolveAge = (startYear: number | null, birthYear: number): number => {
|
||||
const buildSpecialityAge = (retirementYear: number, age: number, divisor: number): number =>
|
||||
Math.max(Math.round((retirementYear - age) / divisor), 3) + age;
|
||||
|
||||
const resolveBootstrapSpecialityAge = (
|
||||
scenarioStartYear: number | null,
|
||||
birthYear: number,
|
||||
retirementYear: number,
|
||||
divisor: number
|
||||
): number => buildSpecialityAge(retirementYear, resolveAge(scenarioStartYear, birthYear), divisor);
|
||||
|
||||
const ADULT_GENERAL_AGE = 14;
|
||||
|
||||
type GeneralBootstrapDisposition = 'active' | 'delayed' | 'expired';
|
||||
@@ -313,7 +463,8 @@ const buildGeneralSeeds = (
|
||||
defaultCrewTypeId: number,
|
||||
mapCities: MapDefinition['cities'],
|
||||
nationCityIds: Map<number, number[]>,
|
||||
placementRng: RandUtil,
|
||||
installRng: RandUtil,
|
||||
initializedValues: Map<ScenarioGeneral, { affinity: number; personality: string }>,
|
||||
options?: ScenarioBootstrapOptions
|
||||
): {
|
||||
seeds: GeneralSeed[];
|
||||
@@ -340,47 +491,49 @@ const buildGeneralSeeds = (
|
||||
const ownedCityIds = nationId > 0 ? (nationCityIds.get(nationId) ?? []) : [];
|
||||
const candidateCityIds = ownedCityIds.length > 0 ? ownedCityIds : mapCities.map((city) => city.id);
|
||||
if (candidateCityIds.length > 0) {
|
||||
cityId = placementRng.choice(candidateCityIds);
|
||||
cityId = installRng.choice(candidateCityIds);
|
||||
}
|
||||
}
|
||||
const birthYear = resolveBirthYear(row.birthYear, scenario.startYear);
|
||||
const deathYear = resolveDeathYear(row.deathYear, birthYear, scenario.startYear);
|
||||
const deathMonth = resolveScenarioGeneralDeathMonth({
|
||||
scenarioTitle: scenario.title,
|
||||
startYear: scenario.startYear,
|
||||
contextLabel,
|
||||
generalId: id,
|
||||
generalName: row.name,
|
||||
deathYear,
|
||||
});
|
||||
const turnTermMinutes = Math.max(1, Math.floor(options?.turnTermMinutes ?? 60));
|
||||
const initialTurnOffsetMicros =
|
||||
installRng.nextRangeInt(0, turnTermMinutes * 60 - 1) * 1_000_000 + installRng.nextRangeInt(0, 999_999);
|
||||
const deathMonth = installRng.nextRangeInt(1, 12);
|
||||
const officerLevel = resolveOfficerLevel(row.officerLevel, nationId);
|
||||
const age = resolveAge(scenario.startYear, birthYear);
|
||||
const initialYear = options?.initialYear ?? scenario.startYear;
|
||||
const initialMonth = options?.initialMonth ?? 1;
|
||||
const age = resolveAge(initialYear, birthYear);
|
||||
const initialized = initializedValues.get(row);
|
||||
if (!initialized) {
|
||||
throw new Error(`Missing legacy initialization values for general ${row.name}.`);
|
||||
}
|
||||
const stats = {
|
||||
leadership: row.leadership,
|
||||
strength: row.strength,
|
||||
intelligence: row.intelligence,
|
||||
};
|
||||
const { specialDomestic, specialWar } = resolveScenarioTraits(row.special, row.specialWar);
|
||||
|
||||
const seedMeta: Record<string, unknown> = {
|
||||
source: contextLabel,
|
||||
deathMonth,
|
||||
specage: buildSpecialityAge(retirementYear, age, 12),
|
||||
specage2: buildSpecialityAge(retirementYear, age, 6),
|
||||
initialTurnOffsetMicros,
|
||||
// Ref's GeneralBuilder derives speciality ages from the scenario
|
||||
// opening year even when installation stores a pre-opening age.
|
||||
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
|
||||
specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6),
|
||||
};
|
||||
if (row.affinity !== null) {
|
||||
seedMeta.affinity = row.affinity;
|
||||
}
|
||||
if (row.personality !== null) {
|
||||
seedMeta.personality = row.personality;
|
||||
}
|
||||
if (row.special !== null) {
|
||||
seedMeta.special = row.special;
|
||||
seedMeta.affinity = initialized.affinity;
|
||||
seedMeta.personality = initialized.personality;
|
||||
if (specialDomestic !== null) {
|
||||
seedMeta.special = specialDomestic;
|
||||
}
|
||||
if (row.picture !== null) {
|
||||
seedMeta.picture = row.picture;
|
||||
}
|
||||
if (row.specialWar !== null && row.specialWar !== undefined) {
|
||||
seedMeta.specialWar = row.specialWar;
|
||||
if (specialWar !== null) {
|
||||
seedMeta.specialWar = specialWar;
|
||||
}
|
||||
if (row.horse !== null && row.horse !== undefined) {
|
||||
seedMeta.horse = row.horse;
|
||||
@@ -407,10 +560,10 @@ const buildGeneralSeeds = (
|
||||
officerLevel,
|
||||
birthYear,
|
||||
deathYear,
|
||||
affinity: row.affinity,
|
||||
personality: row.personality,
|
||||
special: row.special,
|
||||
specialWar: row.specialWar ?? null,
|
||||
affinity: initialized.affinity,
|
||||
personality: initialized.personality,
|
||||
special: specialDomestic,
|
||||
specialWar,
|
||||
horse: row.horse ?? null,
|
||||
weapon: row.weapon ?? null,
|
||||
book: row.book ?? null,
|
||||
@@ -419,14 +572,16 @@ const buildGeneralSeeds = (
|
||||
npcType,
|
||||
text: row.text,
|
||||
crewTypeId: defaultCrewTypeId,
|
||||
experience: age * 100,
|
||||
dedication: age * 100,
|
||||
meta: seedMeta,
|
||||
};
|
||||
seeds.push(seed);
|
||||
|
||||
const generalMeta: GeneralMeta = {
|
||||
killturn: resolveKillturnFromDeathYear(
|
||||
scenario.startYear,
|
||||
1,
|
||||
initialYear,
|
||||
initialMonth,
|
||||
deathYear,
|
||||
deathMonth,
|
||||
DEFAULT_GENERAL_KILLTURN
|
||||
@@ -434,14 +589,14 @@ const buildGeneralSeeds = (
|
||||
deathMonth,
|
||||
npcType,
|
||||
crewTypeId: defaultCrewTypeId,
|
||||
specage: buildSpecialityAge(retirementYear, age, 12),
|
||||
specage2: buildSpecialityAge(retirementYear, age, 6),
|
||||
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
|
||||
specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6),
|
||||
};
|
||||
addTriggerMeta(generalMeta, 'affinity', row.affinity);
|
||||
addTriggerMeta(generalMeta, 'personality', row.personality ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'special', row.special ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'affinity', initialized.affinity);
|
||||
addTriggerMeta(generalMeta, 'personality', initialized.personality);
|
||||
addTriggerMeta(generalMeta, 'special', specialDomestic ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'picture', row.picture ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'specialWar', row.specialWar ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'specialWar', specialWar ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'horse', row.horse ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'weapon', row.weapon ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'book', row.book ?? undefined);
|
||||
@@ -456,13 +611,13 @@ const buildGeneralSeeds = (
|
||||
cityId,
|
||||
troopId: 0,
|
||||
stats,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
experience: age * 100,
|
||||
dedication: age * 100,
|
||||
officerLevel,
|
||||
role: {
|
||||
personality: row.personality,
|
||||
specialDomestic: row.special,
|
||||
specialWar: row.specialWar ?? null,
|
||||
personality: initialized.personality,
|
||||
specialDomestic,
|
||||
specialWar,
|
||||
items: {
|
||||
horse: row.horse ?? null,
|
||||
weapon: row.weapon ?? null,
|
||||
@@ -632,8 +787,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
}
|
||||
|
||||
const mapDefaults = resolveMapDefaults(map, options);
|
||||
const placementRng = new RandUtil(
|
||||
new LiteHashDRBG(simpleSerialize(options?.hiddenSeed ?? scenario.title, 'InitScenarioGeneralCities'))
|
||||
const installRng = new RandUtil(
|
||||
new LiteHashDRBG(simpleSerialize(options?.hiddenSeed ?? scenario.title, 'InitScenario'))
|
||||
);
|
||||
const defaultCrewTypeId = unitSet?.defaultCrewTypeId ?? options?.defaultCrewTypeId ?? DEFAULT_CREWTYPE_ID;
|
||||
const seedCities: CitySeed[] = [];
|
||||
@@ -714,6 +869,19 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
const allGeneralSeeds: GeneralSeed[] = [];
|
||||
const allGenerals: General[] = [];
|
||||
const delayedActionsByBirthYear = new Map<number, unknown[][]>();
|
||||
const initializedValues = new Map<ScenarioGeneral, { affinity: number; personality: string }>();
|
||||
const rawAvailablePersonalities = scenario.config.const.availablePersonality;
|
||||
const availablePersonalities = Array.isArray(rawAvailablePersonalities)
|
||||
? rawAvailablePersonalities.filter((value): value is string => typeof value === 'string')
|
||||
: DEFAULT_AVAILABLE_PERSONALITIES;
|
||||
const personalityPool =
|
||||
availablePersonalities.length > 0 ? availablePersonalities : DEFAULT_AVAILABLE_PERSONALITIES;
|
||||
for (const row of [...scenario.generals, ...scenario.generalsEx, ...scenario.generalsNeutral]) {
|
||||
const affinity = row.affinity !== null && row.affinity > 0 ? row.affinity : installRng.nextRangeInt(1, 150);
|
||||
const rawPersonality = row.personality ?? installRng.choice(personalityPool);
|
||||
const personality = rawPersonality.includes('_') ? rawPersonality : `che_${rawPersonality}`;
|
||||
initializedValues.set(row, { affinity, personality });
|
||||
}
|
||||
|
||||
const partitionGenerals = (rows: ScenarioGeneral[], npcType: 2 | 6): ScenarioGeneral[] => {
|
||||
const active: ScenarioGeneral[] = [];
|
||||
@@ -750,7 +918,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
defaultCrewTypeId,
|
||||
map.cities,
|
||||
nationCityIds,
|
||||
placementRng,
|
||||
installRng,
|
||||
initializedValues,
|
||||
options
|
||||
);
|
||||
allGeneralSeeds.push(...generalResult.seeds);
|
||||
@@ -769,7 +938,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
defaultCrewTypeId,
|
||||
map.cities,
|
||||
nationCityIds,
|
||||
placementRng,
|
||||
installRng,
|
||||
initializedValues,
|
||||
options
|
||||
);
|
||||
allGeneralSeeds.push(...generalExResult.seeds);
|
||||
@@ -788,7 +958,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
defaultCrewTypeId,
|
||||
map.cities,
|
||||
nationCityIds,
|
||||
placementRng,
|
||||
installRng,
|
||||
initializedValues,
|
||||
options
|
||||
);
|
||||
allGeneralSeeds.push(...generalNeutralResult.seeds);
|
||||
@@ -801,7 +972,9 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
...actions,
|
||||
['DeleteEvent'],
|
||||
]);
|
||||
const events = [...scenario.events, ...delayedGeneralEvents];
|
||||
const defaultEvents = scenario.ignoreDefaultEvents ? [] : LEGACY_DEFAULT_EVENTS;
|
||||
const defaultInitialEvents = scenario.ignoreDefaultEvents ? [] : LEGACY_DEFAULT_INITIAL_EVENTS;
|
||||
const events = [...defaultEvents, ...scenario.events, ...delayedGeneralEvents];
|
||||
|
||||
const seed: WorldSeedPayload = {
|
||||
scenarioConfig: scenario.config,
|
||||
@@ -814,7 +987,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
troops: [],
|
||||
diplomacy: scenario.diplomacy,
|
||||
events,
|
||||
initialEvents: scenario.initialEvents,
|
||||
initialEvents: [...defaultInitialEvents, ...scenario.initialEvents],
|
||||
};
|
||||
|
||||
const snapshot: WorldSnapshot = {
|
||||
@@ -828,7 +1001,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
troops: [],
|
||||
diplomacy: scenario.diplomacy,
|
||||
events,
|
||||
initialEvents: scenario.initialEvents,
|
||||
initialEvents: [...defaultInitialEvents, ...scenario.initialEvents],
|
||||
};
|
||||
|
||||
return { snapshot, seed, warnings };
|
||||
|
||||
@@ -27,16 +27,20 @@ export const getCityDistance = (map: MapDefinition, startCityId: number, endCity
|
||||
return Infinity;
|
||||
};
|
||||
|
||||
export const searchDistance = (map: MapDefinition, startCityId: number, range: number): Record<number, number> => {
|
||||
const result: Record<number, number> = {};
|
||||
export const searchDistanceEntries = (
|
||||
map: MapDefinition,
|
||||
startCityId: number,
|
||||
range: number
|
||||
): Array<[cityId: number, distance: number]> => {
|
||||
const result: Array<[number, number]> = [];
|
||||
const visited = new Set<number>();
|
||||
const queue: [number, number][] = [[startCityId, 0]];
|
||||
|
||||
visited.add(startCityId);
|
||||
result[startCityId] = 0;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const [currentId, dist] = queue.shift()!;
|
||||
result.push([currentId, dist]);
|
||||
|
||||
if (dist >= range) continue;
|
||||
|
||||
@@ -47,7 +51,6 @@ export const searchDistance = (map: MapDefinition, startCityId: number, range: n
|
||||
if (!visited.has(neighborId)) {
|
||||
visited.add(neighborId);
|
||||
const newDist = dist + 1;
|
||||
result[neighborId] = newDist;
|
||||
queue.push([neighborId, newDist]);
|
||||
}
|
||||
}
|
||||
@@ -55,3 +58,6 @@ export const searchDistance = (map: MapDefinition, startCityId: number, range: n
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const searchDistance = (map: MapDefinition, startCityId: number, range: number): Record<number, number> =>
|
||||
Object.fromEntries(searchDistanceEntries(map, startCityId, range));
|
||||
|
||||
@@ -158,6 +158,8 @@ export interface GeneralSeed {
|
||||
npcType: number;
|
||||
text: string | null;
|
||||
crewTypeId: number;
|
||||
experience?: number;
|
||||
dedication?: number;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,43 @@ describe('crew type catalog', () => {
|
||||
|
||||
expect(computeBattleOrder(defender, attacker)).toBe(10000);
|
||||
});
|
||||
|
||||
it('uses live injured and full action-adjusted stats for defender order', () => {
|
||||
const footman = crewType(1100, 1, '보병');
|
||||
const rng = new RandUtil(new ConstantRNG(0));
|
||||
const attacker = buildGeneralUnit(rng, buildGeneral(1, footman.id), footman, true);
|
||||
const defenderGeneral = { ...buildGeneral(2, footman.id), injury: 50 };
|
||||
const defender = buildGeneralUnit(
|
||||
rng,
|
||||
defenderGeneral,
|
||||
footman,
|
||||
false,
|
||||
new WarActionPipeline([
|
||||
{
|
||||
onCalcStat: (_context, statName, value) =>
|
||||
statName === 'leadership' && typeof value === 'number' ? value + 40 : value,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
expect(computeBattleOrder(defender, attacker)).toBe(260);
|
||||
});
|
||||
|
||||
it('excludes defenders below the legacy defence training threshold', () => {
|
||||
const footman = crewType(1100, 1, '보병');
|
||||
const rng = new RandUtil(new ConstantRNG(0));
|
||||
const attacker = buildGeneralUnit(rng, buildGeneral(1, footman.id), footman, true);
|
||||
const baseDefender = buildGeneral(2, footman.id);
|
||||
const defenderGeneral = {
|
||||
...baseDefender,
|
||||
train: 79,
|
||||
atmos: 80,
|
||||
meta: { ...baseDefender.meta, defence_train: 80 },
|
||||
};
|
||||
const defender = buildGeneralUnit(rng, defenderGeneral, footman, false);
|
||||
|
||||
expect(computeBattleOrder(defender, attacker)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('crew type war triggers', () => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createRefOrderedActionStack } from '../src/actionModules/bundle.js';
|
||||
import type { GeneralActionModule } from '../src/actionModules/general.js';
|
||||
import { ActionDefinition } from '../src/actions/turn/general/che_소집해제.js';
|
||||
|
||||
describe('che_소집해제', () => {
|
||||
it('applies legacy experience and dedication stat hooks', () => {
|
||||
const personality = {
|
||||
eventHandlers: {},
|
||||
onCalcStat: (_context, statName, value) => {
|
||||
if (statName === 'experience') return Number(value) * 1.1;
|
||||
if (statName === 'dedication') return Number(value) * 0.9;
|
||||
return value;
|
||||
},
|
||||
} satisfies GeneralActionModule;
|
||||
const noOp = {};
|
||||
const definition = new ActionDefinition({
|
||||
generalActionModules: createRefOrderedActionStack({
|
||||
nation: noOp,
|
||||
officer: noOp,
|
||||
domestic: noOp,
|
||||
war: noOp,
|
||||
personality,
|
||||
crewType: null,
|
||||
inheritance: noOp,
|
||||
scenario: null,
|
||||
items: [],
|
||||
}),
|
||||
} as never);
|
||||
const general = { crew: 500, experience: 1_000, dedication: 2_000 };
|
||||
const city = { population: 10_000 };
|
||||
|
||||
definition.resolve(
|
||||
{
|
||||
general,
|
||||
city,
|
||||
addLog: () => undefined,
|
||||
} as never,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(general.experience).toBe(1_077);
|
||||
expect(general.dedication).toBe(2_090);
|
||||
});
|
||||
});
|
||||
@@ -181,8 +181,12 @@ describe('che_출병', () => {
|
||||
const defenderNation = buildNation(2);
|
||||
const attackerCity = buildCity(1, attackerNation.id);
|
||||
const defenderCity = buildCity(2, defenderNation.id);
|
||||
const neutralCity = buildCity(3, 0);
|
||||
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
|
||||
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
|
||||
defender.crew = 0;
|
||||
defenderCity.defence = 0;
|
||||
defenderCity.wall = 0;
|
||||
|
||||
const definition = new ActionDefinition();
|
||||
const context: Omit<DispatchResolveContext, 'addLog'> = {
|
||||
@@ -192,10 +196,23 @@ describe('che_출병', () => {
|
||||
rng,
|
||||
destCity: defenderCity,
|
||||
destNation: defenderNation,
|
||||
cities: [attackerCity, defenderCity],
|
||||
cities: [attackerCity, defenderCity, neutralCity],
|
||||
nations: [attackerNation, defenderNation],
|
||||
generals: [attacker, defender],
|
||||
unitSet,
|
||||
map: {
|
||||
id: 'test-map',
|
||||
name: 'test-map',
|
||||
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: 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: [
|
||||
{ fromNationId: attackerNation.id, toNationId: defenderNation.id, state: 0, term: 0 },
|
||||
{ fromNationId: defenderNation.id, toNationId: attackerNation.id, state: 0, term: 0 },
|
||||
],
|
||||
time: {
|
||||
year: 200,
|
||||
month: 1,
|
||||
@@ -220,6 +237,9 @@ describe('che_출병', () => {
|
||||
expect(resolution.logs.length).toBeGreaterThan(0);
|
||||
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({ city: resolution.city, patches: resolution.patches?.cities }).toMatchObject({
|
||||
city: { frontState: 2 },
|
||||
});
|
||||
expect(
|
||||
resolution.effects.some(
|
||||
(effect) =>
|
||||
@@ -229,4 +249,77 @@ describe('che_출병', () => {
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers an enemy on the shortest route layer before considering the next layer', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const attackerCity = buildCity(1, attackerNation.id);
|
||||
const targetCity = buildCity(2, 0);
|
||||
const alternateCity = buildCity(3, 0);
|
||||
targetCity.defence = 0;
|
||||
targetCity.wall = 0;
|
||||
alternateCity.defence = 0;
|
||||
alternateCity.wall = 0;
|
||||
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
|
||||
const pickLastRng = {
|
||||
...rng,
|
||||
nextInt: (_minInclusive: number, maxExclusive: number) => maxExclusive - 1,
|
||||
};
|
||||
const definition = new ActionDefinition();
|
||||
const mapCity = (id: number, connections: number[]) => ({
|
||||
id,
|
||||
name: `City${id}`,
|
||||
level: 2,
|
||||
region: 1,
|
||||
position: { x: id, y: 0 },
|
||||
connections,
|
||||
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,
|
||||
},
|
||||
});
|
||||
const context: Omit<DispatchResolveContext, 'addLog'> = {
|
||||
general: attacker,
|
||||
city: attackerCity,
|
||||
nation: attackerNation,
|
||||
rng: pickLastRng,
|
||||
destCity: targetCity,
|
||||
destNation: null,
|
||||
cities: [attackerCity, targetCity, alternateCity],
|
||||
nations: [attackerNation],
|
||||
generals: [attacker],
|
||||
unitSet,
|
||||
map: {
|
||||
id: 'triangle',
|
||||
name: 'triangle',
|
||||
cities: [mapCity(1, [2, 3]), mapCity(2, [1, 3]), mapCity(3, [1, 2])],
|
||||
},
|
||||
diplomacy: [],
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
seedBase: 'route-layer-seed',
|
||||
warConfig,
|
||||
aftermathConfig,
|
||||
};
|
||||
|
||||
const resolution = resolveGeneralAction(
|
||||
definition,
|
||||
context,
|
||||
{ now: new Date('2000-01-01T00:00:00Z'), schedule },
|
||||
{ destCityId: targetCity.id }
|
||||
);
|
||||
|
||||
expect(resolution.patches?.cities.some((patch) => patch.id === targetCity.id)).toBe(true);
|
||||
expect(resolution.patches?.cities.some((patch) => patch.id === alternateCity.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,6 +191,10 @@ describe('Blank Start Scenario', () => {
|
||||
const newNation = world.getNation(newNationId)!;
|
||||
expect(newNation.chiefGeneralId).toBe(gen0.id);
|
||||
expect(newNation.level).toBe(0); // Wandering Nation
|
||||
// Ref che_거병 does not install a nation-specific NPC policy. Leaving
|
||||
// this key absent preserves AutorunNationPolicy's 50,000 population
|
||||
// floor instead of silently overriding it with zero.
|
||||
expect(newNation.meta).not.toHaveProperty('npc_nation_policy');
|
||||
|
||||
// --- Step 2: Gen 1 performs Appointment ---
|
||||
// Before appointment, Gen 0 should FAIL Founding because general count = 1
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { MapDefinition } from '../../../src/world/types.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
|
||||
import { createRefOrderedActionStack } from '../../../src/actionModules/bundle.js';
|
||||
import type { GeneralActionModule } from '../../../src/actionModules/general.js';
|
||||
|
||||
const MOCK_SCENARIO_BASE = {
|
||||
title: 'Test',
|
||||
@@ -146,7 +148,7 @@ function createViewState(world: InMemoryWorld, year: number = 200, env: TurnComm
|
||||
}
|
||||
|
||||
describe('che_귀환', () => {
|
||||
it('should return to capital if normal officer', async () => {
|
||||
it('should return to capital and apply legacy experience modifiers', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: LINEAR_MAP,
|
||||
@@ -203,7 +205,23 @@ describe('che_귀환', () => {
|
||||
generalId: general.id,
|
||||
commandKey: 'che_귀환',
|
||||
resolver: (await import('../../../src/actions/turn/general/che_귀환.js')).commandSpec.createDefinition(
|
||||
{} as any
|
||||
{
|
||||
...systemEnv,
|
||||
generalActionModules: createRefOrderedActionStack<GeneralActionModule>({
|
||||
nation: {
|
||||
onCalcStat: (_context, statName, value) =>
|
||||
statName === 'experience' && typeof value === 'number' ? value * 1.1 : value,
|
||||
},
|
||||
officer: {},
|
||||
domestic: {},
|
||||
war: {},
|
||||
personality: {},
|
||||
crewType: null,
|
||||
inheritance: {},
|
||||
scenario: null,
|
||||
items: [],
|
||||
}),
|
||||
}
|
||||
),
|
||||
args: {},
|
||||
},
|
||||
@@ -211,7 +229,7 @@ describe('che_귀환', () => {
|
||||
|
||||
const updated = world.getGeneral(general.id);
|
||||
expect(updated?.cityId).toBe(101); // Capital
|
||||
expect(updated?.experience).toBe(70);
|
||||
expect(updated?.experience).toBe(77);
|
||||
expect(updated?.dedication).toBe(100);
|
||||
expect(updated?.meta.leadership_exp).toBe(1);
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import { MINIMAL_MAP } from '../fixtures/minimalMap.js';
|
||||
import { InMemoryWorld, TestGameRunner } from '../testEnv.js';
|
||||
import type { City, General, Nation } from '../../src/domain/entities.js';
|
||||
import type { WorldSnapshot } from '../../src/world/types.js';
|
||||
import { commandSpec as procureSpec } from '../../src/actions/turn/general/che_물자조달.js';
|
||||
import {
|
||||
commandSpec as procureSpec,
|
||||
roundLegacyAccumulatedInteger,
|
||||
} from '../../src/actions/turn/general/che_물자조달.js';
|
||||
import { commandSpec as donateSpec } from '../../src/actions/turn/general/che_헌납.js';
|
||||
import { commandSpec as moveSpec } from '../../src/actions/turn/general/che_이동.js';
|
||||
import { commandSpec as wanderSpec } from '../../src/actions/turn/general/che_방랑.js';
|
||||
@@ -24,8 +27,65 @@ import {
|
||||
loadItemModules,
|
||||
} from '../../src/items/index.js';
|
||||
import { createRefOrderedActionStack } from '../../src/actionModules/bundle.js';
|
||||
import type { GeneralActionModule } from '../../src/actionModules/general.js';
|
||||
import {
|
||||
normalizeLegacyGeneratedDex,
|
||||
resolveLegacySpecialityAge,
|
||||
} from '../../src/actions/turn/general/che_인재탐색.js';
|
||||
import {
|
||||
addLegacyStoredTech,
|
||||
readLegacyStoredTech,
|
||||
toLegacyStoredTech,
|
||||
} from '../../src/actions/turn/general/che_기술연구.js';
|
||||
import {
|
||||
readLegacyCityTrust,
|
||||
storeLegacyCityTrust,
|
||||
} from '../../src/actions/turn/general/legacyCityTrust.js';
|
||||
import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js';
|
||||
|
||||
describe('General Commands New Scenario', () => {
|
||||
it('truncates generated NPC dex like GeneralBuilder integer arguments', () => {
|
||||
expect(normalizeLegacyGeneratedDex([36.5, 7.9, 7.1, 7.99, 0.75])).toEqual([36, 7, 7, 7, 0]);
|
||||
});
|
||||
|
||||
it('rounds the accumulated procurement experience like a MariaDB INT assignment', () => {
|
||||
const delta = (45 * 0.7) / 3;
|
||||
expect(delta).toBe(10.499999999999998);
|
||||
expect(Math.round(delta)).toBe(10);
|
||||
expect(roundLegacyAccumulatedInteger(4554, delta)).toBe(4565);
|
||||
});
|
||||
|
||||
it('persists generated NPC speciality ages from the legacy creation date', () => {
|
||||
expect(resolveLegacySpecialityAge(80, 22, 12)).toBe(27);
|
||||
expect(resolveLegacySpecialityAge(80, 22, 6)).toBe(32);
|
||||
expect(resolveLegacySpecialityAge(80, 24, 12)).toBe(29);
|
||||
});
|
||||
|
||||
it('stores technology as binary32 without per-update decimal quantization', () => {
|
||||
const value = 433.51797;
|
||||
expect(toLegacyStoredTech(value)).toBe(Math.fround(value));
|
||||
expect(toLegacyStoredTech(value)).not.toBe(Number(Math.fround(value).toPrecision(6)));
|
||||
expect(readLegacyStoredTech(624.0966796875)).toBe(624.097);
|
||||
expect(addLegacyStoredTech(624.0966796875, 22.9)).toBe(Math.fround(624.097 + 22.9));
|
||||
});
|
||||
|
||||
it('separates MariaDB FLOAT trust storage from its six-digit PHP read value', () => {
|
||||
const stored = storeLegacyCityTrust(88.306755);
|
||||
|
||||
expect(stored).toBe(Math.fround(88.306755));
|
||||
expect(stored).not.toBe(readLegacyCityTrust(stored));
|
||||
expect(readLegacyCityTrust(stored)).toBe(88.3068);
|
||||
expect(readLegacyCityTrust(storeLegacyCityTrust(readLegacyCityTrust(stored) + 10))).toBe(98.3068);
|
||||
});
|
||||
|
||||
it('rounds recruitment cost across the PHP half boundary', () => {
|
||||
const cavalryCost = (11 * 1.15 * 7000) / 100;
|
||||
|
||||
expect(cavalryCost).toBe(885.4999999999999);
|
||||
expect(Math.round(cavalryCost)).toBe(885);
|
||||
expect(roundLegacyRecruitCost(cavalryCost)).toBe(886);
|
||||
});
|
||||
|
||||
// 1. Setup Environment
|
||||
const systemEnv: TurnCommandEnv = {
|
||||
develCost: 100,
|
||||
@@ -168,7 +228,26 @@ describe('General Commands New Scenario', () => {
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
|
||||
// 1. Procure
|
||||
const procureDef = procureSpec.createDefinition(systemEnv);
|
||||
const procureDef = procureSpec.createDefinition({
|
||||
...systemEnv,
|
||||
generalActionModules: (() => {
|
||||
const noOp = {};
|
||||
return createRefOrderedActionStack({
|
||||
nation: noOp,
|
||||
officer: noOp,
|
||||
domestic: noOp,
|
||||
war: noOp,
|
||||
personality: {
|
||||
eventHandlers: {},
|
||||
onCalcStat: (_context, statName, value) => (statName === 'experience' ? value * 1.1 : value),
|
||||
} satisfies GeneralActionModule,
|
||||
crewType: null,
|
||||
inheritance: noOp,
|
||||
scenario: null,
|
||||
items: [],
|
||||
});
|
||||
})(),
|
||||
});
|
||||
await runner.runTurn([
|
||||
{
|
||||
generalId: 1,
|
||||
@@ -191,8 +270,12 @@ describe('General Commands New Scenario', () => {
|
||||
]);
|
||||
|
||||
const n1_after_procure = world.getNation(1)!;
|
||||
const g1_after_procure = world.getGeneral(1)!;
|
||||
// Nation gains gold
|
||||
expect(n1_after_procure.gold).toBeGreaterThan(10000);
|
||||
// Ref's addExperience/addDedication route rewards through onCalcStat.
|
||||
expect(g1_after_procure.experience).toBe(183);
|
||||
expect(g1_after_procure.dedication).toBe(208);
|
||||
|
||||
// 2. Donate
|
||||
const donateDef = donateSpec.createDefinition(systemEnv);
|
||||
|
||||
@@ -7,8 +7,18 @@ import { commandSpec as recruitSpec } from '../../src/actions/turn/general/che_
|
||||
import { commandSpec as trainSpec } from '../../src/actions/turn/general/che_훈련.js';
|
||||
import { commandSpec as atmosSpec } from '../../src/actions/turn/general/che_사기진작.js';
|
||||
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
||||
import { createRefOrderedActionStack } from '../../src/actionModules/bundle.js';
|
||||
import type { GeneralActionModule } from '../../src/actionModules/general.js';
|
||||
import { applyLegacyInjury, finalizeLegacyStat } from '../../src/actions/turn/general/legacyGeneralStat.js';
|
||||
|
||||
describe('Troop Management Scenario', () => {
|
||||
it('applies injury before action stat modifiers and floors like Ref General::getLeadership()', () => {
|
||||
const injured = applyLegacyInjury(62, 3);
|
||||
expect(injured).toBeCloseTo(60.14, 12);
|
||||
expect(finalizeLegacyStat(injured * 2)).toBe(120);
|
||||
expect(Math.round(((120 * 100) / 6076) * 30)).toBe(59);
|
||||
});
|
||||
|
||||
it('should successfully draft troops, then train and boost morale', async () => {
|
||||
// 1. Setup World
|
||||
const mockNation: Nation = {
|
||||
@@ -130,6 +140,29 @@ describe('Troop Management Scenario', () => {
|
||||
baseGold: 1000,
|
||||
baseRice: 1000,
|
||||
maxResourceActionAmount: 1000,
|
||||
...(snapshot.unitSet ? { unitSet: snapshot.unitSet } : {}),
|
||||
generalActionModules: (() => {
|
||||
const noOp = {};
|
||||
return createRefOrderedActionStack({
|
||||
nation: noOp,
|
||||
officer: noOp,
|
||||
domestic: noOp,
|
||||
war: noOp,
|
||||
personality: {
|
||||
eventHandlers: {},
|
||||
onCalcStat: (_context, statName, value) => {
|
||||
if (typeof value !== 'number') return value;
|
||||
if (statName === 'experience') return value * 0.9;
|
||||
if (statName === 'addDex') return value * 0.5;
|
||||
return value;
|
||||
},
|
||||
} satisfies GeneralActionModule,
|
||||
crewType: null,
|
||||
inheritance: noOp,
|
||||
scenario: null,
|
||||
items: [],
|
||||
});
|
||||
})(),
|
||||
};
|
||||
|
||||
// 2. Draft Troops
|
||||
@@ -145,8 +178,20 @@ describe('Troop Management Scenario', () => {
|
||||
|
||||
const generalAfterDraft = world.getGeneral(1)!;
|
||||
expect(generalAfterDraft.crew).toBe(1000);
|
||||
// Ref's General::addExperience() applies personality/item stat modules
|
||||
// after rounding the crew-based base reward. Dedication is routed
|
||||
// through the same pipeline but remains unchanged in this fixture.
|
||||
expect(generalAfterDraft.experience).toBe(109);
|
||||
expect(generalAfterDraft.dedication).toBe(110);
|
||||
// General::addDex(): 보병(armType 1)은 징병 인원 / 100만큼 숙련도가 오른다.
|
||||
expect(generalAfterDraft.meta.dex1).toBe(10);
|
||||
expect(generalAfterDraft.meta.dex1).toBe(5);
|
||||
// Ref che_징병 stores aux.armType and its AI keeps that category on
|
||||
// later recruitment decisions.
|
||||
expect(generalAfterDraft.meta.armType).toBe(1);
|
||||
// 훈련/사기진작의 실제 증가분과 addDex 파이프라인을 관찰할 여유를 만든다.
|
||||
world.snapshot.generals = world.snapshot.generals.map((general) =>
|
||||
general.id === generalAfterDraft.id ? { ...general, train: 80, atmos: 80 } : general
|
||||
);
|
||||
|
||||
// 3. Train
|
||||
const trainDef = trainSpec.createDefinition(systemEnv);
|
||||
@@ -162,6 +207,10 @@ describe('Troop Management Scenario', () => {
|
||||
const generalAfterTrain = world.getGeneral(1)!;
|
||||
// 레거시 훈련식: round(통솔 * 100 * trainDelta / 병력), 상한까지 적용.
|
||||
expect(generalAfterTrain.train).toBe(100);
|
||||
// General::addExperience()/addDedication()/addDex()와 동일하게 모듈 보정을 거친다.
|
||||
expect(generalAfterTrain.experience).toBe(199);
|
||||
expect(generalAfterTrain.dedication).toBe(180);
|
||||
expect(generalAfterTrain.meta.dex1).toBe(15);
|
||||
|
||||
// 4. Boost Morale
|
||||
const atmosDef = atmosSpec.createDefinition(systemEnv);
|
||||
@@ -177,5 +226,8 @@ describe('Troop Management Scenario', () => {
|
||||
const generalAfterAtmos = world.getGeneral(1)!;
|
||||
// 레거시 사기진작식: round(통솔 * 100 / 병력 * atmosDelta), 명령 상한까지 적용.
|
||||
expect(generalAfterAtmos.atmos).toBe(100);
|
||||
expect(generalAfterAtmos.experience).toBe(289);
|
||||
expect(generalAfterAtmos.dedication).toBe(250);
|
||||
expect(generalAfterAtmos.meta.dex1).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createGeneralTriggerContext } from '../src/triggers/general.js';
|
||||
import {
|
||||
createTraitCatalog,
|
||||
createTraitModules,
|
||||
loadEventDomesticTraitModules,
|
||||
loadDomesticTraitModules,
|
||||
loadWarTraitModules,
|
||||
} from '../src/actionModules/traits/index.js';
|
||||
@@ -251,6 +252,40 @@ describe('trait modules', () => {
|
||||
expect(general.triggerState.flags['pre.치료']).toBe(true);
|
||||
});
|
||||
|
||||
it('assigns event-의술 healing draws in legacy general-id order', async () => {
|
||||
const eventDomestic = await loadEventDomesticTraitModules(['che_event_의술']);
|
||||
const registry = createTraitCatalog({ domestic: eventDomestic });
|
||||
const traitModules = createTraitModules(registry);
|
||||
const pipeline = new GeneralActionPipeline(traitModules.general);
|
||||
const healer = buildGeneral({
|
||||
id: 818,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: 'che_event_의술',
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
});
|
||||
const lowerIdPatient = buildGeneral({ id: 444, name: 'Lower', injury: 12 });
|
||||
const higherIdPatient = buildGeneral({ id: 775, name: 'Higher', injury: 30 });
|
||||
const worldView = {
|
||||
listGeneralsByCity: (_cityId: number) => [healer, higherIdPatient, lowerIdPatient],
|
||||
listGenerals: () => [healer, higherIdPatient, lowerIdPatient],
|
||||
};
|
||||
let draw = 0;
|
||||
const rng: RandomGenerator = {
|
||||
nextFloat1: () => 0,
|
||||
nextBool: () => draw++ === 0,
|
||||
nextInt: (minInclusive: number, _maxExclusive: number) => minInclusive,
|
||||
};
|
||||
const triggerContext = createGeneralTriggerContext({ general: healer, rng, worldView });
|
||||
|
||||
pipeline.getPreTurnExecuteTriggerList({ general: healer, worldView }).fire(triggerContext, {});
|
||||
|
||||
expect(lowerIdPatient.injury).toBe(0);
|
||||
expect(higherIdPatient.injury).toBe(30);
|
||||
});
|
||||
|
||||
it('activates 의술 battle trigger and reduces damage', async () => {
|
||||
const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']);
|
||||
const war = await loadWarTraitModules(['che_의술', 'che_징병']);
|
||||
|
||||
@@ -170,13 +170,49 @@ describe('war aftermath', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(attackerNation.meta.tech).toBe(1000.6);
|
||||
expect(defenderNation.meta.tech).toBe(1000.9);
|
||||
expect(attackerNation.meta.tech).toBe(Math.fround(1000.6));
|
||||
expect(defenderNation.meta.tech).toBe(Math.fround(1000.9));
|
||||
expect(outcome.diplomacyDeltas).toHaveLength(2);
|
||||
expect(attackerCity.meta.dead).toBe(60);
|
||||
expect(defenderCity.meta.dead).toBe(90);
|
||||
});
|
||||
|
||||
it('truncates each city casualty split before accumulating it', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
const attackerCity = buildCity(1, 1);
|
||||
const defenderCity = buildCity(2, 2);
|
||||
attackerCity.meta.dead = 10;
|
||||
defenderCity.meta.dead = 20;
|
||||
const attacker = buildGeneral(1, 1, 1);
|
||||
|
||||
resolveWarAftermath({
|
||||
battle: {
|
||||
attacker,
|
||||
defenders: [],
|
||||
defenderCity,
|
||||
logs: [],
|
||||
conquered: false,
|
||||
reports: [
|
||||
{ id: attacker.id, type: 'general', name: attacker.name, isAttacker: true, killed: 101, dead: 52 },
|
||||
],
|
||||
},
|
||||
attackerNation,
|
||||
defenderNation,
|
||||
attackerCity,
|
||||
defenderCity,
|
||||
nations: [attackerNation, defenderNation],
|
||||
cities: [attackerCity, defenderCity],
|
||||
generals: [attacker],
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
});
|
||||
|
||||
expect(attackerCity.meta.dead).toBe(71);
|
||||
expect(defenderCity.meta.dead).toBe(111);
|
||||
});
|
||||
|
||||
it('logs emergency relocation when a surviving nation loses its capital', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
@@ -307,6 +343,62 @@ describe('war aftermath', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('matches ruined-nation lord ordering and NPC appointment draws', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
defenderNation.chiefGeneralId = 10;
|
||||
const attackerCity = buildCity(1, 1);
|
||||
const defenderCity = buildCity(2, 2);
|
||||
const attacker = buildGeneral(1, 1, 1);
|
||||
const lord = buildGeneral(10, 2, 2);
|
||||
const npc = buildGeneral(2, 2, 2);
|
||||
npc.npcState = 2;
|
||||
|
||||
const rangeDraws = [0.2, 0.21, 0.4, 0.41];
|
||||
const nextBool = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||
const rng = {
|
||||
nextRange: vi.fn(() => rangeDraws.shift()!),
|
||||
nextBool,
|
||||
nextRangeInt: vi.fn(() => 6),
|
||||
} as unknown as RandUtil;
|
||||
|
||||
const outcome = resolveWarAftermath({
|
||||
battle: {
|
||||
attacker,
|
||||
defenders: [],
|
||||
defenderCity,
|
||||
logs: [],
|
||||
conquered: true,
|
||||
reports: [],
|
||||
},
|
||||
attackerNation,
|
||||
defenderNation,
|
||||
attackerCity,
|
||||
defenderCity,
|
||||
nations: [attackerNation, defenderNation],
|
||||
cities: [attackerCity, defenderCity],
|
||||
// The caller order deliberately puts the lord first.
|
||||
generals: [attacker, lord, npc],
|
||||
unitSet: buildUnitSet(),
|
||||
config: {
|
||||
...buildConfig(),
|
||||
joinMode: 'full',
|
||||
joinRuinedNpcProbability: 0.1,
|
||||
},
|
||||
time: { year: 186, month: 1, startYear: 179 },
|
||||
rng,
|
||||
});
|
||||
|
||||
expect(npc.gold).toBe(800);
|
||||
expect(npc.rice).toBe(790);
|
||||
expect(lord.gold).toBe(600);
|
||||
expect(lord.rice).toBe(590);
|
||||
expect(nextBool.mock.calls.map(([probability]) => probability)).toEqual([0.5, 0.1, 0.5]);
|
||||
expect(outcome.conquest?.ruinedNpcJoinPlans).toEqual([
|
||||
{ generalId: npc.id, destNationId: attackerNation.id, joinTurn: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches city conquest to every stationed defender before collapse RNG', () => {
|
||||
const rng = new RandUtil(new ConstantRNG(0));
|
||||
const draws = [0.01, 0.02, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
|
||||
|
||||
@@ -166,6 +166,64 @@ const buildGeneral = (strength: number): General => ({
|
||||
});
|
||||
|
||||
describe('war triggers', () => {
|
||||
it('normalizes accumulated dexterity to the PHP SQL float precision', () => {
|
||||
const general = buildGeneral(80);
|
||||
general.meta.dex4 = 14_677.199999999997;
|
||||
const wizard = new WarCrewType({
|
||||
...buildUnitSet().crewTypes![0]!,
|
||||
id: 104,
|
||||
armType: 4,
|
||||
name: '귀병',
|
||||
});
|
||||
const unit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
general,
|
||||
buildCity(),
|
||||
buildNation(),
|
||||
true,
|
||||
wizard,
|
||||
new ActionLogger({ generalId: 1, nationId: 1 }),
|
||||
new WarActionPipeline([])
|
||||
);
|
||||
|
||||
unit.addDex(wizard, 2047);
|
||||
|
||||
expect(general.meta.dex4).toBe(16_519.5);
|
||||
});
|
||||
|
||||
it('preserves the legacy fractional morale gain after a win', () => {
|
||||
const general = buildGeneral(80);
|
||||
general.atmos = 105;
|
||||
const crewType = new WarCrewType(buildUnitSet().crewTypes![0]!);
|
||||
const unit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
general,
|
||||
buildCity(),
|
||||
buildNation(),
|
||||
true,
|
||||
crewType,
|
||||
new ActionLogger({ generalId: 1, nationId: 1 }),
|
||||
new WarActionPipeline([])
|
||||
);
|
||||
const defenderCity = new WarUnitCity(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
{ ...buildCity(), nationId: 2 },
|
||||
{ ...buildNation(), id: 2 },
|
||||
new WarCrewType(buildUnitSet().crewTypes![1]!),
|
||||
new ActionLogger({ generalId: 0, nationId: 2 }),
|
||||
200,
|
||||
180
|
||||
);
|
||||
unit.setOppose(defenderCity);
|
||||
|
||||
unit.addWin();
|
||||
|
||||
expect(general.atmos).toBeCloseTo(115.5, 12);
|
||||
});
|
||||
|
||||
it('updates the legacy experience level and applies item experience modifiers immediately', () => {
|
||||
const general = buildGeneral(80);
|
||||
general.experience = 90;
|
||||
@@ -224,6 +282,28 @@ describe('war triggers', () => {
|
||||
expect(general.meta.dex5).toBe(5090);
|
||||
});
|
||||
|
||||
it('consumes one accumulated stat-exp threshold when a battle finishes', () => {
|
||||
const general = buildGeneral(80);
|
||||
general.meta.intel_exp = 30;
|
||||
const crewType = new WarCrewType(buildUnitSet().crewTypes![0]!);
|
||||
const unit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
general,
|
||||
buildCity(),
|
||||
buildNation(),
|
||||
true,
|
||||
crewType,
|
||||
new ActionLogger({ generalId: 1, nationId: 1 }),
|
||||
new WarActionPipeline([])
|
||||
);
|
||||
|
||||
unit.finishBattle();
|
||||
|
||||
expect(general.stats.intelligence).toBe(71);
|
||||
expect(general.meta.intel_exp).toBe(0);
|
||||
});
|
||||
|
||||
it('activates and applies critical damage', async () => {
|
||||
const rng = new RandUtil(new ConstantRNG(0));
|
||||
const config = buildConfig();
|
||||
@@ -326,6 +406,30 @@ describe('war triggers', () => {
|
||||
expect(city.conflict).toEqual({ 1: 1.05, 2: 1 });
|
||||
expect(city.meta.conflict_order).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('uses the legacy virtual rice reserve for a neutral defender', () => {
|
||||
const events: string[] = [];
|
||||
const city = { ...buildCity(), nationId: 0 };
|
||||
|
||||
resolveWarBattle({
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
attacker: {
|
||||
general: buildGeneral(80),
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
},
|
||||
defenders: [],
|
||||
defenderCity: city,
|
||||
defenderNation: null,
|
||||
trace: (event) => events.push(event.event),
|
||||
});
|
||||
|
||||
expect(events).not.toContain('supply_retreat');
|
||||
expect(events).toContain('phase_damage');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWarBattle', () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { round } from '../src/war/utils.js';
|
||||
|
||||
describe('legacy war rounding', () => {
|
||||
it('matches PHP round() at drifted positive and negative half boundaries', () => {
|
||||
expect(round(4159.499999999999)).toBe(4160);
|
||||
expect(round(-4159.499999999999)).toBe(-4160);
|
||||
});
|
||||
|
||||
it('keeps values meaningfully below a half boundary on the lower integer', () => {
|
||||
expect(round(4159.499999)).toBe(4159);
|
||||
expect(round(-4159.499999)).toBe(-4159);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getCityDistance, searchDistance } from '@sammo-ts/logic/world/distance.js';
|
||||
import { getCityDistance, searchDistance, searchDistanceEntries } from '@sammo-ts/logic/world/distance.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
|
||||
describe('World Distance', () => {
|
||||
@@ -69,5 +69,22 @@ describe('World Distance', () => {
|
||||
const result = searchDistance(mockMap, 1, 10);
|
||||
expect(result).not.to.have.property('8');
|
||||
});
|
||||
|
||||
it('preserves legacy BFS visit order independently of numeric object-key ordering', () => {
|
||||
const orderMap: MapDefinition = {
|
||||
id: 'order-map',
|
||||
name: 'Order Map',
|
||||
cities: [
|
||||
{ id: 1, connections: [10, 2] },
|
||||
{ id: 10, connections: [1] },
|
||||
{ id: 2, connections: [1] },
|
||||
] as any[],
|
||||
};
|
||||
expect(searchDistanceEntries(orderMap, 1, 1)).toEqual([
|
||||
[1, 0],
|
||||
[10, 1],
|
||||
[2, 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,38 @@ describe('scenario bootstrap', () => {
|
||||
special: 'Special',
|
||||
text: 'Test line',
|
||||
},
|
||||
{
|
||||
affinity: 11,
|
||||
name: 'MedicalGeneral',
|
||||
picture: null,
|
||||
nation: 1,
|
||||
city: 'Alpha',
|
||||
leadership: 40,
|
||||
strength: 40,
|
||||
intelligence: 70,
|
||||
officerLevel: 1,
|
||||
birthYear: 180,
|
||||
deathYear: 240,
|
||||
personality: '출세',
|
||||
special: '의술',
|
||||
text: null,
|
||||
},
|
||||
{
|
||||
affinity: 12,
|
||||
name: 'ChargeGeneral',
|
||||
picture: null,
|
||||
nation: 1,
|
||||
city: 'Alpha',
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intelligence: 20,
|
||||
officerLevel: 1,
|
||||
birthYear: 180,
|
||||
deathYear: 240,
|
||||
personality: '패권',
|
||||
special: '돌격',
|
||||
text: null,
|
||||
},
|
||||
],
|
||||
generalsEx: [],
|
||||
generalsNeutral: [],
|
||||
@@ -129,17 +161,49 @@ describe('scenario bootstrap', () => {
|
||||
expect(result.snapshot.generals[0]?.crewTypeId).toBe(1200);
|
||||
expect(result.snapshot.generals[0]?.role.specialDomestic).toBe('Special');
|
||||
expect(result.snapshot.generals[0]?.role.specialWar).toBeNull();
|
||||
expect(result.snapshot.generals[1]?.role).toMatchObject({
|
||||
personality: 'che_출세',
|
||||
specialDomestic: 'che_event_의술',
|
||||
specialWar: null,
|
||||
});
|
||||
expect(result.seed.generals[1]).toMatchObject({
|
||||
special: 'che_event_의술',
|
||||
specialWar: null,
|
||||
});
|
||||
expect(result.snapshot.generals[2]?.role).toMatchObject({
|
||||
personality: 'che_패권',
|
||||
specialDomestic: 'che_event_돌격',
|
||||
specialWar: null,
|
||||
});
|
||||
expect(result.snapshot.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 });
|
||||
expect(result.seed.generals[0]?.meta).toMatchObject({
|
||||
deathMonth: expect.any(Number),
|
||||
specage: 25,
|
||||
specage2: 30,
|
||||
});
|
||||
const preOpening = buildScenarioBootstrap({
|
||||
scenario,
|
||||
map,
|
||||
unitSet,
|
||||
options: { initialYear: scenario.startYear! - 1 },
|
||||
});
|
||||
expect(preOpening.snapshot.generals[0]).toMatchObject({
|
||||
age: 19,
|
||||
meta: { specage: 25, specage2: 30 },
|
||||
});
|
||||
expect(preOpening.seed.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 });
|
||||
expect(buildScenarioBootstrap({ scenario, map, unitSet }).snapshot.generals[0]?.meta).toEqual(
|
||||
result.snapshot.generals[0]?.meta
|
||||
);
|
||||
expect(result.seed.generals[0]?.npcType).toBe(2);
|
||||
expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario');
|
||||
expect(result.seed.events[0]).toEqual(['pre_month', 9_000, true, ['UpdateCitySupply'], ['ProcessWarIncome']]);
|
||||
expect(result.seed.events.flat(3)).toContain('ProcessSemiAnnual');
|
||||
expect(result.seed.events.flat(3)).toContain('NewYear');
|
||||
expect(result.seed.initialEvents[0]).toEqual([
|
||||
true,
|
||||
['NoticeToHistoryLog', '<S>2년간 거병 및 건국이 가능합니다.</>', 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it('places generals without an explicit city in a deterministic valid city', () => {
|
||||
@@ -210,7 +274,7 @@ describe('scenario bootstrap', () => {
|
||||
cities: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
ignoreDefaultEvents: false,
|
||||
ignoreDefaultEvents: true,
|
||||
};
|
||||
const map: MapDefinition = {
|
||||
id: 'test-map',
|
||||
@@ -248,6 +312,36 @@ describe('scenario bootstrap', () => {
|
||||
expect(first.seed.generals[0]?.cityId).toBe(1);
|
||||
expect([1, 2]).toContain(first.seed.generals[1]?.cityId);
|
||||
expect(first.seed.generals.every((general) => general.cityId > 0)).toBe(true);
|
||||
expect(
|
||||
first.seed.generals.map((general) => ({
|
||||
cityId: general.cityId,
|
||||
affinity: general.affinity,
|
||||
personality: general.personality,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
deathMonth: general.meta.deathMonth,
|
||||
initialTurnOffsetMicros: general.meta.initialTurnOffsetMicros,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
cityId: 1,
|
||||
affinity: 10,
|
||||
personality: 'che_안전',
|
||||
experience: 2_000,
|
||||
dedication: 2_000,
|
||||
deathMonth: 12,
|
||||
initialTurnOffsetMicros: 2_161_529_667,
|
||||
},
|
||||
{
|
||||
cityId: 2,
|
||||
affinity: 20,
|
||||
personality: 'che_재간',
|
||||
experience: 2_000,
|
||||
dedication: 2_000,
|
||||
deathMonth: 7,
|
||||
initialTurnOffsetMicros: 3_203_248_275,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('defers future generals into birth-year registration events and omits expired rows', () => {
|
||||
@@ -306,7 +400,7 @@ describe('scenario bootstrap', () => {
|
||||
cities: [],
|
||||
events: [['Month', 500, ['Date', '>=', 200, 1], ['Existing']]],
|
||||
initialEvents: [],
|
||||
ignoreDefaultEvents: false,
|
||||
ignoreDefaultEvents: true,
|
||||
};
|
||||
const map: MapDefinition = {
|
||||
id: 'test-map',
|
||||
|
||||
Reference in New Issue
Block a user