diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 0227cf3..fb02915 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -121,6 +121,7 @@ export const seedScenarioToDatabase = async ( await prisma.event.deleteMany(); await prisma.diplomacy.deleteMany(); await prisma.general.deleteMany(); + await prisma.troop.deleteMany(); await prisma.city.deleteMany(); await prisma.nation.deleteMany(); await prisma.worldState.deleteMany(); @@ -239,6 +240,16 @@ export const seedScenarioToDatabase = async ( }); } + if (seed.troops.length > 0) { + await prisma.troop.createMany({ + data: seed.troops.map((troop) => ({ + troopLeaderId: troop.id, + nationId: troop.nationId, + name: troop.name, + })), + }); + } + if (seed.diplomacy.length > 0) { await prisma.diplomacy.createMany({ data: seed.diplomacy.map((row) => ({ diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 9bd7be4..b89145e 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -121,6 +121,21 @@ const buildNationUpdate = ( meta: asJson(nation.meta), }); +const buildTroopUpdate = ( + troop: ReturnType['troops'][number] +): Prisma.TroopUpdateInput => ({ + nationId: troop.nationId, + name: troop.name, +}); + +const buildTroopCreate = ( + troop: ReturnType['troops'][number] +): Prisma.TroopCreateManyInput => ({ + troopLeaderId: troop.id, + nationId: troop.nationId, + name: troop.name, +}); + const buildLogCreateData = ( entry: LogEntryDraft, context: { year: number; month: number; at: Date } @@ -161,8 +176,15 @@ export const createDatabaseTurnHooks = async ( const hooks: TurnDaemonHooks = { flushChanges: async () => { const state = world.getState(); - const { generals, cities, nations, logs, createdGenerals } = - world.consumeDirtyState(); + const { + generals, + cities, + nations, + troops, + logs, + createdGenerals, + createdTroops, + } = world.consumeDirtyState(); await connector.prisma.worldState.update({ where: { id: state.id }, @@ -177,12 +199,20 @@ export const createDatabaseTurnHooks = async ( const createdIds = new Set( createdGenerals.map((general) => general.id) ); + const createdTroopIds = new Set( + createdTroops.map((troop) => troop.id) + ); if (createdGenerals.length > 0) { await connector.prisma.general.createMany({ data: createdGenerals.map(buildGeneralCreate), }); } + if (createdTroops.length > 0) { + await connector.prisma.troop.createMany({ + data: createdTroops.map(buildTroopCreate), + }); + } await Promise.all([ ...generals @@ -205,6 +235,14 @@ export const createDatabaseTurnHooks = async ( data: buildNationUpdate(nation), }) ), + ...troops + .filter((troop) => !createdTroopIds.has(troop.id)) + .map((troop) => + connector.prisma.troop.update({ + where: { troopLeaderId: troop.id }, + data: buildTroopUpdate(troop), + }) + ), ]); if (logs.length > 0) { diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 78dfc0d..2e4da93 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1,4 +1,10 @@ -import type { City, LogEntryDraft, Nation, TurnSchedule } from '@sammo-ts/logic'; +import type { + City, + LogEntryDraft, + Nation, + Troop, + TurnSchedule, +} from '@sammo-ts/logic'; import { getNextTurnAt } from '@sammo-ts/logic'; import type { TurnCheckpoint } from '../lifecycle/types.js'; @@ -22,9 +28,11 @@ export interface GeneralTurnResult { generals: Array<{ id: number; patch: Partial }>; cities: Array<{ id: number; patch: Partial }>; nations: Array<{ id: number; patch: Partial }>; + troops: Array<{ id: number; patch: Partial }>; }; created?: { generals: TurnGeneral[]; + troops?: Troop[]; }; } @@ -141,6 +149,11 @@ const applyNationPatch = (base: Nation, patch: Partial): Nation => ({ meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, }); +const applyTroopPatch = (base: Troop, patch: Partial): Troop => ({ + ...base, + ...patch, +}); + export class InMemoryTurnWorld { // DB에서 읽어온 월드 상태를 메모리에 고정해 턴 처리를 담당한다. private readonly schedule: TurnSchedule; @@ -149,10 +162,13 @@ export class InMemoryTurnWorld { private readonly generals = new Map(); private readonly cities = new Map(); private readonly nations = new Map(); + private readonly troops = new Map(); private readonly dirtyGeneralIds = new Set(); private readonly dirtyCityIds = new Set(); private readonly dirtyNationIds = new Set(); + private readonly dirtyTroopIds = new Set(); private readonly createdGeneralIds = new Set(); + private readonly createdTroopIds = new Set(); private readonly logs: LogEntryDraft[] = []; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; @@ -180,6 +196,9 @@ export class InMemoryTurnWorld { for (const nation of snapshot.nations) { this.nations.set(nation.id, { ...nation }); } + for (const troop of snapshot.troops) { + this.troops.set(troop.id, { ...troop }); + } } getState(): TurnWorldState { @@ -198,6 +217,10 @@ export class InMemoryTurnWorld { return this.nations.get(id) ?? null; } + getTroopById(id: number): Troop | null { + return this.troops.get(id) ?? null; + } + listGenerals(): TurnGeneral[] { return Array.from(this.generals.values()).map((general) => ({ ...general, @@ -214,6 +237,12 @@ export class InMemoryTurnWorld { })); } + listTroops(): Troop[] { + return Array.from(this.troops.values()).map((troop) => ({ + ...troop, + })); + } + setLastTurnTime(turnTime: Date): void { const meta = { ...this.state.meta, @@ -326,6 +355,14 @@ export class InMemoryTurnWorld { ); this.dirtyNationIds.add(patch.id); } + for (const patch of result.patches.troops) { + const target = this.troops.get(patch.id); + if (!target) { + continue; + } + this.troops.set(patch.id, applyTroopPatch(target, patch.patch)); + this.dirtyTroopIds.add(patch.id); + } } if (result.created) { for (const createdGeneral of result.created.generals) { @@ -336,6 +373,16 @@ export class InMemoryTurnWorld { this.dirtyGeneralIds.add(createdGeneral.id); this.createdGeneralIds.add(createdGeneral.id); } + if (result.created.troops) { + for (const createdTroop of result.created.troops) { + if (this.troops.has(createdTroop.id)) { + continue; + } + this.troops.set(createdTroop.id, { ...createdTroop }); + this.dirtyTroopIds.add(createdTroop.id); + this.createdTroopIds.add(createdTroop.id); + } + } } return nextTurnAt; @@ -380,8 +427,10 @@ export class InMemoryTurnWorld { generals: TurnGeneral[]; cities: City[]; nations: Nation[]; + troops: Troop[]; logs: LogEntryDraft[]; createdGenerals: TurnGeneral[]; + createdTroops: Troop[]; } { const generals = Array.from(this.dirtyGeneralIds) .map((id) => this.generals.get(id)) @@ -395,13 +444,29 @@ export class InMemoryTurnWorld { const nations = Array.from(this.dirtyNationIds) .map((id) => this.nations.get(id)) .filter((nation): nation is Nation => Boolean(nation)); + const troops = Array.from(this.dirtyTroopIds) + .map((id) => this.troops.get(id)) + .filter((troop): troop is Troop => Boolean(troop)); + const createdTroops = Array.from(this.createdTroopIds) + .map((id) => this.troops.get(id)) + .filter((troop): troop is Troop => Boolean(troop)); const logs = this.logs.splice(0, this.logs.length); this.dirtyGeneralIds.clear(); this.dirtyCityIds.clear(); this.dirtyNationIds.clear(); + this.dirtyTroopIds.clear(); this.createdGeneralIds.clear(); + this.createdTroopIds.clear(); - return { generals, cities, nations, logs, createdGenerals }; + return { + generals, + cities, + nations, + troops, + logs, + createdGenerals, + createdTroops, + }; } } diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 7a2c7c2..12078d0 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -8,6 +8,7 @@ import type { ScenarioConfig, ScenarioDiplomacy, ScenarioMeta, + Troop, } from '@sammo-ts/logic'; import { AssignmentActionDefinition, @@ -605,6 +606,7 @@ export const createReservedTurnHandler = (options: { generals: [] as Array<{ id: number; patch: Partial }>, cities: [] as Array<{ id: number; patch: Partial }>, nations: [] as Array<{ id: number; patch: Partial }>, + troops: [] as Array<{ id: number; patch: Partial }>, }; const created: TurnGeneral[] = []; diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index cd4103b..3ff1e43 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -6,6 +6,7 @@ import type { ScenarioConfig, ScenarioDiplomacy, ScenarioMeta, + Troop, UnitSetDefinition, WorldSnapshot, } from '@sammo-ts/logic'; @@ -25,7 +26,10 @@ export interface TurnGeneral extends General { } export interface TurnWorldSnapshot - extends Omit { + extends Omit< + WorldSnapshot, + 'generals' | 'cities' | 'nations' | 'troops' + > { scenarioConfig: ScenarioConfig; scenarioMeta?: ScenarioMeta; map: MapDefinition; @@ -36,6 +40,7 @@ export interface TurnWorldSnapshot generals: TurnGeneral[]; cities: City[]; nations: Nation[]; + troops: Troop[]; } export interface TurnWorldLoadResult { diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 9abe487..96a75e2 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -4,6 +4,7 @@ import type { General as PrismaGeneral, Nation as PrismaNation, Prisma, + Troop as PrismaTroop, } from '@prisma/client'; import { createPostgresConnector } from '@sammo-ts/infra'; @@ -13,6 +14,7 @@ import type { ScenarioConfig, ScenarioDiplomacy, ScenarioMeta, + Troop, TriggerValue, } from '@sammo-ts/logic'; import { z } from 'zod'; @@ -216,6 +218,12 @@ const mapDiplomacyRow = (row: PrismaDiplomacy): ScenarioDiplomacy => ({ durationMonths: row.term, }); +const mapTroopRow = (row: PrismaTroop): Troop => ({ + id: row.troopLeaderId, + nationId: row.nationId, + name: row.name, +}); + export const loadTurnWorldFromDatabase = async ( options: TurnWorldLoaderOptions ): Promise => { @@ -233,12 +241,14 @@ export const loadTurnWorldFromDatabase = async ( cityRows, nationRows, diplomacyRows, + troopRows, eventRows, ] = await Promise.all([ prisma.general.findMany(), prisma.city.findMany(), prisma.nation.findMany(), prisma.diplomacy.findMany(), + prisma.troop.findMany(), prisma.event.findMany({ orderBy: [{ priority: 'desc' }, { id: 'asc' }], }), @@ -248,6 +258,7 @@ export const loadTurnWorldFromDatabase = async ( const cities = cityRows.map(mapCityRow); const nations = nationRows.map(mapNationRow); const diplomacy = diplomacyRows.map(mapDiplomacyRow); + const troops = troopRows.map(mapTroopRow); const scenarioConfig = mapScenarioConfig(worldState.config); const mapName = scenarioConfig.environment?.mapName ?? 'che'; @@ -305,6 +316,7 @@ export const loadTurnWorldFromDatabase = async ( nations, cities, generals, + troops, diplomacy, events, initialEvents, diff --git a/packages/infra/prisma/schema.prisma b/packages/infra/prisma/schema.prisma index be163e7..09e5a8a 100644 --- a/packages/infra/prisma/schema.prisma +++ b/packages/infra/prisma/schema.prisma @@ -147,6 +147,14 @@ model General { @@map("general") } +model Troop { + troopLeaderId Int @id @map("troop_leader") + nationId Int @map("nation") + name String + + @@map("troop") +} + model GeneralTurn { id Int @id @default(autoincrement()) generalId Int @map("general_id") diff --git a/packages/logic/src/domain/entities.ts b/packages/logic/src/domain/entities.ts index 63b6dee..525b4a2 100644 --- a/packages/logic/src/domain/entities.ts +++ b/packages/logic/src/domain/entities.ts @@ -3,6 +3,7 @@ export type GeneralId = number; export type CityId = number; export type NationId = number; +export type TroopId = number; export type ColorCode = string; @@ -95,3 +96,9 @@ export interface Nation { typeCode: string; meta: Record; } + +export interface Troop { + id: TroopId; + nationId: NationId; + name: string; +} diff --git a/packages/logic/src/ports/worldSnapshot.ts b/packages/logic/src/ports/worldSnapshot.ts index 5f6b758..ed97872 100644 --- a/packages/logic/src/ports/worldSnapshot.ts +++ b/packages/logic/src/ports/worldSnapshot.ts @@ -1,4 +1,4 @@ -import type { City, General, Nation } from '../domain/entities.js'; +import type { City, General, Nation, Troop } from '../domain/entities.js'; import type { ScenarioConfig } from '../scenario/types.js'; import type { ScenarioMeta } from '../world/types.js'; @@ -6,11 +6,13 @@ import type { ScenarioMeta } from '../world/types.js'; export interface WorldStateSnapshotSource< GeneralType extends General = General, CityType extends City = City, - NationType extends Nation = Nation + NationType extends Nation = Nation, + TroopType extends Troop = Troop > { listGenerals(): Promise; listCities(): Promise; listNations(): Promise; + listTroops(): Promise; } export interface ScenarioConfigSource { diff --git a/packages/logic/src/world/bootstrap.ts b/packages/logic/src/world/bootstrap.ts index 7f8a68c..1a35377 100644 --- a/packages/logic/src/world/bootstrap.ts +++ b/packages/logic/src/world/bootstrap.ts @@ -676,6 +676,7 @@ export const buildScenarioBootstrap = ( nations: seedNations, cities: seedCities, generals: allGeneralSeeds, + troops: [], diplomacy: scenario.diplomacy, events: scenario.events, initialEvents: scenario.initialEvents, @@ -689,6 +690,7 @@ export const buildScenarioBootstrap = ( nations: domainNations, cities: domainCities, generals: allGenerals, + troops: [], diplomacy: scenario.diplomacy, events: scenario.events, initialEvents: scenario.initialEvents, diff --git a/packages/logic/src/world/loader.ts b/packages/logic/src/world/loader.ts index d30367b..18199ad 100644 --- a/packages/logic/src/world/loader.ts +++ b/packages/logic/src/world/loader.ts @@ -1,4 +1,4 @@ -import type { City, General, Nation } from '../domain/entities.js'; +import type { City, General, Nation, Troop } from '../domain/entities.js'; import type { ScenarioConfig, ScenarioDiplomacy } from '../scenario/types.js'; import type { ScenarioConfigSource, WorldStateSnapshotSource } from '../ports/worldSnapshot.js'; import type { @@ -11,9 +11,15 @@ import type { export interface WorldSnapshotLoadInput< GeneralType extends General = General, CityType extends City = City, - NationType extends Nation = Nation + NationType extends Nation = Nation, + TroopType extends Troop = Troop > { - worldSource: WorldStateSnapshotSource; + worldSource: WorldStateSnapshotSource< + GeneralType, + CityType, + NationType, + TroopType + >; scenarioConfig?: ScenarioConfig; scenarioMeta?: ScenarioMeta; scenarioSource?: ScenarioConfigSource; @@ -28,9 +34,15 @@ export interface WorldSnapshotLoadInput< export const loadWorldSnapshot = async < GeneralType extends General, CityType extends City, - NationType extends Nation + NationType extends Nation, + TroopType extends Troop >( - input: WorldSnapshotLoadInput + input: WorldSnapshotLoadInput< + GeneralType, + CityType, + NationType, + TroopType + > ): Promise => { const { worldSource, scenarioSource } = input; @@ -47,10 +59,11 @@ export const loadWorldSnapshot = async < ? await scenarioSource.loadScenarioMeta() : undefined); - const [generals, cities, nations] = await Promise.all([ + const [generals, cities, nations, troops] = await Promise.all([ worldSource.listGenerals(), worldSource.listCities(), worldSource.listNations(), + worldSource.listTroops(), ]); return { @@ -61,6 +74,7 @@ export const loadWorldSnapshot = async < nations, cities, generals, + troops, 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 index 6d896b2..b70654c 100644 --- a/packages/logic/src/world/types.ts +++ b/packages/logic/src/world/types.ts @@ -1,4 +1,10 @@ -import type { City, General, Nation, StatBlock } from '../domain/entities.js'; +import type { + City, + General, + Nation, + StatBlock, + Troop, +} from '../domain/entities.js'; import type { ScenarioConfig, ScenarioDiplomacy, @@ -136,6 +142,7 @@ export interface WorldSeedPayload { nations: NationSeed[]; cities: CitySeed[]; generals: GeneralSeed[]; + troops: Troop[]; diplomacy: ScenarioDiplomacy[]; events: unknown[]; initialEvents: unknown[]; @@ -149,6 +156,7 @@ export interface WorldSnapshot { nations: Nation[]; cities: City[]; generals: General[]; + troops: Troop[]; diplomacy: ScenarioDiplomacy[]; events: unknown[]; initialEvents: unknown[];