fix: match scenario 2601 monthly seed progression

This commit is contained in:
2026-08-03 18:56:23 +00:00
parent 2f17584075
commit 58b9a230a7
92 changed files with 3696 additions and 587 deletions
+221 -48
View File
@@ -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 };
+10 -4
View File
@@ -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));
+2
View File
@@ -158,6 +158,8 @@ export interface GeneralSeed {
npcType: number;
text: string | null;
crewTypeId: number;
experience?: number;
dedication?: number;
meta: Record<string, unknown>;
}