feat: 월드 스냅샷 및 시나리오 부트스트랩 관련 타입 및 로더 구현
This commit is contained in:
@@ -33,6 +33,8 @@ Move items into the main docs once they are finalized.
|
||||
- Profile selection workflow and deployment mapping
|
||||
- [AI suggestion] Define a scenario bootstrap pipeline that turns scenario/map/unit set inputs into a world snapshot or DB seed payload.
|
||||
- [AI suggestion] Implement a DB-backed world loader for turn daemon startup (nation/city/general + scenario config).
|
||||
- [AI suggestion] Add map/unit set loaders that normalize legacy `CityConstBase`/`GameUnitConstBase` into `MapDefinition`/`UnitSetDefinition` inputs.
|
||||
- [AI suggestion] Add a DB seed writer that converts `WorldSeedPayload` into DB inserts with defaults and audit logs.
|
||||
|
||||
## Legacy Engine Docs
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from './domain/entities.js';
|
||||
export type { RandomGenerator } from '@sammo-ts/common';
|
||||
export * from './ports/world.js';
|
||||
export * from './ports/worldSnapshot.js';
|
||||
export * from './scenario/index.js';
|
||||
export * from './triggers/index.js';
|
||||
export * from './world/index.js';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { City, General, Nation } from '../domain/entities.js';
|
||||
import type { ScenarioConfig } from '../scenario/types.js';
|
||||
import type { ScenarioMeta } from '../world/types.js';
|
||||
|
||||
// DB에서 월드 상태를 로드할 때 사용하는 조회 인터페이스.
|
||||
export interface WorldStateSnapshotSource<
|
||||
GeneralType extends General = General,
|
||||
CityType extends City = City,
|
||||
NationType extends Nation = Nation
|
||||
> {
|
||||
listGenerals(): Promise<GeneralType[]>;
|
||||
listCities(): Promise<CityType[]>;
|
||||
listNations(): Promise<NationType[]>;
|
||||
}
|
||||
|
||||
export interface ScenarioConfigSource {
|
||||
loadScenarioConfig(): Promise<ScenarioConfig>;
|
||||
loadScenarioMeta?(): Promise<ScenarioMeta | undefined>;
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import type {
|
||||
City,
|
||||
General,
|
||||
GeneralTriggerState,
|
||||
Nation,
|
||||
TriggerValue,
|
||||
} from '../domain/entities.js';
|
||||
import type {
|
||||
ScenarioDefinition,
|
||||
ScenarioGeneral,
|
||||
} from '../scenario/types.js';
|
||||
import type {
|
||||
CitySeed,
|
||||
GeneralSeed,
|
||||
MapDefaults,
|
||||
MapDefinition,
|
||||
NationSeed,
|
||||
ScenarioMeta,
|
||||
UnitSetDefinition,
|
||||
WorldSeedPayload,
|
||||
WorldSnapshot,
|
||||
} from './types.js';
|
||||
|
||||
export interface ScenarioBootstrapOptions {
|
||||
includeNeutralNation?: boolean;
|
||||
includeNeutralNationInSeed?: boolean;
|
||||
neutralNationName?: string;
|
||||
neutralNationColor?: string;
|
||||
defaultGeneralGold?: number;
|
||||
defaultGeneralRice?: number;
|
||||
nationTypePrefix?: string;
|
||||
mapDefaults?: Partial<MapDefaults>;
|
||||
}
|
||||
|
||||
export type ScenarioBootstrapWarningCode =
|
||||
| 'map_name_mismatch'
|
||||
| 'unit_set_mismatch'
|
||||
| 'duplicate_city_name'
|
||||
| 'unknown_city'
|
||||
| 'duplicate_city_owner'
|
||||
| 'unknown_general_city'
|
||||
| 'unknown_general_nation';
|
||||
|
||||
export interface ScenarioBootstrapWarning {
|
||||
code: ScenarioBootstrapWarningCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ScenarioBootstrapInput {
|
||||
scenario: ScenarioDefinition;
|
||||
map: MapDefinition;
|
||||
unitSet?: UnitSetDefinition;
|
||||
options?: ScenarioBootstrapOptions;
|
||||
}
|
||||
|
||||
export interface ScenarioBootstrapResult {
|
||||
snapshot: WorldSnapshot;
|
||||
seed: WorldSeedPayload;
|
||||
warnings: ScenarioBootstrapWarning[];
|
||||
}
|
||||
|
||||
const DEFAULT_NEUTRAL_NATION_NAME = '재야';
|
||||
const DEFAULT_NEUTRAL_NATION_COLOR = '#000000';
|
||||
const DEFAULT_GENERAL_GOLD = 1000;
|
||||
const DEFAULT_GENERAL_RICE = 1000;
|
||||
const DEFAULT_CITY_TRUST = 50;
|
||||
const DEFAULT_CITY_TRADE = 100;
|
||||
const DEFAULT_CITY_SUPPLY_STATE = 1;
|
||||
const DEFAULT_CITY_FRONT_STATE = 0;
|
||||
|
||||
const createScenarioMeta = (scenario: ScenarioDefinition): ScenarioMeta => ({
|
||||
title: scenario.title,
|
||||
startYear: scenario.startYear,
|
||||
life: scenario.life,
|
||||
fiction: scenario.fiction,
|
||||
history: scenario.history,
|
||||
ignoreDefaultEvents: scenario.ignoreDefaultEvents,
|
||||
});
|
||||
|
||||
const createEmptyTriggerState = (): GeneralTriggerState => ({
|
||||
flags: {},
|
||||
counters: {},
|
||||
modifiers: {},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const addTriggerMeta = (
|
||||
meta: Record<string, TriggerValue>,
|
||||
key: string,
|
||||
value: TriggerValue | null | undefined
|
||||
): void => {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
meta[key] = value;
|
||||
};
|
||||
|
||||
const resolveMapDefaults = (
|
||||
map: MapDefinition,
|
||||
options?: ScenarioBootstrapOptions
|
||||
): MapDefaults => {
|
||||
const override = options?.mapDefaults ?? {};
|
||||
return {
|
||||
trust:
|
||||
override.trust ??
|
||||
map.defaults?.trust ??
|
||||
DEFAULT_CITY_TRUST,
|
||||
trade:
|
||||
override.trade ??
|
||||
map.defaults?.trade ??
|
||||
DEFAULT_CITY_TRADE,
|
||||
supplyState:
|
||||
override.supplyState ??
|
||||
map.defaults?.supplyState ??
|
||||
DEFAULT_CITY_SUPPLY_STATE,
|
||||
frontState:
|
||||
override.frontState ??
|
||||
map.defaults?.frontState ??
|
||||
DEFAULT_CITY_FRONT_STATE,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveNationType = (
|
||||
rawType: string,
|
||||
prefix: string
|
||||
): string => {
|
||||
const trimmed = rawType.trim();
|
||||
if (!trimmed) {
|
||||
return `${prefix}중립`;
|
||||
}
|
||||
if (trimmed.includes('_')) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${prefix}${trimmed}`;
|
||||
};
|
||||
|
||||
const resolveBirthYear = (
|
||||
birthYear: number,
|
||||
startYear: number | null
|
||||
): number => {
|
||||
if (birthYear > 0) {
|
||||
return birthYear;
|
||||
}
|
||||
if (startYear !== null) {
|
||||
return startYear - 20;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const resolveDeathYear = (
|
||||
deathYear: number,
|
||||
birthYear: number,
|
||||
startYear: number | null
|
||||
): number => {
|
||||
if (deathYear > 0) {
|
||||
return deathYear;
|
||||
}
|
||||
if (birthYear > 0) {
|
||||
return birthYear + 60;
|
||||
}
|
||||
if (startYear !== null) {
|
||||
return startYear + 60;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const resolveAge = (startYear: number | null, birthYear: number): number => {
|
||||
if (startYear === null || birthYear <= 0) {
|
||||
return 20;
|
||||
}
|
||||
return Math.max(startYear - birthYear, 0);
|
||||
};
|
||||
|
||||
const resolveOfficerLevel = (officerLevel: number, nationId: number): number => {
|
||||
if (officerLevel > 0) {
|
||||
return officerLevel;
|
||||
}
|
||||
if (nationId > 0) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const resolveNationId = (
|
||||
rawNation: number | string | null,
|
||||
nationNameToId: Map<string, number>,
|
||||
warnings: ScenarioBootstrapWarning[],
|
||||
generalName: string
|
||||
): number => {
|
||||
if (rawNation === null) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof rawNation === 'number') {
|
||||
return rawNation;
|
||||
}
|
||||
const nationId = nationNameToId.get(rawNation);
|
||||
if (nationId === undefined) {
|
||||
warnings.push({
|
||||
code: 'unknown_general_nation',
|
||||
message: `General ${generalName} references unknown nation ${rawNation}.`,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
return nationId;
|
||||
};
|
||||
|
||||
const resolveCityId = (
|
||||
rawCity: string | null,
|
||||
cityByName: Map<string, { id: number }>,
|
||||
warnings: ScenarioBootstrapWarning[],
|
||||
generalName: string
|
||||
): number => {
|
||||
if (rawCity === null) {
|
||||
return 0;
|
||||
}
|
||||
const city = cityByName.get(rawCity);
|
||||
if (!city) {
|
||||
warnings.push({
|
||||
code: 'unknown_general_city',
|
||||
message: `General ${generalName} references unknown city ${rawCity}.`,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
return city.id;
|
||||
};
|
||||
|
||||
const buildGeneralSeeds = (
|
||||
rows: ScenarioGeneral[],
|
||||
npcType: number,
|
||||
startId: number,
|
||||
contextLabel: string,
|
||||
scenario: ScenarioDefinition,
|
||||
cityByName: Map<string, { id: number }>,
|
||||
nationNameToId: Map<string, number>,
|
||||
warnings: ScenarioBootstrapWarning[],
|
||||
options?: ScenarioBootstrapOptions
|
||||
): {
|
||||
seeds: GeneralSeed[];
|
||||
generals: General[];
|
||||
nextId: number;
|
||||
} => {
|
||||
const seeds: GeneralSeed[] = [];
|
||||
const generals: General[] = [];
|
||||
let nextId = startId;
|
||||
|
||||
const defaultGold = options?.defaultGeneralGold ?? DEFAULT_GENERAL_GOLD;
|
||||
const defaultRice = options?.defaultGeneralRice ?? DEFAULT_GENERAL_RICE;
|
||||
|
||||
for (const row of rows) {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
|
||||
const nationId = resolveNationId(
|
||||
row.nation,
|
||||
nationNameToId,
|
||||
warnings,
|
||||
row.name
|
||||
);
|
||||
const cityId = resolveCityId(
|
||||
row.city,
|
||||
cityByName,
|
||||
warnings,
|
||||
row.name
|
||||
);
|
||||
const birthYear = resolveBirthYear(row.birthYear, scenario.startYear);
|
||||
const deathYear = resolveDeathYear(
|
||||
row.deathYear,
|
||||
birthYear,
|
||||
scenario.startYear
|
||||
);
|
||||
const officerLevel = resolveOfficerLevel(row.officerLevel, nationId);
|
||||
const age = resolveAge(scenario.startYear, birthYear);
|
||||
const stats = {
|
||||
leadership: row.leadership,
|
||||
strength: row.strength,
|
||||
intelligence: row.intelligence,
|
||||
};
|
||||
|
||||
const seedMeta: Record<string, unknown> = {
|
||||
source: contextLabel,
|
||||
};
|
||||
if (row.affinity !== null) {
|
||||
seedMeta.affinity = row.affinity;
|
||||
}
|
||||
if (row.personality !== null) {
|
||||
seedMeta.personality = row.personality;
|
||||
}
|
||||
if (row.special !== null) {
|
||||
seedMeta.special = row.special;
|
||||
}
|
||||
if (row.picture !== null) {
|
||||
seedMeta.picture = row.picture;
|
||||
}
|
||||
if (row.text !== null) {
|
||||
seedMeta.text = row.text;
|
||||
}
|
||||
|
||||
const seed: GeneralSeed = {
|
||||
id,
|
||||
name: row.name,
|
||||
nationId,
|
||||
cityId,
|
||||
stats,
|
||||
officerLevel,
|
||||
birthYear,
|
||||
deathYear,
|
||||
affinity: row.affinity,
|
||||
personality: row.personality,
|
||||
special: row.special,
|
||||
picture: row.picture,
|
||||
npcType,
|
||||
text: row.text,
|
||||
meta: seedMeta,
|
||||
};
|
||||
seeds.push(seed);
|
||||
|
||||
const generalMeta: Record<string, TriggerValue> = {
|
||||
npcType,
|
||||
};
|
||||
addTriggerMeta(generalMeta, 'affinity', row.affinity);
|
||||
addTriggerMeta(generalMeta, 'personality', row.personality ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'special', row.special ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'picture', row.picture ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'birthYear', birthYear);
|
||||
addTriggerMeta(generalMeta, 'deathYear', deathYear);
|
||||
addTriggerMeta(generalMeta, 'text', row.text ?? undefined);
|
||||
|
||||
generals.push({
|
||||
id,
|
||||
name: row.name,
|
||||
nationId,
|
||||
cityId,
|
||||
troopId: 0,
|
||||
stats,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel,
|
||||
injury: 0,
|
||||
gold: defaultGold,
|
||||
rice: defaultRice,
|
||||
crew: 0,
|
||||
train: 0,
|
||||
age,
|
||||
npcState: npcType,
|
||||
triggerState: createEmptyTriggerState(),
|
||||
meta: generalMeta,
|
||||
});
|
||||
}
|
||||
|
||||
return { seeds, generals, nextId };
|
||||
};
|
||||
|
||||
// 시나리오, 맵, 유닛셋을 묶어 초기 월드 스냅샷과 시드 데이터를 만든다.
|
||||
export const buildScenarioBootstrap = (
|
||||
input: ScenarioBootstrapInput
|
||||
): ScenarioBootstrapResult => {
|
||||
const { scenario, map, unitSet, options } = input;
|
||||
const warnings: ScenarioBootstrapWarning[] = [];
|
||||
|
||||
const environment = scenario.config.environment;
|
||||
if (map.id !== environment.mapName && map.name !== environment.mapName) {
|
||||
warnings.push({
|
||||
code: 'map_name_mismatch',
|
||||
message: `Scenario mapName ${environment.mapName} does not match map definition ${map.id}.`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
unitSet &&
|
||||
unitSet.id !== environment.unitSet &&
|
||||
unitSet.name !== environment.unitSet
|
||||
) {
|
||||
warnings.push({
|
||||
code: 'unit_set_mismatch',
|
||||
message: `Scenario unitSet ${environment.unitSet} does not match unit set ${unitSet.id}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const cityByName = new Map<string, { id: number }>();
|
||||
for (const city of map.cities) {
|
||||
if (cityByName.has(city.name)) {
|
||||
warnings.push({
|
||||
code: 'duplicate_city_name',
|
||||
message: `Duplicate city name detected: ${city.name}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
cityByName.set(city.name, city);
|
||||
}
|
||||
|
||||
const nationNameToId = new Map<string, number>();
|
||||
for (const nation of scenario.nations) {
|
||||
nationNameToId.set(nation.name, nation.id);
|
||||
}
|
||||
|
||||
const cityOwnership = new Map<number, number>();
|
||||
const nationCityIds = new Map<number, number[]>();
|
||||
|
||||
for (const nation of scenario.nations) {
|
||||
const cityIds: number[] = [];
|
||||
for (const cityName of nation.cities) {
|
||||
const city = cityByName.get(cityName);
|
||||
if (!city) {
|
||||
warnings.push({
|
||||
code: 'unknown_city',
|
||||
message: `Nation ${nation.name} references unknown city ${cityName}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const existing = cityOwnership.get(city.id);
|
||||
if (existing !== undefined && existing !== nation.id) {
|
||||
warnings.push({
|
||||
code: 'duplicate_city_owner',
|
||||
message: `City ${cityName} is already assigned to nation ${existing}.`,
|
||||
});
|
||||
}
|
||||
cityOwnership.set(city.id, nation.id);
|
||||
cityIds.push(city.id);
|
||||
}
|
||||
nationCityIds.set(nation.id, cityIds);
|
||||
}
|
||||
|
||||
const scenarioMeta = createScenarioMeta(scenario);
|
||||
const typePrefix =
|
||||
options?.nationTypePrefix ?? `${environment.mapName}_`;
|
||||
|
||||
const seedNations: NationSeed[] = [];
|
||||
const domainNations: Nation[] = [];
|
||||
|
||||
const includeNeutralNation = options?.includeNeutralNation ?? true;
|
||||
const includeNeutralNationInSeed =
|
||||
options?.includeNeutralNationInSeed ?? false;
|
||||
if (includeNeutralNation) {
|
||||
const neutralNation: Nation = {
|
||||
id: 0,
|
||||
name: options?.neutralNationName ?? DEFAULT_NEUTRAL_NATION_NAME,
|
||||
color: options?.neutralNationColor ?? DEFAULT_NEUTRAL_NATION_COLOR,
|
||||
capitalCityId: null,
|
||||
chiefGeneralId: null,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
level: 0,
|
||||
typeCode: `${typePrefix}중립`,
|
||||
meta: {},
|
||||
};
|
||||
domainNations.push(neutralNation);
|
||||
if (includeNeutralNationInSeed) {
|
||||
seedNations.push({
|
||||
id: neutralNation.id,
|
||||
name: neutralNation.name,
|
||||
color: neutralNation.color,
|
||||
gold: neutralNation.gold,
|
||||
rice: neutralNation.rice,
|
||||
infoText: null,
|
||||
tech: 0,
|
||||
typeCode: neutralNation.typeCode,
|
||||
level: neutralNation.level,
|
||||
cityIds: [],
|
||||
capitalCityId: null,
|
||||
meta: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const nation of scenario.nations) {
|
||||
const cityIds = nationCityIds.get(nation.id) ?? [];
|
||||
const capitalCityId = cityIds[0] ?? null;
|
||||
const typeCode = resolveNationType(nation.type, typePrefix);
|
||||
|
||||
seedNations.push({
|
||||
id: nation.id,
|
||||
name: nation.name,
|
||||
color: nation.color,
|
||||
gold: nation.gold,
|
||||
rice: nation.rice,
|
||||
infoText: nation.infoText,
|
||||
tech: nation.tech,
|
||||
typeCode,
|
||||
level: nation.level,
|
||||
cityIds,
|
||||
capitalCityId,
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const nationMeta: Record<string, TriggerValue> = {
|
||||
tech: nation.tech,
|
||||
};
|
||||
addTriggerMeta(nationMeta, 'infoText', nation.infoText ?? undefined);
|
||||
|
||||
domainNations.push({
|
||||
id: nation.id,
|
||||
name: nation.name,
|
||||
color: nation.color,
|
||||
capitalCityId,
|
||||
chiefGeneralId: null,
|
||||
gold: nation.gold,
|
||||
rice: nation.rice,
|
||||
power: 0,
|
||||
level: nation.level,
|
||||
typeCode,
|
||||
meta: nationMeta,
|
||||
});
|
||||
}
|
||||
|
||||
const mapDefaults = resolveMapDefaults(map, options);
|
||||
const seedCities: CitySeed[] = [];
|
||||
const domainCities: City[] = [];
|
||||
|
||||
for (const city of map.cities) {
|
||||
const nationId = cityOwnership.get(city.id) ?? 0;
|
||||
const seed: CitySeed = {
|
||||
id: city.id,
|
||||
name: city.name,
|
||||
nationId,
|
||||
level: city.level,
|
||||
population: city.initial.population,
|
||||
populationMax: city.max.population,
|
||||
agriculture: city.initial.agriculture,
|
||||
agricultureMax: city.max.agriculture,
|
||||
commerce: city.initial.commerce,
|
||||
commerceMax: city.max.commerce,
|
||||
security: city.initial.security,
|
||||
securityMax: city.max.security,
|
||||
defence: city.initial.defence,
|
||||
defenceMax: city.max.defence,
|
||||
wall: city.initial.wall,
|
||||
wallMax: city.max.wall,
|
||||
supplyState: mapDefaults.supplyState,
|
||||
frontState: mapDefaults.frontState,
|
||||
trust: mapDefaults.trust,
|
||||
trade: mapDefaults.trade,
|
||||
region: city.region,
|
||||
position: city.position,
|
||||
connections: city.connections,
|
||||
meta: city.meta ?? {},
|
||||
};
|
||||
seedCities.push(seed);
|
||||
|
||||
const cityMeta: Record<string, TriggerValue> = {
|
||||
region: city.region,
|
||||
trust: seed.trust,
|
||||
trade: seed.trade,
|
||||
positionX: city.position.x,
|
||||
positionY: city.position.y,
|
||||
};
|
||||
|
||||
domainCities.push({
|
||||
id: seed.id,
|
||||
name: seed.name,
|
||||
nationId: seed.nationId,
|
||||
level: seed.level,
|
||||
population: seed.population,
|
||||
populationMax: seed.populationMax,
|
||||
agriculture: seed.agriculture,
|
||||
agricultureMax: seed.agricultureMax,
|
||||
commerce: seed.commerce,
|
||||
commerceMax: seed.commerceMax,
|
||||
security: seed.security,
|
||||
securityMax: seed.securityMax,
|
||||
supplyState: seed.supplyState,
|
||||
frontState: seed.frontState,
|
||||
defence: seed.defence,
|
||||
defenceMax: seed.defenceMax,
|
||||
wall: seed.wall,
|
||||
wallMax: seed.wallMax,
|
||||
meta: cityMeta,
|
||||
});
|
||||
}
|
||||
|
||||
let nextGeneralId = 1;
|
||||
const allGeneralSeeds: GeneralSeed[] = [];
|
||||
const allGenerals: General[] = [];
|
||||
|
||||
const generalResult = buildGeneralSeeds(
|
||||
scenario.generals,
|
||||
2,
|
||||
nextGeneralId,
|
||||
'general',
|
||||
scenario,
|
||||
cityByName,
|
||||
nationNameToId,
|
||||
warnings,
|
||||
options
|
||||
);
|
||||
allGeneralSeeds.push(...generalResult.seeds);
|
||||
allGenerals.push(...generalResult.generals);
|
||||
nextGeneralId = generalResult.nextId;
|
||||
|
||||
const generalExResult = buildGeneralSeeds(
|
||||
scenario.generalsEx,
|
||||
2,
|
||||
nextGeneralId,
|
||||
'general_ex',
|
||||
scenario,
|
||||
cityByName,
|
||||
nationNameToId,
|
||||
warnings,
|
||||
options
|
||||
);
|
||||
allGeneralSeeds.push(...generalExResult.seeds);
|
||||
allGenerals.push(...generalExResult.generals);
|
||||
nextGeneralId = generalExResult.nextId;
|
||||
|
||||
const generalNeutralResult = buildGeneralSeeds(
|
||||
scenario.generalsNeutral,
|
||||
6,
|
||||
nextGeneralId,
|
||||
'general_neutral',
|
||||
scenario,
|
||||
cityByName,
|
||||
nationNameToId,
|
||||
warnings,
|
||||
options
|
||||
);
|
||||
allGeneralSeeds.push(...generalNeutralResult.seeds);
|
||||
allGenerals.push(...generalNeutralResult.generals);
|
||||
|
||||
const seed: WorldSeedPayload = {
|
||||
scenarioConfig: scenario.config,
|
||||
scenarioMeta,
|
||||
map,
|
||||
...(unitSet ? { unitSet } : {}),
|
||||
nations: seedNations,
|
||||
cities: seedCities,
|
||||
generals: allGeneralSeeds,
|
||||
diplomacy: scenario.diplomacy,
|
||||
events: scenario.events,
|
||||
initialEvents: scenario.initialEvents,
|
||||
};
|
||||
|
||||
const snapshot: WorldSnapshot = {
|
||||
scenarioConfig: scenario.config,
|
||||
scenarioMeta,
|
||||
map,
|
||||
...(unitSet ? { unitSet } : {}),
|
||||
nations: domainNations,
|
||||
cities: domainCities,
|
||||
generals: allGenerals,
|
||||
diplomacy: scenario.diplomacy,
|
||||
events: scenario.events,
|
||||
initialEvents: scenario.initialEvents,
|
||||
};
|
||||
|
||||
return { snapshot, seed, warnings };
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './types.js';
|
||||
export * from './bootstrap.js';
|
||||
export * from './loader.js';
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { City, General, Nation } from '../domain/entities.js';
|
||||
import type { ScenarioConfig, ScenarioDiplomacy } from '../scenario/types.js';
|
||||
import type { ScenarioConfigSource, WorldStateSnapshotSource } from '../ports/worldSnapshot.js';
|
||||
import type {
|
||||
MapDefinition,
|
||||
ScenarioMeta,
|
||||
UnitSetDefinition,
|
||||
WorldSnapshot,
|
||||
} from './types.js';
|
||||
|
||||
export interface WorldSnapshotLoadInput<
|
||||
GeneralType extends General = General,
|
||||
CityType extends City = City,
|
||||
NationType extends Nation = Nation
|
||||
> {
|
||||
worldSource: WorldStateSnapshotSource<GeneralType, CityType, NationType>;
|
||||
scenarioConfig?: ScenarioConfig;
|
||||
scenarioMeta?: ScenarioMeta;
|
||||
scenarioSource?: ScenarioConfigSource;
|
||||
map: MapDefinition;
|
||||
unitSet?: UnitSetDefinition;
|
||||
diplomacy?: ScenarioDiplomacy[];
|
||||
events?: unknown[];
|
||||
initialEvents?: unknown[];
|
||||
}
|
||||
|
||||
// DB 기반 월드 로더: 세계 상태와 시나리오 설정을 합쳐 스냅샷을 만든다.
|
||||
export const loadWorldSnapshot = async <
|
||||
GeneralType extends General,
|
||||
CityType extends City,
|
||||
NationType extends Nation
|
||||
>(
|
||||
input: WorldSnapshotLoadInput<GeneralType, CityType, NationType>
|
||||
): Promise<WorldSnapshot> => {
|
||||
const { worldSource, scenarioSource } = input;
|
||||
|
||||
const scenarioConfig =
|
||||
input.scenarioConfig ??
|
||||
(scenarioSource ? await scenarioSource.loadScenarioConfig() : undefined);
|
||||
if (!scenarioConfig) {
|
||||
throw new Error('Scenario config is required to load world snapshot.');
|
||||
}
|
||||
|
||||
const scenarioMeta =
|
||||
input.scenarioMeta ??
|
||||
(scenarioSource?.loadScenarioMeta
|
||||
? await scenarioSource.loadScenarioMeta()
|
||||
: undefined);
|
||||
|
||||
const [generals, cities, nations] = await Promise.all([
|
||||
worldSource.listGenerals(),
|
||||
worldSource.listCities(),
|
||||
worldSource.listNations(),
|
||||
]);
|
||||
|
||||
return {
|
||||
scenarioConfig,
|
||||
...(scenarioMeta ? { scenarioMeta } : {}),
|
||||
map: input.map,
|
||||
...(input.unitSet ? { unitSet: input.unitSet } : {}),
|
||||
nations,
|
||||
cities,
|
||||
generals,
|
||||
diplomacy: input.diplomacy ?? [],
|
||||
events: input.events ?? [],
|
||||
initialEvents: input.initialEvents ?? [],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { City, General, Nation, StatBlock } from '../domain/entities.js';
|
||||
import type {
|
||||
ScenarioConfig,
|
||||
ScenarioDiplomacy,
|
||||
} from '../scenario/types.js';
|
||||
|
||||
export interface ScenarioMeta {
|
||||
title: string;
|
||||
startYear: number | null;
|
||||
life: number | null;
|
||||
fiction: number | null;
|
||||
history: string[];
|
||||
ignoreDefaultEvents: boolean;
|
||||
}
|
||||
|
||||
export interface MapCityStats {
|
||||
population: number;
|
||||
agriculture: number;
|
||||
commerce: number;
|
||||
security: number;
|
||||
defence: number;
|
||||
wall: number;
|
||||
}
|
||||
|
||||
export interface MapCityDefinition {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
region: number;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
connections: number[];
|
||||
max: MapCityStats;
|
||||
initial: MapCityStats;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MapDefaults {
|
||||
trust: number;
|
||||
trade: number;
|
||||
supplyState: number;
|
||||
frontState: number;
|
||||
}
|
||||
|
||||
export interface MapDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
cities: MapCityDefinition[];
|
||||
defaults?: Partial<MapDefaults>;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UnitSetDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NationSeed {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
gold: number;
|
||||
rice: number;
|
||||
infoText: string | null;
|
||||
tech: number;
|
||||
typeCode: string;
|
||||
level: number;
|
||||
cityIds: number[];
|
||||
capitalCityId: number | null;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CitySeed {
|
||||
id: number;
|
||||
name: string;
|
||||
nationId: number;
|
||||
level: number;
|
||||
population: number;
|
||||
populationMax: number;
|
||||
agriculture: number;
|
||||
agricultureMax: number;
|
||||
commerce: number;
|
||||
commerceMax: number;
|
||||
security: number;
|
||||
securityMax: number;
|
||||
defence: number;
|
||||
defenceMax: number;
|
||||
wall: number;
|
||||
wallMax: number;
|
||||
supplyState: number;
|
||||
frontState: number;
|
||||
trust: number;
|
||||
trade: number;
|
||||
region: number;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
connections: number[];
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GeneralSeed {
|
||||
id: number;
|
||||
name: string;
|
||||
nationId: number;
|
||||
cityId: number;
|
||||
stats: StatBlock;
|
||||
officerLevel: number;
|
||||
birthYear: number;
|
||||
deathYear: number;
|
||||
affinity: number | null;
|
||||
personality: string | null;
|
||||
special: string | null;
|
||||
picture: number | string | null;
|
||||
npcType: number;
|
||||
text: string | null;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WorldSeedPayload {
|
||||
scenarioConfig: ScenarioConfig;
|
||||
scenarioMeta: ScenarioMeta;
|
||||
map: MapDefinition;
|
||||
unitSet?: UnitSetDefinition;
|
||||
nations: NationSeed[];
|
||||
cities: CitySeed[];
|
||||
generals: GeneralSeed[];
|
||||
diplomacy: ScenarioDiplomacy[];
|
||||
events: unknown[];
|
||||
initialEvents: unknown[];
|
||||
}
|
||||
|
||||
export interface WorldSnapshot {
|
||||
scenarioConfig: ScenarioConfig;
|
||||
scenarioMeta?: ScenarioMeta;
|
||||
map: MapDefinition;
|
||||
unitSet?: UnitSetDefinition;
|
||||
nations: Nation[];
|
||||
cities: City[];
|
||||
generals: General[];
|
||||
diplomacy: ScenarioDiplomacy[];
|
||||
events: unknown[];
|
||||
initialEvents: unknown[];
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ScenarioDefinition } from '../src/scenario/types.js';
|
||||
import type { MapDefinition, UnitSetDefinition } from '../src/world/types.js';
|
||||
import { buildScenarioBootstrap } from '../src/world/bootstrap.js';
|
||||
|
||||
describe('scenario bootstrap', () => {
|
||||
it('builds snapshot and seed from scenario/map inputs', () => {
|
||||
const scenario: ScenarioDefinition = {
|
||||
title: 'Test Scenario',
|
||||
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: 'Test nation',
|
||||
tech: 100,
|
||||
type: 'Test',
|
||||
level: 3,
|
||||
cities: ['Alpha'],
|
||||
},
|
||||
],
|
||||
diplomacy: [],
|
||||
generals: [
|
||||
{
|
||||
affinity: 10,
|
||||
name: 'TestGeneral',
|
||||
picture: 101,
|
||||
nation: 1,
|
||||
city: 'Alpha',
|
||||
leadership: 50,
|
||||
strength: 60,
|
||||
intelligence: 55,
|
||||
officerLevel: 1,
|
||||
birthYear: 180,
|
||||
deathYear: 240,
|
||||
personality: 'Calm',
|
||||
special: 'Special',
|
||||
text: 'Test line',
|
||||
},
|
||||
],
|
||||
generalsEx: [],
|
||||
generalsNeutral: [],
|
||||
cities: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
ignoreDefaultEvents: false,
|
||||
};
|
||||
|
||||
const map: MapDefinition = {
|
||||
id: 'test-map',
|
||||
name: 'test-map',
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Alpha',
|
||||
level: 3,
|
||||
region: 1,
|
||||
position: { x: 10, y: 20 },
|
||||
connections: [],
|
||||
max: {
|
||||
population: 100000,
|
||||
agriculture: 20000,
|
||||
commerce: 20000,
|
||||
security: 15000,
|
||||
defence: 5000,
|
||||
wall: 3000,
|
||||
},
|
||||
initial: {
|
||||
population: 50000,
|
||||
agriculture: 10000,
|
||||
commerce: 10000,
|
||||
security: 8000,
|
||||
defence: 2500,
|
||||
wall: 1500,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const unitSet: UnitSetDefinition = {
|
||||
id: 'test-unit',
|
||||
name: 'test-unit',
|
||||
};
|
||||
|
||||
const result = buildScenarioBootstrap({ scenario, map, unitSet });
|
||||
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
expect(result.snapshot.nations).toHaveLength(2);
|
||||
expect(result.seed.nations).toHaveLength(1);
|
||||
expect(result.snapshot.cities[0]?.nationId).toBe(1);
|
||||
expect(result.seed.cities[0]?.nationId).toBe(1);
|
||||
expect(result.snapshot.generals[0]?.cityId).toBe(1);
|
||||
expect(result.seed.generals[0]?.npcType).toBe(2);
|
||||
expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user