feat: add troop model and integrate troop handling in game logic

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