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,