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.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) => ({
+40 -2
View File
@@ -121,6 +121,21 @@ const buildNationUpdate = (
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 = (
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) {
+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 type { TurnCheckpoint } from '../lifecycle/types.js';
@@ -22,9 +28,11 @@ export interface GeneralTurnResult {
generals: Array<{ id: number; patch: Partial<TurnGeneral> }>;
cities: Array<{ id: number; patch: Partial<City> }>;
nations: Array<{ id: number; patch: Partial<Nation> }>;
troops: Array<{ id: number; patch: Partial<Troop> }>;
};
created?: {
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,
});
const applyTroopPatch = (base: Troop, patch: Partial<Troop>): Troop => ({
...base,
...patch,
});
export class InMemoryTurnWorld {
// DB에서 읽어온 월드 상태를 메모리에 고정해 턴 처리를 담당한다.
private readonly schedule: TurnSchedule;
@@ -149,10 +162,13 @@ export class InMemoryTurnWorld {
private readonly generals = new Map<number, TurnGeneral>();
private readonly cities = new Map<number, City>();
private readonly nations = new Map<number, Nation>();
private readonly troops = new Map<number, Troop>();
private readonly dirtyGeneralIds = new Set<number>();
private readonly dirtyCityIds = new Set<number>();
private readonly dirtyNationIds = new Set<number>();
private readonly dirtyTroopIds = new Set<number>();
private readonly createdGeneralIds = new Set<number>();
private readonly createdTroopIds = new Set<number>();
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,
};
}
}
@@ -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<TurnGeneral> }>,
cities: [] as Array<{ id: number; patch: Partial<City> }>,
nations: [] as Array<{ id: number; patch: Partial<Nation> }>,
troops: [] as Array<{ id: number; patch: Partial<Troop> }>,
};
const created: TurnGeneral[] = [];
+6 -1
View File
@@ -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<WorldSnapshot, 'generals' | 'cities' | 'nations'> {
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 {
+12
View File
@@ -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<TurnWorldLoadResult> => {
@@ -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,
+8
View File
@@ -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")
+7
View File
@@ -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<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 { 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<GeneralType[]>;
listCities(): Promise<CityType[]>;
listNations(): Promise<NationType[]>;
listTroops(): Promise<TroopType[]>;
}
export interface ScenarioConfigSource {
+2
View File
@@ -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,
+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 { 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<GeneralType, CityType, NationType>;
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<GeneralType, CityType, NationType>
input: WorldSnapshotLoadInput<
GeneralType,
CityType,
NationType,
TroopType
>
): Promise<WorldSnapshot> => {
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 ?? [],
+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 {
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[];