fix: align scenario 2400 monthly parity
This commit is contained in:
@@ -10,10 +10,11 @@ import type {
|
||||
} from '@sammo-ts/logic';
|
||||
import { evaluateConstraints } from '@sammo-ts/logic';
|
||||
import type { ConstraintContext } from '@sammo-ts/logic';
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||
import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
|
||||
import type { ReservedTurnEntry } from '../../reservedTurnStore.js';
|
||||
import type { TurnGeneral, TurnWorldState } from '../../types.js';
|
||||
@@ -50,6 +51,19 @@ const d징병 = 2;
|
||||
const d직전 = 3;
|
||||
const d전쟁 = 4;
|
||||
|
||||
export const calculateRecentWarTurn = (general: TurnGeneral, turnTermMinutes: number): number => {
|
||||
if (general.recentWarTick !== null && general.recentWarTick !== undefined && general.turnTick !== undefined) {
|
||||
const tickDiff = general.turnTick - general.recentWarTick;
|
||||
return tickDiff <= 0 ? 0 : Math.floor(tickDiff / GAME_TICKS_PER_TURN);
|
||||
}
|
||||
const recent = general.recentWarTime;
|
||||
if (!recent) return 12000;
|
||||
const diffMs = general.turnTime.getTime() - recent.getTime();
|
||||
if (diffMs <= 0) return 0;
|
||||
const turnMs = turnTermMinutes * 60 * 1000;
|
||||
return turnMs > 0 ? Math.floor(diffMs / turnMs) : 12000;
|
||||
};
|
||||
|
||||
export const selectNpcMessageForTurn = (
|
||||
message: unknown,
|
||||
rng: Pick<RandUtil, 'nextBool'>,
|
||||
@@ -86,6 +100,66 @@ export const resolveLegacyAiStats = (
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveLegacyAiStatsWithModules = (
|
||||
general: TurnGeneral,
|
||||
nation: Nation | null | undefined,
|
||||
maxStatLevel: number,
|
||||
modules: TurnCommandEnv['generalActionModules'],
|
||||
worldRef: AiWorldView | null,
|
||||
world: TurnWorldState,
|
||||
startYear: number
|
||||
) => {
|
||||
const maxLevel = Math.max(1, maxStatLevel);
|
||||
const clampStat = (value: number): number => Math.max(0, Math.min(value, maxLevel));
|
||||
const pipeline = new GeneralActionPipeline(modules ?? []);
|
||||
const context = {
|
||||
general,
|
||||
nation,
|
||||
...(worldRef
|
||||
? {
|
||||
worldView: {
|
||||
listGenerals: () => worldRef.listGenerals(),
|
||||
listGeneralsByCity: (cityId: number) =>
|
||||
worldRef.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
||||
listNations: () => worldRef.listNations(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
time: {
|
||||
year: world.currentYear,
|
||||
month: world.currentMonth,
|
||||
startYear,
|
||||
},
|
||||
};
|
||||
const rawStat = (statName: 'leadership' | 'strength' | 'intelligence'): number => general.stats[statName];
|
||||
const calculate = (
|
||||
statName: 'leadership' | 'strength' | 'intelligence',
|
||||
withInjury: boolean,
|
||||
withStatAdjust: boolean,
|
||||
truncate: boolean
|
||||
): number => {
|
||||
const injuryRatio = withInjury ? (100 - Math.max(0, Math.min(general.injury, 100))) / 100 : 1;
|
||||
let value = rawStat(statName) * injuryRatio;
|
||||
if (withStatAdjust && statName === 'strength') {
|
||||
value += Math.round(calculate('intelligence', withInjury, false, false) / 4);
|
||||
} else if (withStatAdjust && statName === 'intelligence') {
|
||||
value += Math.round(calculate('strength', withInjury, false, false) / 4);
|
||||
}
|
||||
value = clampStat(value);
|
||||
value = clampStat(Number(pipeline.onCalcStat(context, statName, value)));
|
||||
return truncate ? Math.trunc(value) : value;
|
||||
};
|
||||
|
||||
return {
|
||||
fullLeadership: calculate('leadership', false, true, true),
|
||||
fullStrength: calculate('strength', false, true, true),
|
||||
fullIntelligence: calculate('intelligence', false, true, true),
|
||||
effectiveLeadership: calculate('leadership', true, true, true),
|
||||
effectiveStrength: calculate('strength', true, true, true),
|
||||
effectiveIntelligence: calculate('intelligence', true, true, true),
|
||||
};
|
||||
};
|
||||
|
||||
export class GeneralAI {
|
||||
public general: TurnGeneral;
|
||||
public city?: City;
|
||||
@@ -560,6 +634,28 @@ export class GeneralAI {
|
||||
return this.buildCandidate(this.nationDefinitions, this.nationFallback, action, args, reason);
|
||||
}
|
||||
|
||||
getLastNationTurn(): Record<string, unknown> {
|
||||
return asRecord(asRecord(this.nation?.meta)[`turn_last_${this.general.officerLevel}`]);
|
||||
}
|
||||
|
||||
getLastCapitalMoveTrial(): [number, number] | null {
|
||||
const raw = asRecord(this.nation?.meta).lastCapitalMoveTrial;
|
||||
if (!Array.isArray(raw) || raw.length < 2) return null;
|
||||
const officerLevel = Number(raw[0]);
|
||||
const turnTick = Number(raw[1]);
|
||||
return Number.isFinite(officerLevel) && Number.isFinite(turnTick) ? [officerLevel, turnTick] : null;
|
||||
}
|
||||
|
||||
markCapitalMoveTrial(): void {
|
||||
if (!this.nation || this.general.turnTick === undefined) return;
|
||||
const nextMeta = {
|
||||
...(this.promotionNationMeta ?? this.nation.meta),
|
||||
lastCapitalMoveTrial: [this.general.officerLevel, this.general.turnTick],
|
||||
};
|
||||
this.nation = { ...this.nation, meta: nextMeta as Nation['meta'] };
|
||||
this.promotionNationMeta = nextMeta;
|
||||
}
|
||||
|
||||
getReservedTurn(generalId: number): ReservedTurnEntry {
|
||||
return this.reservedTurnProvider.getGeneralTurn(generalId, 0);
|
||||
}
|
||||
@@ -930,11 +1026,21 @@ export class GeneralAI {
|
||||
}
|
||||
|
||||
private refreshLegacyFullStats(): void {
|
||||
const stats = resolveLegacyAiStats(
|
||||
this.general,
|
||||
this.nation,
|
||||
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max
|
||||
);
|
||||
const stats = this.commandEnv.generalActionModules
|
||||
? resolveLegacyAiStatsWithModules(
|
||||
this.general,
|
||||
this.nation,
|
||||
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max,
|
||||
this.commandEnv.generalActionModules,
|
||||
this.worldRef,
|
||||
this.world,
|
||||
this.startYear
|
||||
)
|
||||
: resolveLegacyAiStats(
|
||||
this.general,
|
||||
this.nation,
|
||||
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max
|
||||
);
|
||||
this.general.meta = {
|
||||
...this.general.meta,
|
||||
...stats,
|
||||
@@ -1129,6 +1235,15 @@ export class GeneralAI {
|
||||
lastAttackable = yearMonth;
|
||||
worldLastAttackable.set(nationId, yearMonth);
|
||||
this.nation!.meta = { ...this.nation!.meta, last_attackable: yearMonth };
|
||||
// Ref writes nation_env.last_attackable while constructing each
|
||||
// GeneralAI instance. Carry that side effect through the existing
|
||||
// nation-meta patch channel even when no promotion was selected.
|
||||
// Promotion runs first in quarter months, so preserve a chief_set
|
||||
// patch already accumulated by choose*Promotion().
|
||||
this.promotionNationMeta = {
|
||||
...(this.promotionNationMeta ?? this.nation!.meta),
|
||||
last_attackable: yearMonth,
|
||||
};
|
||||
};
|
||||
|
||||
if (minWarTerm === null) {
|
||||
@@ -1163,19 +1278,7 @@ export class GeneralAI {
|
||||
}
|
||||
|
||||
private calcRecentWarTurn(general: TurnGeneral): number {
|
||||
const recent = general.recentWarTime;
|
||||
if (!recent) {
|
||||
return 12000;
|
||||
}
|
||||
const diffMs = general.turnTime.getTime() - recent.getTime();
|
||||
if (diffMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const turnMs = this.turnTermMinutes * 60 * 1000;
|
||||
if (turnMs <= 0) {
|
||||
return 12000;
|
||||
}
|
||||
return Math.floor(diffMs / turnMs);
|
||||
return calculateRecentWarTurn(general, this.turnTermMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,12 +121,17 @@ export const doNPC구출발령 = (ai: GeneralAI) => {
|
||||
if (lostCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const destCityId = pickRandomCityId(ai, ai.supplyCities);
|
||||
if (destCityId === null) {
|
||||
const candidates = lostCandidates.flatMap((general) => {
|
||||
const destCityId = pickRandomCityId(ai, ai.supplyCities);
|
||||
return destCityId === null ? [] : [{ general, destCityId }];
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const destGeneral = ai.rng.choice(lostCandidates);
|
||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC구출발령');
|
||||
// Ref draws one destination city for every lost general, then chooses one
|
||||
// completed (general, city) pair. The unused pairs still consume RNG.
|
||||
const picked = ai.rng.choice(candidates);
|
||||
return buildAssignmentCandidate(ai, picked.general.id, picked.destCityId, 'NPC구출발령');
|
||||
};
|
||||
|
||||
export const doNPC전방발령 = (ai: GeneralAI) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GeneralAI } from '../core.js';
|
||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { calcCityDevRatio } from '../../aiUtils.js';
|
||||
import { searchAllDistanceByCityList } from '../../distance.js';
|
||||
|
||||
@@ -9,6 +10,33 @@ export const do천도 = (ai: GeneralAI) => {
|
||||
if (!ai.map) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastTurn = ai.getLastNationTurn();
|
||||
const lastArgs =
|
||||
lastTurn.arg && typeof lastTurn.arg === 'object' ? (lastTurn.arg as Record<string, unknown>) : null;
|
||||
const lastDestination = Number(lastArgs?.destCityID ?? lastArgs?.destCityId);
|
||||
if (
|
||||
lastTurn.command === '천도' &&
|
||||
Number.isFinite(lastDestination) &&
|
||||
lastDestination !== ai.nation.capitalCityId
|
||||
) {
|
||||
const continuing = ai.buildNationCandidate('che_천도', { destCityID: lastDestination }, '천도');
|
||||
if (continuing) {
|
||||
ai.markCapitalMoveTrial();
|
||||
return continuing;
|
||||
}
|
||||
}
|
||||
|
||||
const lastTrial = ai.getLastCapitalMoveTrial();
|
||||
const currentTurnTick = ai.general.turnTick;
|
||||
if (
|
||||
lastTrial &&
|
||||
currentTurnTick !== undefined &&
|
||||
Math.abs(currentTurnTick - lastTrial[1]) < Math.floor(GAME_TICKS_PER_TURN / 2) &&
|
||||
lastTrial[0] !== ai.general.officerLevel
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const nationCities = Object.values(ai.nationCities);
|
||||
if (nationCities.length <= 1) {
|
||||
return null;
|
||||
@@ -80,5 +108,7 @@ export const do천도 = (ai: GeneralAI) => {
|
||||
}
|
||||
}
|
||||
|
||||
return ai.buildNationCandidate('che_천도', { destCityID: targetCityId }, '천도');
|
||||
const candidate = ai.buildNationCandidate('che_천도', { destCityID: targetCityId }, '천도');
|
||||
if (candidate) ai.markCapitalMoveTrial();
|
||||
return candidate;
|
||||
};
|
||||
|
||||
@@ -1236,11 +1236,26 @@ export class InMemoryTurnWorld {
|
||||
|
||||
const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule);
|
||||
if (!result.deleted?.general) {
|
||||
const resolvedGeneral = result.general ?? currentGeneral;
|
||||
const clock = this.getGameClock();
|
||||
const currentTurnTick = currentGeneral.turnTick ?? clock.dateToTick(currentGeneral.turnTime);
|
||||
const nextTurnTick =
|
||||
currentTurnTick + (clock.dateToTick(nextTurnAt) - clock.dateToTick(currentGeneral.turnTime));
|
||||
const recentWarTimeChanged =
|
||||
(resolvedGeneral.recentWarTime?.getTime() ?? null) !==
|
||||
(currentGeneral.recentWarTime?.getTime() ?? null);
|
||||
const recentWarTickChanged = resolvedGeneral.recentWarTick !== currentGeneral.recentWarTick;
|
||||
const nextGeneral = this.normalizeGeneralClock(
|
||||
normalizeGeneralDatabaseIntegers({
|
||||
...(result.general ?? currentGeneral),
|
||||
...resolvedGeneral,
|
||||
turnTime: nextTurnAt,
|
||||
turnTick: undefined,
|
||||
// Ref advances the logical tick directly. Re-encoding the
|
||||
// millisecond Date would discard its sub-millisecond tail.
|
||||
turnTick: nextTurnTick,
|
||||
// A loaded row always carries recentWarTick (often null).
|
||||
// When battle logic changes recentWarTime, discard that
|
||||
// stale tick so normalizeGeneralClock derives the new one.
|
||||
...(recentWarTimeChanged && !recentWarTickChanged ? { recentWarTick: undefined } : {}),
|
||||
})
|
||||
);
|
||||
this.generals.set(nextGeneral.id, nextGeneral);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
|
||||
@@ -25,18 +25,24 @@ const resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => {
|
||||
return typeof value === 'string' || typeof value === 'number' ? value : String(value);
|
||||
};
|
||||
|
||||
const createTurnTime = (
|
||||
const createTurnClock = (
|
||||
rng: RandUtil,
|
||||
environment: MonthlyEventEnvironment,
|
||||
tickSeconds: number
|
||||
): Date => {
|
||||
world: InMemoryTurnWorld
|
||||
): { turnTime: Date; turnTick: number } => {
|
||||
const tickSeconds = world.getState().tickSeconds;
|
||||
const turnMinutes = tickSeconds / 60;
|
||||
if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) {
|
||||
throw new Error('ProvideNPCTroopLeader requires a positive integer turn term.');
|
||||
}
|
||||
const seconds = rng.nextRangeInt(0, turnMinutes * 60 - 1);
|
||||
const fraction = rng.nextRangeInt(0, 999_999);
|
||||
return new Date(environment.turnTime.getTime() + seconds * 1_000 + Math.floor(fraction / 1_000));
|
||||
const ticksPerSecond = GAME_TICKS_PER_TURN / tickSeconds;
|
||||
const turnTick =
|
||||
world.dateToGameTick(environment.turnTime) +
|
||||
seconds * ticksPerSecond +
|
||||
Math.floor((fraction * ticksPerSecond) / 1_000_000);
|
||||
return { turnTime: world.gameTickToDate(turnTick), turnTick };
|
||||
};
|
||||
|
||||
export const createProvideNpcTroopLeaderHandler = (options: {
|
||||
@@ -51,9 +57,7 @@ export const createProvideNpcTroopLeaderHandler = (options: {
|
||||
}
|
||||
const currentLastId = world.getState().meta.lastNPCTroopLeaderID;
|
||||
let lastNpcTroopLeaderId =
|
||||
typeof currentLastId === 'number' && Number.isFinite(currentLastId)
|
||||
? Math.trunc(currentLastId)
|
||||
: 0;
|
||||
typeof currentLastId === 'number' && Number.isFinite(currentLastId) ? Math.trunc(currentLastId) : 0;
|
||||
|
||||
for (const nation of world.listNations().sort((left, right) => left.id - right.id)) {
|
||||
const maximum = MAX_LEADERS_BY_NATION_LEVEL[nation.level] ?? 0;
|
||||
@@ -85,6 +89,7 @@ export const createProvideNpcTroopLeaderHandler = (options: {
|
||||
const city = rng.choice(cityPool);
|
||||
const id = world.getNextGeneralId();
|
||||
const age = 20;
|
||||
const turnClock = createTurnClock(rng, environment, world);
|
||||
const general: TurnGeneral = {
|
||||
id,
|
||||
userId: null,
|
||||
@@ -117,7 +122,7 @@ export const createProvideNpcTroopLeaderHandler = (options: {
|
||||
picture: 'default.jpg',
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
lastTurn: { command: '휴식' },
|
||||
turnTime: createTurnTime(rng, environment, world.getState().tickSeconds),
|
||||
...turnClock,
|
||||
recentWarTime: null,
|
||||
meta: {
|
||||
killturn: 70,
|
||||
|
||||
@@ -149,13 +149,9 @@ export const createAssignGeneralSpecialityHandler = (options: {
|
||||
const defaultWar = normalizeCode(world.getScenarioConfig().const.defaultSpecialWar);
|
||||
const retirementYear = readRuntimeNumber(world, 'retirementYear', 80);
|
||||
const scenarioStat = world.getScenarioConfig().stat;
|
||||
// ref SQL에 ORDER BY가 없으므로 loader가 보존한 DB scan 순서를 두
|
||||
// domestic/war pass에서 그대로 재사용한다.
|
||||
const generals = world.listGenerals().sort((left, right) => {
|
||||
const leftOrder = readFiniteNumber(left.meta, ['legacyScanOrder']) ?? left.id;
|
||||
const rightOrder = readFiniteNumber(right.meta, ['legacyScanOrder']) ?? right.id;
|
||||
return leftOrder - rightOrder;
|
||||
});
|
||||
// Ref explicitly orders both speciality passes by general.no. This
|
||||
// avoids leaking Aria's deleted-page reuse order into gameplay RNG.
|
||||
const generals = world.listGenerals().sort((left, right) => left.id - right.id);
|
||||
|
||||
for (const general of generals) {
|
||||
if (
|
||||
|
||||
@@ -1760,6 +1760,18 @@ export const createReservedTurnHandler = async (options: {
|
||||
nationFallback,
|
||||
});
|
||||
const candidate = ai.chooseGeneralTurn(generalCommand);
|
||||
// Ref GeneralAI::calcDiplomacyState writes
|
||||
// nation_env.last_attackable for ordinary generals too. The
|
||||
// nation-turn path consumes this patch above, but most NPCs
|
||||
// never execute a nation turn.
|
||||
const generalAiNationState = ai.consumePromotionPatches();
|
||||
if (generalAiNationState.nationMeta && currentNation) {
|
||||
currentNation = {
|
||||
...currentNation,
|
||||
meta: generalAiNationState.nationMeta as Nation['meta'],
|
||||
};
|
||||
worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta });
|
||||
}
|
||||
const npcMessage = ai.consumeNpcMessage();
|
||||
if (npcMessage) {
|
||||
const messageTarget = {
|
||||
|
||||
Reference in New Issue
Block a user