diff --git a/app/game-engine/package.json b/app/game-engine/package.json index 6e9e683b..0ec6d95b 100644 --- a/app/game-engine/package.json +++ b/app/game-engine/package.json @@ -5,9 +5,95 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./auction/finalizer.js": { + "types": "./dist/auction/finalizer.d.ts", + "default": "./dist/auction/finalizer.js" + }, + "./scenario/mapLoader.js": { + "types": "./dist/scenario/mapLoader.d.ts", + "default": "./dist/scenario/mapLoader.js" + }, + "./scenario/scenarioComposition.js": { + "types": "./dist/scenario/scenarioComposition.d.ts", + "default": "./dist/scenario/scenarioComposition.js" + }, + "./scenario/scenarioLoader.js": { + "types": "./dist/scenario/scenarioLoader.d.ts", + "default": "./dist/scenario/scenarioLoader.js" + }, + "./scenario/scenarioSeeder.js": { + "types": "./dist/scenario/scenarioSeeder.d.ts", + "default": "./dist/scenario/scenarioSeeder.js" + }, + "./scenario/unitSetLoader.js": { + "types": "./dist/scenario/unitSetLoader.d.ts", + "default": "./dist/scenario/unitSetLoader.js" + }, + "./turn/databaseHooks.js": { + "types": "./dist/turn/databaseHooks.d.ts", + "default": "./dist/turn/databaseHooks.js" + }, + "./turn/inMemoryWorld.js": { + "types": "./dist/turn/inMemoryWorld.d.ts", + "default": "./dist/turn/inMemoryWorld.js" + }, + "./turn/monthlyDisasterAction.js": { + "types": "./dist/turn/monthlyDisasterAction.d.ts", + "default": "./dist/turn/monthlyDisasterAction.js" + }, + "./turn/monthlyEventHandler.js": { + "types": "./dist/turn/monthlyEventHandler.d.ts", + "default": "./dist/turn/monthlyEventHandler.js" + }, + "./turn/monthlyNationBettingAction.js": { + "types": "./dist/turn/monthlyNationBettingAction.d.ts", + "default": "./dist/turn/monthlyNationBettingAction.js" + }, + "./turn/npcPossessionService.js": { + "types": "./dist/turn/npcPossessionService.d.ts", + "default": "./dist/turn/npcPossessionService.js" + }, + "./turn/rankData.js": { + "types": "./dist/turn/rankData.d.ts", + "default": "./dist/turn/rankData.js" + }, + "./turn/reservedTurnHandler.js": { + "types": "./dist/turn/reservedTurnHandler.d.ts", + "default": "./dist/turn/reservedTurnHandler.js" + }, + "./turn/reservedTurnStore.js": { + "types": "./dist/turn/reservedTurnStore.d.ts", + "default": "./dist/turn/reservedTurnStore.js" + }, + "./turn/selectPoolService.js": { + "types": "./dist/turn/selectPoolService.d.ts", + "default": "./dist/turn/selectPoolService.js" + }, + "./turn/turnCommandProfile.js": { + "types": "./dist/turn/turnCommandProfile.d.ts", + "default": "./dist/turn/turnCommandProfile.js" + }, + "./turn/types.js": { + "types": "./dist/turn/types.d.ts", + "default": "./dist/turn/types.js" + }, + "./turn/worldCommandHandler.js": { + "types": "./dist/turn/worldCommandHandler.d.ts", + "default": "./dist/turn/worldCommandHandler.js" + }, + "./turn/worldLoader.js": { + "types": "./dist/turn/worldLoader.d.ts", + "default": "./dist/turn/worldLoader.js" + } + }, "scripts": { - "build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine", - "dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine --watch", + "build": "tsdown -c ./tsdown.config.ts && node ../../tools/check-package-exports.mjs .", + "dev": "tsdown -c ./tsdown.config.ts --watch", "start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/app/game-engine/tsdown.config.ts b/app/game-engine/tsdown.config.ts new file mode 100644 index 00000000..6e0af083 --- /dev/null +++ b/app/game-engine/tsdown.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'auction/finalizer': 'src/auction/finalizer.ts', + 'scenario/mapLoader': 'src/scenario/mapLoader.ts', + 'scenario/scenarioComposition': 'src/scenario/scenarioComposition.ts', + 'scenario/scenarioLoader': 'src/scenario/scenarioLoader.ts', + 'scenario/scenarioSeeder': 'src/scenario/scenarioSeeder.ts', + 'scenario/unitSetLoader': 'src/scenario/unitSetLoader.ts', + 'turn/databaseHooks': 'src/turn/databaseHooks.ts', + 'turn/inMemoryWorld': 'src/turn/inMemoryWorld.ts', + 'turn/monthlyDisasterAction': 'src/turn/monthlyDisasterAction.ts', + 'turn/monthlyEventHandler': 'src/turn/monthlyEventHandler.ts', + 'turn/monthlyNationBettingAction': 'src/turn/monthlyNationBettingAction.ts', + 'turn/npcPossessionService': 'src/turn/npcPossessionService.ts', + 'turn/rankData': 'src/turn/rankData.ts', + 'turn/reservedTurnHandler': 'src/turn/reservedTurnHandler.ts', + 'turn/reservedTurnStore': 'src/turn/reservedTurnStore.ts', + 'turn/selectPoolService': 'src/turn/selectPoolService.ts', + 'turn/turnCommandProfile': 'src/turn/turnCommandProfile.ts', + 'turn/types': 'src/turn/types.ts', + 'turn/worldCommandHandler': 'src/turn/worldCommandHandler.ts', + 'turn/worldLoader': 'src/turn/worldLoader.ts', + }, + format: 'es', + outDir: 'dist', + dts: { + build: true, + }, + sourcemap: true, + target: 'node22', + platform: 'node', + fixedExtension: false, + hash: false, +}); diff --git a/docs/architecture/package-boundaries.md b/docs/architecture/package-boundaries.md index ccaebfb4..57d16482 100644 --- a/docs/architecture/package-boundaries.md +++ b/docs/architecture/package-boundaries.md @@ -40,6 +40,12 @@ Resource를 읽는 `scenarioLoader`, `mapLoader`, `unitSetLoader`, 사용합니다. `@sammo-ts/game-engine` 루트는 daemon process entrypoint이므로 API 제품 소스에서 library처럼 import하지 않습니다. +game-engine의 공개 subpath는 `app/game-engine/package.json`의 `exports`와 +`app/game-engine/tsdown.config.ts`의 entry에 함께 선언합니다. `pnpm --filter +@sammo-ts/game-engine build`는 각 JS·type export target이 실제 생성됐는지 +검사하므로, typecheck에서는 보이지만 production Node에서만 실패하는 subpath +누락을 배포 전에 차단합니다. + Frontend가 tRPC router shape를 참조할 때는 `import type`만 사용하고 backend package를 `devDependencies`에 둡니다. 브라우저에서 실제 실행하는 공유 값만 `common` 또는 `logic`의 browser-safe export에서 가져옵니다. diff --git a/tools/check-package-exports.mjs b/tools/check-package-exports.mjs new file mode 100644 index 00000000..484ce864 --- /dev/null +++ b/tools/check-package-exports.mjs @@ -0,0 +1,31 @@ +import { access, readFile } from 'node:fs/promises'; +import path from 'node:path'; + +const packageRoot = path.resolve(process.argv[2] ?? ''); +if (!process.argv[2]) { + throw new Error('Usage: node tools/check-package-exports.mjs '); +} + +const manifest = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8')); +const targets = new Set(); +for (const descriptor of Object.values(manifest.exports ?? {})) { + if (typeof descriptor === 'string') { + targets.add(descriptor); + continue; + } + if (descriptor && typeof descriptor === 'object') { + for (const target of Object.values(descriptor)) { + if (typeof target === 'string') targets.add(target); + } + } +} + +for (const target of targets) { + const absolute = path.resolve(packageRoot, target); + if (absolute !== packageRoot && !absolute.startsWith(`${packageRoot}${path.sep}`)) { + throw new Error(`Package export escapes its package directory: ${target}`); + } + await access(absolute); +} + +process.stdout.write(`Validated ${targets.size} package export targets for ${manifest.name}.\n`);