fix(game): preserve scenario NPC progression

This commit is contained in:
2026-08-02 08:20:42 +00:00
parent e3c5d03cbe
commit 791f6e1395
7 changed files with 227 additions and 29 deletions
+37 -15
View File
@@ -31,6 +31,12 @@ export interface ScenarioBootstrapOptions {
defaultCrewTypeId?: number;
nationTypePrefix?: string;
mapDefaults?: Partial<MapDefaults>;
/**
* Legacy scenario installation uses the world's hidden seed while resolving
* generals whose city is omitted. Keep this input explicit so bootstrap
* remains deterministic in tests and reset operations.
*/
hiddenSeed?: string | number;
}
export type ScenarioBootstrapWarningCode =
@@ -208,11 +214,7 @@ const resolveGeneralBootstrapDisposition = (
return 'active';
};
const buildDelayedGeneralAction = (
general: ScenarioGeneral,
nationId: number,
npcType: 2 | 6
): unknown[] => {
const buildDelayedGeneralAction = (general: ScenarioGeneral, nationId: number, npcType: 2 | 6): unknown[] => {
const common = [
general.affinity ?? 0,
general.name,
@@ -309,6 +311,9 @@ const buildGeneralSeeds = (
nationNameToId: Map<string, number>,
warnings: ScenarioBootstrapWarning[],
defaultCrewTypeId: number,
mapCities: MapDefinition['cities'],
nationCityIds: Map<number, number[]>,
placementRng: RandUtil,
options?: ScenarioBootstrapOptions
): {
seeds: GeneralSeed[];
@@ -330,7 +335,14 @@ const buildGeneralSeeds = (
nextId += 1;
const nationId = resolveNationId(row.nation, nationNameToId, warnings, row.name);
const cityId = resolveCityId(row.city, cityByName, warnings, row.name);
let cityId = resolveCityId(row.city, cityByName, warnings, row.name);
if (row.city === null) {
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);
}
}
const birthYear = resolveBirthYear(row.birthYear, scenario.startYear);
const deathYear = resolveDeathYear(row.deathYear, birthYear, scenario.startYear);
const deathMonth = resolveScenarioGeneralDeathMonth({
@@ -620,6 +632,9 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
}
const mapDefaults = resolveMapDefaults(map, options);
const placementRng = new RandUtil(
new LiteHashDRBG(simpleSerialize(options?.hiddenSeed ?? scenario.title, 'InitScenarioGeneralCities'))
);
const defaultCrewTypeId = unitSet?.defaultCrewTypeId ?? options?.defaultCrewTypeId ?? DEFAULT_CREWTYPE_ID;
const seedCities: CitySeed[] = [];
const domainCities: City[] = [];
@@ -733,6 +748,9 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
nationNameToId,
warnings,
defaultCrewTypeId,
map.cities,
nationCityIds,
placementRng,
options
);
allGeneralSeeds.push(...generalResult.seeds);
@@ -749,6 +767,9 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
nationNameToId,
warnings,
defaultCrewTypeId,
map.cities,
nationCityIds,
placementRng,
options
);
allGeneralSeeds.push(...generalExResult.seeds);
@@ -765,20 +786,21 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
nationNameToId,
warnings,
defaultCrewTypeId,
map.cities,
nationCityIds,
placementRng,
options
);
allGeneralSeeds.push(...generalNeutralResult.seeds);
allGenerals.push(...generalNeutralResult.generals);
const delayedGeneralEvents = Array.from(delayedActionsByBirthYear.entries()).map(
([birthYear, actions]) => [
'Month',
1_000,
['Date', '>=', birthYear + ADULT_GENERAL_AGE, 1],
...actions,
['DeleteEvent'],
]
);
const delayedGeneralEvents = Array.from(delayedActionsByBirthYear.entries()).map(([birthYear, actions]) => [
'Month',
1_000,
['Date', '>=', birthYear + ADULT_GENERAL_AGE, 1],
...actions,
['DeleteEvent'],
]);
const events = [...scenario.events, ...delayedGeneralEvents];
const seed: WorldSeedPayload = {
+109 -5
View File
@@ -142,6 +142,114 @@ describe('scenario bootstrap', () => {
expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario');
});
it('places generals without an explicit city in a deterministic valid city', () => {
const scenario: ScenarioDefinition = {
title: 'Random placement',
startYear: 200,
life: null,
fiction: null,
history: [],
config: {
stat: { total: 100, min: 10, max: 70, npcTotal: 80, npcMax: 60, npcMin: 5, chiefMin: 50 },
iconPath: '.',
map: {},
const: {},
environment: { mapName: 'test-map', unitSet: 'test-unit' },
},
nations: [
{
id: 1,
name: 'TestNation',
color: '#123456',
gold: 5000,
rice: 3000,
infoText: null,
tech: 100,
type: 'Test',
level: 3,
cities: ['Alpha'],
},
],
diplomacy: [],
generals: [
{
affinity: 10,
name: 'NationGeneral',
picture: null,
nation: 1,
city: null,
leadership: 50,
strength: 50,
intelligence: 50,
officerLevel: 1,
birthYear: 180,
deathYear: 240,
personality: null,
special: '',
text: '',
},
{
affinity: 20,
name: 'NeutralGeneral',
picture: null,
nation: null,
city: null,
leadership: 50,
strength: 50,
intelligence: 50,
officerLevel: 0,
birthYear: 180,
deathYear: 240,
personality: null,
special: '',
text: '',
},
],
generalsEx: [],
generalsNeutral: [],
cities: [],
events: [],
initialEvents: [],
ignoreDefaultEvents: false,
};
const map: MapDefinition = {
id: 'test-map',
name: 'test-map',
cities: [
{
id: 1,
name: 'Alpha',
level: 5,
region: 1,
position: { x: 0, y: 0 },
connections: [2],
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: 'Beta',
level: 5,
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 },
},
],
};
const first = buildScenarioBootstrap({ scenario, map, options: { hiddenSeed: 'placement-seed' } });
const second = buildScenarioBootstrap({ scenario, map, options: { hiddenSeed: 'placement-seed' } });
expect(first.seed.generals.map((general) => general.cityId)).toEqual(
second.seed.generals.map((general) => general.cityId)
);
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);
});
it('defers future generals into birth-year registration events and omits expired rows', () => {
const general = (
name: string,
@@ -192,11 +300,7 @@ describe('scenario bootstrap', () => {
},
],
diplomacy: [],
generals: [
general('현재', 180, 240),
general('미래1', 190, 250, 'TestNation'),
general('만료', 170, 200),
],
generals: [general('현재', 180, 240), general('미래1', 190, 250, 'TestNation'), general('만료', 170, 200)],
generalsEx: [general('미래확장', 190, 260)],
generalsNeutral: [general('미래재야', 191, 260, 0)],
cities: [],