Files
core2026/app/game-engine/src/turn/worldLoader.ts
T
Hide_D d599d94a7b feat: add atmos property to General entity and update related logic
- Added `atmos` property to the `General` interface in `entities.ts`.
- Initialized `atmos` to 0 in the `buildGeneralSeeds` function in `bootstrap.ts`.
- Updated exports in `index.ts` to include `unitSet.js`.
- Introduced new types for crew types and their requirements in `types.ts`.
- Implemented `unitSetLoader.ts` for loading unit set definitions from JSON files.
- Created `che_징병.ts` action for recruiting crew types with cost and training calculations.
- Developed `unitSet.ts` for handling crew type availability and requirements.
- Added tests for crew type availability and unit set parsing in `crewType.test.ts`.
2025-12-30 09:45:51 +00:00

347 lines
9.9 KiB
TypeScript

import type {
City as PrismaCity,
Diplomacy as PrismaDiplomacy,
General as PrismaGeneral,
Nation as PrismaNation,
Prisma,
Troop as PrismaTroop,
} from '@prisma/client';
import { createPostgresConnector } from '@sammo-ts/infra';
import type {
City,
Nation,
ScenarioConfig,
ScenarioDiplomacy,
ScenarioMeta,
Troop,
TriggerValue,
} from '@sammo-ts/logic';
import { z } from 'zod';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { loadMapDefinitionByName } from '../scenario/mapLoader.js';
import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js';
import type { TurnGeneral, TurnWorldLoadResult } from './types.js';
interface TurnWorldLoaderOptions {
databaseUrl: string;
mapOptions?: MapLoaderOptions;
unitSetOptions?: UnitSetLoaderOptions;
}
type JsonRecord = Record<string, unknown>;
const isRecord = (value: unknown): value is JsonRecord =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): JsonRecord =>
isRecord(value) ? value : {};
const asTriggerRecord = (value: unknown): Record<string, TriggerValue> =>
isRecord(value) ? (value as Record<string, TriggerValue>) : {};
const normalizeCode = (value: string | null | undefined): string | null => {
if (!value || value === 'None') {
return null;
}
return value;
};
const zScenarioStatBlock = z.object({
total: z.number(),
min: z.number(),
max: z.number(),
npcTotal: z.number(),
npcMax: z.number(),
npcMin: z.number(),
chiefMin: z.number(),
});
const zScenarioEnvironment = z.object({
mapName: z.string(),
unitSet: z.string(),
scenarioEffect: z.union([z.string(), z.null()]).optional(),
});
const zScenarioConfig = z.object({
stat: zScenarioStatBlock,
iconPath: z.string(),
map: z.record(z.string(), z.unknown()),
const: z.record(z.string(), z.unknown()),
environment: zScenarioEnvironment,
});
const zScenarioMeta = z.object({
title: z.string(),
startYear: z.number().nullable(),
life: z.number().nullable(),
fiction: z.number().nullable(),
history: z.array(z.string()),
ignoreDefaultEvents: z.boolean(),
});
const parseScenarioMeta = (meta: JsonRecord): ScenarioMeta | undefined => {
const raw = meta.scenarioMeta;
const parsed = zScenarioMeta.safeParse(raw);
return parsed.success ? parsed.data : undefined;
};
const parseLastTurnTime = (meta: JsonRecord): Date | null => {
const raw = meta.lastTurnTime;
if (typeof raw !== 'string') {
return null;
}
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed;
};
const resolveFallbackTurnTimeBase = (
generals: TurnGeneral[],
updatedAt: Date | null
): Date => {
let earliest: Date | null = null;
for (const general of generals) {
const turnTime = general.turnTime;
if (!earliest || turnTime.getTime() < earliest.getTime()) {
earliest = turnTime;
}
}
if (earliest) {
return earliest;
}
if (updatedAt) {
return updatedAt;
}
return new Date();
};
const alignToPreviousTick = (base: Date, tickMinutes: number): Date => {
const nextTick = getNextTickTime(base, tickMinutes);
return new Date(nextTick.getTime() - tickMinutes * 60_000);
};
const mapScenarioConfig = (raw: Prisma.JsonValue): ScenarioConfig => {
const parsed = zScenarioConfig.safeParse(raw);
if (!parsed.success) {
throw new Error(`world_state.config is invalid: ${parsed.error.message}`);
}
return parsed.data;
};
const mapGeneralRow = (row: PrismaGeneral): TurnGeneral => ({
id: row.id,
name: row.name,
nationId: row.nationId,
cityId: row.cityId,
troopId: row.troopId,
stats: {
leadership: row.leadership,
strength: row.strength,
intelligence: row.intel,
},
experience: row.experience,
dedication: row.dedication,
officerLevel: row.officerLevel,
role: {
personality: normalizeCode(row.personalCode),
specialDomestic: normalizeCode(row.specialCode),
specialWar: normalizeCode(row.special2Code),
items: {
horse: normalizeCode(row.horseCode),
weapon: normalizeCode(row.weaponCode),
book: normalizeCode(row.bookCode),
item: normalizeCode(row.itemCode),
},
},
injury: row.injury,
gold: row.gold,
rice: row.rice,
crew: row.crew,
crewTypeId: row.crewTypeId,
train: row.train,
atmos: row.atmos,
age: row.age,
npcState: row.npcState,
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: {},
},
meta: asTriggerRecord(row.meta),
turnTime: row.turnTime,
recentWarTime: row.recentWarTime ?? null,
});
const mapCityRow = (row: PrismaCity): City => ({
id: row.id,
name: row.name,
nationId: row.nationId,
level: row.level,
population: row.population,
populationMax: row.populationMax,
agriculture: row.agriculture,
agricultureMax: row.agricultureMax,
commerce: row.commerce,
commerceMax: row.commerceMax,
security: row.security,
securityMax: row.securityMax,
supplyState: row.supplyState,
frontState: row.frontState,
defence: row.defence,
defenceMax: row.defenceMax,
wall: row.wall,
wallMax: row.wallMax,
meta: {
...asTriggerRecord(row.meta),
trust: row.trust,
trade: row.trade,
region: row.region,
},
});
const mapNationRow = (row: PrismaNation): Nation => ({
id: row.id,
name: row.name,
color: row.color,
capitalCityId: row.capitalCityId,
chiefGeneralId: null,
gold: row.gold,
rice: row.rice,
power: 0,
level: row.level,
typeCode: row.typeCode,
meta: {
...asTriggerRecord(row.meta),
tech: row.tech,
},
});
const mapDiplomacyRow = (row: PrismaDiplomacy): ScenarioDiplomacy => ({
fromNationId: row.srcNationId,
toNationId: row.destNationId,
state: row.stateCode,
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> => {
const connector = createPostgresConnector({ url: options.databaseUrl });
await connector.connect();
try {
const prisma = connector.prisma;
const worldState = await prisma.worldState.findFirst();
if (!worldState) {
throw new Error('world_state row is required to start turn daemon.');
}
const [
generalRows,
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' }],
}),
]);
const generals = generalRows.map(mapGeneralRow);
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';
const map = await loadMapDefinitionByName(mapName, options.mapOptions);
const unitSetName = scenarioConfig.environment?.unitSet ?? 'che';
const unitSet = await loadUnitSetDefinitionByName(
unitSetName,
options.unitSetOptions
);
const meta = asRecord(worldState.meta);
const scenarioMeta = parseScenarioMeta(meta);
const tickMinutes = Math.max(
1,
Math.round(worldState.tickSeconds / 60)
);
const fallbackBase = resolveFallbackTurnTimeBase(
generals,
worldState.updatedAt ?? null
);
const lastTurnTime =
parseLastTurnTime(meta) ??
alignToPreviousTick(fallbackBase, tickMinutes);
const events = eventRows
.filter((row) => row.targetCode !== 'initial')
.map((row) => ({
id: row.id,
targetCode: row.targetCode,
priority: row.priority,
condition: row.condition,
action: row.action,
meta: row.meta,
}));
const initialEvents = eventRows
.filter((row) => row.targetCode === 'initial')
.map((row) => ({
id: row.id,
targetCode: row.targetCode,
priority: row.priority,
condition: row.condition,
action: row.action,
meta: row.meta,
}));
return {
state: {
id: worldState.id,
currentYear: worldState.currentYear,
currentMonth: worldState.currentMonth,
tickSeconds: worldState.tickSeconds,
lastTurnTime,
meta,
},
snapshot: {
scenarioConfig,
...(scenarioMeta ? { scenarioMeta } : {}),
map,
unitSet,
nations,
cities,
generals,
troops,
diplomacy,
events,
initialEvents,
},
};
} finally {
await connector.disconnect();
}
};