From 54252dc6b44025cea89e693e7030e2d5c7b65823 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 28 Dec 2025 15:26:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9B=94=EB=93=9C=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=20=EB=B0=8F=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=EB=B6=80=ED=8A=B8=EC=8A=A4=ED=8A=B8=EB=9E=A9=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=83=80=EC=9E=85=20=EB=B0=8F=20=EB=A1=9C=EB=8D=94?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/todo.md | 2 + packages/logic/src/index.ts | 2 + packages/logic/src/ports/worldSnapshot.ts | 19 + packages/logic/src/world/bootstrap.ts | 645 +++++++++++++++++++++ packages/logic/src/world/index.ts | 3 + packages/logic/src/world/loader.ts | 68 +++ packages/logic/src/world/types.ts | 148 +++++ packages/logic/test/worldBootstrap.test.ts | 121 ++++ 8 files changed, 1008 insertions(+) create mode 100644 packages/logic/src/ports/worldSnapshot.ts create mode 100644 packages/logic/src/world/bootstrap.ts create mode 100644 packages/logic/src/world/index.ts create mode 100644 packages/logic/src/world/loader.ts create mode 100644 packages/logic/src/world/types.ts create mode 100644 packages/logic/test/worldBootstrap.test.ts diff --git a/docs/architecture/todo.md b/docs/architecture/todo.md index d961389..8fdc4a1 100644 --- a/docs/architecture/todo.md +++ b/docs/architecture/todo.md @@ -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 diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index e71ed37..f0f4a9c 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -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'; diff --git a/packages/logic/src/ports/worldSnapshot.ts b/packages/logic/src/ports/worldSnapshot.ts new file mode 100644 index 0000000..5f6b758 --- /dev/null +++ b/packages/logic/src/ports/worldSnapshot.ts @@ -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; + listCities(): Promise; + listNations(): Promise; +} + +export interface ScenarioConfigSource { + loadScenarioConfig(): Promise; + loadScenarioMeta?(): Promise; +} diff --git a/packages/logic/src/world/bootstrap.ts b/packages/logic/src/world/bootstrap.ts new file mode 100644 index 0000000..9aa06bb --- /dev/null +++ b/packages/logic/src/world/bootstrap.ts @@ -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; +} + +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, + 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, + 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, + 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, + nationNameToId: Map, + 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 = { + 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 = { + 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(); + 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(); + for (const nation of scenario.nations) { + nationNameToId.set(nation.name, nation.id); + } + + const cityOwnership = new Map(); + const nationCityIds = new Map(); + + 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 = { + 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 = { + 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 }; +}; diff --git a/packages/logic/src/world/index.ts b/packages/logic/src/world/index.ts new file mode 100644 index 0000000..b8d561d --- /dev/null +++ b/packages/logic/src/world/index.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export * from './bootstrap.js'; +export * from './loader.js'; diff --git a/packages/logic/src/world/loader.ts b/packages/logic/src/world/loader.ts new file mode 100644 index 0000000..d30367b --- /dev/null +++ b/packages/logic/src/world/loader.ts @@ -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; + 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 +): Promise => { + 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 ?? [], + }; +}; diff --git a/packages/logic/src/world/types.ts b/packages/logic/src/world/types.ts new file mode 100644 index 0000000..009d878 --- /dev/null +++ b/packages/logic/src/world/types.ts @@ -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; +} + +export interface MapDefaults { + trust: number; + trade: number; + supplyState: number; + frontState: number; +} + +export interface MapDefinition { + id: string; + name: string; + cities: MapCityDefinition[]; + defaults?: Partial; + meta?: Record; +} + +export interface UnitSetDefinition { + id: string; + name: string; + meta?: Record; +} + +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; +} + +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; +} + +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; +} + +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[]; +} diff --git a/packages/logic/test/worldBootstrap.test.ts b/packages/logic/test/worldBootstrap.test.ts new file mode 100644 index 0000000..31bd9ce --- /dev/null +++ b/packages/logic/test/worldBootstrap.test.ts @@ -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'); + }); +});