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
|
- 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] 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] 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
|
## Legacy Engine Docs
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export * from './domain/entities.js';
|
export * from './domain/entities.js';
|
||||||
export type { RandomGenerator } from '@sammo-ts/common';
|
export type { RandomGenerator } from '@sammo-ts/common';
|
||||||
export * from './ports/world.js';
|
export * from './ports/world.js';
|
||||||
|
export * from './ports/worldSnapshot.js';
|
||||||
export * from './scenario/index.js';
|
export * from './scenario/index.js';
|
||||||
export * from './triggers/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