From afe5cadd9ffa3437d69d18c410b92b580e659843 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 28 Dec 2025 14:42:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(wip):=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EB=A1=9C=EB=8D=94=20=EB=B0=8F=20=ED=8C=8C=EC=84=9C?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84,=20=EA=B4=80=EB=A0=A8=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/package.json | 3 +- app/game-engine/src/index.ts | 1 + .../src/scenario/scenarioLoader.ts | 60 ++++ docs/architecture/todo.md | 2 + packages/logic/package.json | 3 +- packages/logic/src/index.ts | 1 + packages/logic/src/scenario/index.ts | 2 + packages/logic/src/scenario/parseScenario.ts | 263 ++++++++++++++++++ packages/logic/src/scenario/types.ts | 83 ++++++ packages/logic/test/scenarioParser.test.ts | 80 ++++++ pnpm-lock.yaml | 11 + 11 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 app/game-engine/src/scenario/scenarioLoader.ts create mode 100644 packages/logic/src/scenario/index.ts create mode 100644 packages/logic/src/scenario/parseScenario.ts create mode 100644 packages/logic/src/scenario/types.ts create mode 100644 packages/logic/test/scenarioParser.test.ts diff --git a/app/game-engine/package.json b/app/game-engine/package.json index 1b32821..1bd1653 100644 --- a/app/game-engine/package.json +++ b/app/game-engine/package.json @@ -10,7 +10,8 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { - "@sammo-ts/common": "workspace:*" + "@sammo-ts/common": "workspace:*", + "@sammo-ts/logic": "workspace:*" }, "devDependencies": { "tsdown": "^0.18.3", diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index 4e5a570..c5fcc1d 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -3,3 +3,4 @@ export * from './lifecycle/clock.js'; export * from './lifecycle/inMemoryControlQueue.js'; export * from './lifecycle/turnSchedule.js'; export * from './lifecycle/turnDaemonLifecycle.js'; +export * from './scenario/scenarioLoader.js'; diff --git a/app/game-engine/src/scenario/scenarioLoader.ts b/app/game-engine/src/scenario/scenarioLoader.ts new file mode 100644 index 0000000..d4cda97 --- /dev/null +++ b/app/game-engine/src/scenario/scenarioLoader.ts @@ -0,0 +1,60 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + parseScenarioDefaults, + parseScenarioDefinition, + type ScenarioDefaults, + type ScenarioDefinition, +} from '@sammo-ts/logic'; + +export interface ScenarioLoaderOptions { + scenarioRoot: string; + defaultsFileName?: string; +} + +const readJsonFile = async (filePath: string): Promise => { + const raw = await fs.readFile(filePath, 'utf8'); + return JSON.parse(raw) as unknown; +}; + +export const resolveScenarioDefaultsPath = ( + options: ScenarioLoaderOptions +): string => + path.resolve( + options.scenarioRoot, + options.defaultsFileName ?? 'default.json' + ); + +export const resolveScenarioPath = ( + options: ScenarioLoaderOptions, + scenarioId: number +): string => path.resolve(options.scenarioRoot, `scenario_${scenarioId}.json`); + +export const loadScenarioDefaults = async ( + defaultsPath: string +): Promise => { + // 기본 시나리오 파일을 읽고 정규화한다. + const raw = await readJsonFile(defaultsPath); + return parseScenarioDefaults(raw); +}; + +export const loadScenarioDefinition = async ( + scenarioPath: string, + defaults: ScenarioDefaults +): Promise => { + // 시나리오 본문을 읽고 기본값과 합쳐서 파싱한다. + const raw = await readJsonFile(scenarioPath); + return parseScenarioDefinition(raw, defaults); +}; + +export const loadScenarioDefinitionById = async ( + scenarioId: number, + options: ScenarioLoaderOptions +): Promise => { + // 시나리오 번호로 파일을 찾고 파싱한다. + const defaultsPath = resolveScenarioDefaultsPath(options); + const scenarioPath = resolveScenarioPath(options, scenarioId); + const defaults = await loadScenarioDefaults(defaultsPath); + return loadScenarioDefinition(scenarioPath, defaults); +}; diff --git a/docs/architecture/todo.md b/docs/architecture/todo.md index 79d4600..d961389 100644 --- a/docs/architecture/todo.md +++ b/docs/architecture/todo.md @@ -31,6 +31,8 @@ Move items into the main docs once they are finalized. - "Next-turn intent" (예턴) data schema and lifecycle - 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). ## Legacy Engine Docs diff --git a/packages/logic/package.json b/packages/logic/package.json index cbe78d3..0ff9430 100644 --- a/packages/logic/package.json +++ b/packages/logic/package.json @@ -18,7 +18,8 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { - "@sammo-ts/common": "workspace:*" + "@sammo-ts/common": "workspace:*", + "zod": "^4.2.1" }, "devDependencies": { "tsdown": "^0.18.3", diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index bcb5d0e..e71ed37 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -1,4 +1,5 @@ export * from './domain/entities.js'; export type { RandomGenerator } from '@sammo-ts/common'; export * from './ports/world.js'; +export * from './scenario/index.js'; export * from './triggers/index.js'; diff --git a/packages/logic/src/scenario/index.ts b/packages/logic/src/scenario/index.ts new file mode 100644 index 0000000..2275b57 --- /dev/null +++ b/packages/logic/src/scenario/index.ts @@ -0,0 +1,2 @@ +export * from './types.js'; +export * from './parseScenario.js'; diff --git a/packages/logic/src/scenario/parseScenario.ts b/packages/logic/src/scenario/parseScenario.ts new file mode 100644 index 0000000..e9489e5 --- /dev/null +++ b/packages/logic/src/scenario/parseScenario.ts @@ -0,0 +1,263 @@ +import type { + ScenarioConfig, + ScenarioDefaults, + ScenarioDefinition, + ScenarioDiplomacy, + ScenarioEnvironment, + ScenarioGeneral, + ScenarioNation, + ScenarioStatBlock, +} from './types.js'; + +type UnknownRecord = Record; + +const FALLBACK_STAT: ScenarioStatBlock = { + total: 0, + min: 0, + max: 0, + npcTotal: 0, + npcMax: 0, + npcMin: 0, + chiefMin: 0, +}; + +const isRecord = (value: unknown): value is UnknownRecord => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const asArray = (value: unknown): unknown[] => + Array.isArray(value) ? value : []; + +const asRecord = (value: unknown): UnknownRecord => + isRecord(value) ? value : {}; + +const asNumber = (value: unknown, fallback: number): number => + typeof value === 'number' ? value : fallback; + +const asString = (value: unknown, fallback: string): string => + typeof value === 'string' ? value : fallback; + +const asNullableNumber = (value: unknown): number | null => + typeof value === 'number' ? value : null; + +const asNullableString = (value: unknown): string | null => + typeof value === 'string' ? value : null; + +const asStringArray = (value: unknown): string[] => + asArray(value).filter((item): item is string => typeof item === 'string'); + +const parseScenarioStatBlock = ( + value: unknown, + fallback: ScenarioStatBlock +): ScenarioStatBlock => { + const data = asRecord(value); + return { + total: asNumber(data.total, fallback.total), + min: asNumber(data.min, fallback.min), + max: asNumber(data.max, fallback.max), + npcTotal: asNumber(data.npcTotal, fallback.npcTotal), + npcMax: asNumber(data.npcMax, fallback.npcMax), + npcMin: asNumber(data.npcMin, fallback.npcMin), + chiefMin: asNumber(data.chiefMin, fallback.chiefMin), + }; +}; + +const parseScenarioEnvironment = ( + mapConfig: UnknownRecord, + constConfig: UnknownRecord +): ScenarioEnvironment => { + const merged = { ...mapConfig, ...constConfig }; + const mapName = asString(merged.mapName, 'che'); + const unitSet = asString(merged.unitSet, 'che'); + const scenarioEffect = + typeof merged.scenarioEffect === 'string' || merged.scenarioEffect === null + ? merged.scenarioEffect + : undefined; + + return { mapName, unitSet, scenarioEffect }; +}; + +const ensureTitle = (value: unknown): string => { + if (typeof value !== 'string') { + throw new Error('Scenario title must be a string.'); + } + return value; +}; + +const parseNationRow = (row: unknown, index: number): ScenarioNation => { + if (!Array.isArray(row)) { + throw new Error(`Scenario nation row ${index} is not an array.`); + } + const [ + name, + color, + gold, + rice, + infoText, + tech, + type, + level, + cities, + ] = row; + + const nationName = asString(name, ''); + if (!nationName) { + throw new Error(`Scenario nation row ${index} has no name.`); + } + + return { + id: index + 1, + name: nationName, + color: asString(color, '#000000'), + gold: asNumber(gold, 0), + rice: asNumber(rice, 0), + infoText: asNullableString(infoText), + tech: asNumber(tech, 0), + type: asString(type, ''), + level: asNumber(level, 0), + cities: asStringArray(cities), + }; +}; + +const parseDiplomacyRow = (row: unknown, index: number): ScenarioDiplomacy => { + if (!Array.isArray(row)) { + throw new Error(`Scenario diplomacy row ${index} is not an array.`); + } + const [fromNationId, toNationId, state, durationMonths] = row; + return { + fromNationId: asNumber(fromNationId, 0), + toNationId: asNumber(toNationId, 0), + state: asNumber(state, 0), + durationMonths: asNumber(durationMonths, 0), + }; +}; + +const parseGeneralRow = ( + row: unknown, + index: number, + label: string +): ScenarioGeneral => { + if (!Array.isArray(row)) { + throw new Error(`Scenario ${label} row ${index} is not an array.`); + } + const values = [...row]; + while (values.length < 14) { + values.push(null); + } + + const [ + affinity, + name, + picture, + nation, + city, + leadership, + strength, + intelligence, + officerLevel, + birthYear, + deathYear, + personality, + special, + text, + ] = values; + + if (typeof name !== 'string') { + throw new Error(`Scenario ${label} row ${index} has no name.`); + } + + return { + affinity: asNullableNumber(affinity), + name, + picture: + typeof picture === 'number' || typeof picture === 'string' + ? picture + : null, + nation: + typeof nation === 'number' || typeof nation === 'string' + ? nation + : null, + city: asNullableString(city), + leadership: asNumber(leadership, 0), + strength: asNumber(strength, 0), + intelligence: asNumber(intelligence, 0), + officerLevel: asNumber(officerLevel, 0), + birthYear: asNumber(birthYear, 0), + deathYear: asNumber(deathYear, 0), + personality: asNullableString(personality), + special: asNullableString(special), + text: asNullableString(text), + }; +}; + +const parseGeneralRows = (rows: unknown[], label: string): ScenarioGeneral[] => + rows.map((row, index) => parseGeneralRow(row, index, label)); + +const parseNationRows = (rows: unknown[]): ScenarioNation[] => + rows.map((row, index) => parseNationRow(row, index)); + +const parseDiplomacyRows = (rows: unknown[]): ScenarioDiplomacy[] => + rows.map((row, index) => parseDiplomacyRow(row, index)); + +export const parseScenarioDefaults = (raw: unknown): ScenarioDefaults => { + // 기본 시나리오 설정값을 안전하게 읽는다. + const data = asRecord(raw); + const stat = parseScenarioStatBlock(data.stat, FALLBACK_STAT); + const iconPath = asString(data.iconPath, '.'); + return { stat, iconPath }; +}; + +export const parseScenarioDefinition = ( + raw: unknown, + defaults: ScenarioDefaults +): ScenarioDefinition => { + // 시나리오 JSON을 런타임에서 쓰는 구조로 정규화한다. + const data = asRecord(raw); + const stat = parseScenarioStatBlock(data.stat, defaults.stat); + const mapConfig = asRecord(data.map); + const constConfig = asRecord(data.const); + const config: ScenarioConfig = { + stat, + iconPath: asString(data.iconPath, defaults.iconPath), + map: mapConfig, + const: constConfig, + environment: parseScenarioEnvironment(mapConfig, constConfig), + }; + + const title = ensureTitle(data.title); + const startYear = + typeof data.startYear === 'number' ? data.startYear : null; + const life = typeof data.life === 'number' ? data.life : null; + const fiction = typeof data.fiction === 'number' ? data.fiction : null; + const history = asStringArray(data.history); + const ignoreDefaultEvents = Boolean(data.ignoreDefaultEvents); + const nations = parseNationRows(asArray(data.nation)); + const diplomacy = parseDiplomacyRows(asArray(data.diplomacy)); + const generals = parseGeneralRows(asArray(data.general), 'general'); + const generalsEx = parseGeneralRows(asArray(data.general_ex), 'general_ex'); + const generalsNeutral = parseGeneralRows( + asArray(data.general_neutral), + 'general_neutral' + ); + const events = asArray(data.events); + const initialEvents = asArray( + data.initialEvents ?? data.initialActions ?? [] + ); + + return { + title, + startYear, + life, + fiction, + history, + config, + nations, + diplomacy, + generals, + generalsEx, + generalsNeutral, + cities: asArray(data.cities), + events, + initialEvents, + ignoreDefaultEvents, + }; +}; diff --git a/packages/logic/src/scenario/types.ts b/packages/logic/src/scenario/types.ts new file mode 100644 index 0000000..7907cfa --- /dev/null +++ b/packages/logic/src/scenario/types.ts @@ -0,0 +1,83 @@ +export interface ScenarioStatBlock { + total: number; + min: number; + max: number; + npcTotal: number; + npcMax: number; + npcMin: number; + chiefMin: number; +} + +export interface ScenarioDefaults { + stat: ScenarioStatBlock; + iconPath: string; +} + +export interface ScenarioEnvironment { + mapName: string; + unitSet: string; + scenarioEffect?: string | null; +} + +export interface ScenarioConfig { + stat: ScenarioStatBlock; + iconPath: string; + map: Record; + const: Record; + environment: ScenarioEnvironment; +} + +export interface ScenarioNation { + id: number; + name: string; + color: string; + gold: number; + rice: number; + infoText: string | null; + tech: number; + type: string; + level: number; + cities: string[]; +} + +export interface ScenarioDiplomacy { + fromNationId: number; + toNationId: number; + state: number; + durationMonths: number; +} + +export interface ScenarioGeneral { + affinity: number | null; + name: string; + picture: number | string | null; + nation: number | string | null; + city: string | null; + leadership: number; + strength: number; + intelligence: number; + officerLevel: number; + birthYear: number; + deathYear: number; + personality: string | null; + special: string | null; + text: string | null; +} + +export interface ScenarioDefinition { + title: string; + startYear: number | null; + life: number | null; + fiction: number | null; + history: string[]; + config: ScenarioConfig; + nations: ScenarioNation[]; + diplomacy: ScenarioDiplomacy[]; + generals: ScenarioGeneral[]; + generalsEx: ScenarioGeneral[]; + generalsNeutral: ScenarioGeneral[]; + cities: unknown[]; + events: unknown[]; + initialEvents: unknown[]; + ignoreDefaultEvents: boolean; +} diff --git a/packages/logic/test/scenarioParser.test.ts b/packages/logic/test/scenarioParser.test.ts new file mode 100644 index 0000000..a07ba29 --- /dev/null +++ b/packages/logic/test/scenarioParser.test.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + parseScenarioDefaults, + parseScenarioDefinition, +} from '../src/scenario/parseScenario.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const scenarioRoot = path.join(repoRoot, 'legacy', 'hwe', 'scenario'); + +const readJson = async (filePath: string): Promise => { + const raw = await fs.readFile(filePath, 'utf8'); + return JSON.parse(raw) as unknown; +}; + +describe('scenario parser', () => { + it('reads defaults from legacy default.json', async () => { + const defaultsRaw = await readJson( + path.join(scenarioRoot, 'default.json') + ); + const defaults = parseScenarioDefaults(defaultsRaw); + + expect(defaults.stat.total).toBe(165); + expect(defaults.stat.min).toBe(15); + expect(defaults.stat.max).toBe(80); + expect(defaults.stat.npcTotal).toBe(150); + expect(defaults.stat.npcMax).toBe(75); + expect(defaults.stat.npcMin).toBe(10); + expect(defaults.stat.chiefMin).toBe(65); + expect(defaults.iconPath).toBe('.'); + }); + + it('defaults map/unit set when scenario omits map config', async () => { + const defaultsRaw = await readJson( + path.join(scenarioRoot, 'default.json') + ); + const scenarioRaw = await readJson( + path.join(scenarioRoot, 'scenario_0.json') + ); + const defaults = parseScenarioDefaults(defaultsRaw); + const scenario = parseScenarioDefinition(scenarioRaw, defaults); + + expect(scenario.config.environment.mapName).toBe('che'); + expect(scenario.config.environment.unitSet).toBe('che'); + }); + + it('parses nation/general rows from a legacy scenario', async () => { + const defaultsRaw = await readJson( + path.join(scenarioRoot, 'default.json') + ); + const scenarioRaw = await readJson( + path.join(scenarioRoot, 'scenario_1010.json') + ); + const defaults = parseScenarioDefaults(defaultsRaw); + const scenario = parseScenarioDefinition(scenarioRaw, defaults); + + expect(scenario.nations.length).toBeGreaterThan(0); + expect(scenario.generals.length).toBeGreaterThan(0); + + const firstNation = scenario.nations[0]; + expect(firstNation.name).toBe('후한'); + expect(firstNation.color).toBe('#800000'); + + const firstGeneral = scenario.generals[0]; + expect(firstGeneral.name).toBe('소제1'); + expect(firstGeneral.affinity).toBe(1); + expect(firstGeneral.picture).toBe(1001); + expect(firstGeneral.nation).toBe(1); + expect(firstGeneral.city).toBe(null); + expect(firstGeneral.leadership).toBe(20); + expect(firstGeneral.personality).toBe('유지'); + expect(firstGeneral.special).toBe(null); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 835e352..722657a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@sammo-ts/common': specifier: workspace:* version: link:../../packages/common + '@sammo-ts/logic': + specifier: workspace:* + version: link:../../packages/logic devDependencies: tsdown: specifier: ^0.18.3 @@ -81,6 +84,9 @@ importers: '@sammo-ts/common': specifier: workspace:* version: link:../common + zod: + specifier: ^4.2.1 + version: 4.2.1 devDependencies: tsdown: specifier: ^0.18.3 @@ -957,6 +963,9 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + zod@4.2.1: + resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + snapshots: '@babel/generator@7.28.5': @@ -1647,3 +1656,5 @@ snapshots: stackback: 0.0.2 yallist@4.0.0: {} + + zod@4.2.1: {}