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`.
This commit is contained in:
2025-12-30 09:45:51 +00:00
parent e3bbbd3718
commit d599d94a7b
18 changed files with 1665 additions and 31 deletions
+5
View File
@@ -44,6 +44,7 @@ export interface GeneralRow {
crew: number;
crewTypeId: number;
train: number;
atmos: number;
age: number;
npcState: number;
meta: unknown;
@@ -79,12 +80,15 @@ export interface CityRow {
commerceMax: number;
security: number;
securityMax: number;
trust: number;
trade: number;
supplyState: number;
frontState: number;
defence: number;
defenceMax: number;
wall: number;
wallMax: number;
region: number;
meta: unknown;
}
@@ -95,6 +99,7 @@ export interface NationRow {
capitalCityId: number | null;
gold: number;
rice: number;
tech: number;
level: number;
typeCode: string;
meta: unknown;
+18 -2
View File
@@ -16,6 +16,7 @@ import {
evaluateConstraints,
FireAttackActionDefinition,
NationRestActionDefinition,
RecruitActionDefinition,
RestActionDefinition,
TalentScoutActionDefinition,
VolunteerRecruitActionDefinition,
@@ -324,6 +325,7 @@ const mapGeneralRow = (row: GeneralRow): General => ({
crew: row.crew,
crewTypeId: row.crewTypeId,
train: row.train,
atmos: row.atmos,
age: row.age,
npcState: row.npcState,
triggerState: {
@@ -354,7 +356,12 @@ const mapCityRow = (row: CityRow): City => ({
defenceMax: row.defenceMax,
wall: row.wall,
wallMax: row.wallMax,
meta: asTriggerRecord(row.meta),
meta: {
...asTriggerRecord(row.meta),
trust: row.trust,
trade: row.trade,
region: row.region,
},
});
const mapNationRow = (row: NationRow): Nation => ({
@@ -368,7 +375,10 @@ const mapNationRow = (row: NationRow): Nation => ({
power: 0,
level: row.level,
typeCode: row.typeCode,
meta: asTriggerRecord(row.meta),
meta: {
...asTriggerRecord(row.meta),
tech: row.tech,
},
});
const buildStateView = (
@@ -465,6 +475,12 @@ const buildEntries = (env: CommandEnv): {
reqArg: false,
args: {},
},
{
category: '내정',
definition: new RecruitActionDefinition(emptyModules, {}),
reqArg: true,
args: {},
},
{
category: '계략',
definition: new FireAttackActionDefinition(emptyModules, {
@@ -11,6 +11,8 @@ import type { MapLoaderOptions } from './mapLoader.js';
import { loadMapDefinitionByName } from './mapLoader.js';
import type { ScenarioLoaderOptions } from './scenarioLoader.js';
import { loadScenarioDefinitionById } from './scenarioLoader.js';
import type { UnitSetLoaderOptions } from './unitSetLoader.js';
import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
const DEFAULT_TICK_SECONDS = 120 * 60;
const DEFAULT_GENERAL_GOLD = 1000;
@@ -21,6 +23,7 @@ export interface ScenarioSeedOptions {
databaseUrl: string;
scenarioOptions?: ScenarioLoaderOptions;
mapOptions?: MapLoaderOptions;
unitSetOptions?: UnitSetLoaderOptions;
resetTables?: boolean;
now?: Date;
tickSeconds?: number;
@@ -97,10 +100,15 @@ export const seedScenarioToDatabase = async (
scenario.config.environment.mapName,
options.mapOptions
);
const unitSet = await loadUnitSetDefinitionByName(
scenario.config.environment.unitSet,
options.unitSetOptions
);
const { seed, warnings } = buildScenarioBootstrap({
scenario,
map,
unitSet,
options: {
includeNeutralNationInSeed:
options.includeNeutralNationInSeed ?? true,
@@ -0,0 +1,52 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_UNIT_SET_ROOT = path.resolve(
__dirname,
'..',
'..',
'resources',
'unitset'
);
export interface UnitSetLoaderOptions {
unitSetRoot?: string;
filePrefix?: string;
}
const readJsonFile = async (filePath: string): Promise<unknown> => {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw) as unknown;
};
const resolveUnitSetRoot = (options?: UnitSetLoaderOptions): string =>
options?.unitSetRoot ?? DEFAULT_UNIT_SET_ROOT;
export const resolveUnitSetDefinitionPath = (
unitSetName: string,
options?: UnitSetLoaderOptions
): string => {
const prefix = options?.filePrefix ?? 'unitset_';
return path.resolve(resolveUnitSetRoot(options), `${prefix}${unitSetName}.json`);
};
export const loadUnitSetDefinition = async (
unitSetPath: string
): Promise<UnitSetDefinition> => {
const raw = await readJsonFile(unitSetPath);
return parseUnitSetDefinition(raw);
};
export const loadUnitSetDefinitionByName = async (
unitSetName: string,
options?: UnitSetLoaderOptions
): Promise<UnitSetDefinition> => {
const unitSetPath = resolveUnitSetDefinitionPath(unitSetName, options);
return loadUnitSetDefinition(unitSetPath);
};
+49 -20
View File
@@ -18,6 +18,14 @@ const asJson = (value: unknown): Prisma.InputJsonValue =>
const toCode = (value: string | null | undefined): string =>
value && value !== 'None' ? value : 'None';
const readMetaNumber = (
meta: Record<string, unknown>,
key: string
): number | null => {
const value = meta[key];
return typeof value === 'number' && Number.isFinite(value) ? value : null;
};
const buildGeneralUpdate = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): Prisma.GeneralUpdateInput => ({
@@ -37,6 +45,7 @@ const buildGeneralUpdate = (
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
age: general.age,
npcState: general.npcState,
horseCode: toCode(general.role.items.horse),
@@ -72,6 +81,7 @@ const buildGeneralCreate = (
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
age: general.age,
horseCode: toCode(general.role.items.horse),
weaponCode: toCode(general.role.items.weapon),
@@ -87,26 +97,45 @@ const buildGeneralCreate = (
const buildCityUpdate = (
city: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['cities'][number]
): Prisma.CityUpdateInput => ({
name: city.name,
nationId: city.nationId,
level: city.level,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
supplyState: city.supplyState,
frontState: city.frontState,
defence: city.defence,
defenceMax: city.defenceMax,
wall: city.wall,
wallMax: city.wallMax,
meta: asJson(city.meta),
});
): Prisma.CityUpdateInput => {
const meta = city.meta as Record<string, unknown>;
const trust = readMetaNumber(meta, 'trust');
const trade = readMetaNumber(meta, 'trade');
const region = readMetaNumber(meta, 'region');
const data: Prisma.CityUpdateInput = {
name: city.name,
nationId: city.nationId,
level: city.level,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
supplyState: city.supplyState,
frontState: city.frontState,
defence: city.defence,
defenceMax: city.defenceMax,
wall: city.wall,
wallMax: city.wallMax,
meta: asJson(city.meta),
};
if (trust !== null) {
data.trust = trust;
}
if (trade !== null) {
data.trade = trade;
}
if (region !== null) {
data.region = region;
}
return data;
};
const buildNationUpdate = (
nation: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['nations'][number]
@@ -4,11 +4,13 @@ import type {
GeneralActionDefinition,
GeneralActionResolveContext,
LogEntryDraft,
MapDefinition,
Nation,
ScenarioConfig,
ScenarioDiplomacy,
ScenarioMeta,
Troop,
UnitSetDefinition,
} from '@sammo-ts/logic';
import {
AssignmentActionDefinition,
@@ -17,6 +19,7 @@ import {
evaluateConstraints,
FireAttackActionDefinition,
NationRestActionDefinition,
RecruitActionDefinition,
resolveGeneralAction,
RestActionDefinition,
TalentScoutActionDefinition,
@@ -110,7 +113,10 @@ const resolveOptionalString = (
return null;
};
const buildCommandEnv = (config: ScenarioConfig): CommandEnv => {
const buildCommandEnv = (
config: ScenarioConfig,
unitSet?: UnitSetDefinition
): CommandEnv => {
const constValues = asRecord(config.const);
return {
@@ -167,7 +173,7 @@ const buildCommandEnv = (config: ScenarioConfig): CommandEnv => {
defaultCrewTypeId: resolveNumber(
constValues,
['defaultCrewTypeId'],
DEFAULT_CREW_TYPE_ID
unitSet?.defaultCrewTypeId ?? DEFAULT_CREW_TYPE_ID
),
defaultSpecialDomestic: resolveOptionalString(
constValues,
@@ -422,6 +428,7 @@ const buildGeneralDefinitions = (
definitions.set('che_화계', new FireAttackActionDefinition([], env));
definitions.set('che_인재탐색', new TalentScoutActionDefinition([], env));
definitions.set('che_의병모집', new VolunteerRecruitActionDefinition([], env));
definitions.set('che_징병', new RecruitActionDefinition([], {}));
definitions.set('휴식', new RestActionDefinition());
return definitions;
};
@@ -468,6 +475,8 @@ const buildActionContext = (
options: {
world: TurnWorldState;
scenarioMeta?: ScenarioMeta;
map?: MapDefinition;
unitSet?: UnitSetDefinition;
worldRef: InMemoryTurnWorld | null;
actionArgs: Record<string, unknown>;
createGeneralId: () => number;
@@ -535,6 +544,18 @@ const buildActionContext = (
destGeneralTurnTime: destGeneral.turnTime,
};
}
case 'che_징병':
if (!options.map || !options.unitSet) {
return null;
}
return {
...base,
map: options.map,
unitSet: options.unitSet,
cities: options.worldRef?.listCities() ?? [],
currentYear: options.world.currentYear,
startYear: resolveStartYear(options.world, options.scenarioMeta),
};
default:
return base;
}
@@ -573,9 +594,11 @@ export const createReservedTurnHandler = (options: {
scenarioConfig: ScenarioConfig;
scenarioMeta?: ScenarioMeta;
diplomacy: ScenarioDiplomacy[];
map?: MapDefinition;
unitSet?: UnitSetDefinition;
getWorld: () => InMemoryTurnWorld | null;
}): GeneralTurnHandler => {
const env = buildCommandEnv(options.scenarioConfig);
const env = buildCommandEnv(options.scenarioConfig, options.unitSet);
const generalDefinitions = buildGeneralDefinitions(env);
const nationDefinitions = buildNationDefinitions(env);
const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!;
@@ -597,10 +620,12 @@ export const createReservedTurnHandler = (options: {
return {
execute(context): GeneralTurnResult {
const worldRef = options.getWorld();
const constraintEnv = resolveConstraintEnv(
context.world,
options.scenarioMeta
);
const constraintEnv = {
...resolveConstraintEnv(context.world, options.scenarioMeta),
...(options.map ? { map: options.map } : {}),
...(options.unitSet ? { unitSet: options.unitSet } : {}),
cities: worldRef?.listCities() ?? [],
};
const logs: LogEntryDraft[] = [];
const patches = {
generals: [] as Array<{ id: number; patch: Partial<TurnGeneral> }>,
@@ -694,6 +719,8 @@ export const createReservedTurnHandler = (options: {
let specificContext = buildActionContext(actionKey, baseContext, {
world: context.world,
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.unitSet,
worldRef,
actionArgs: actionArgsRecord,
createGeneralId,
+2
View File
@@ -88,6 +88,8 @@ export const createTurnDaemonRuntime = async (
scenarioConfig: snapshot.scenarioConfig,
scenarioMeta: snapshot.scenarioMeta,
diplomacy: snapshot.diplomacy,
map: snapshot.map,
unitSet: snapshot.unitSet,
getWorld: () => worldRef,
}),
calendarHandler: options.calendarHandler,
+20 -2
View File
@@ -22,11 +22,14 @@ 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>;
@@ -162,6 +165,7 @@ const mapGeneralRow = (row: PrismaGeneral): TurnGeneral => ({
crew: row.crew,
crewTypeId: row.crewTypeId,
train: row.train,
atmos: row.atmos,
age: row.age,
npcState: row.npcState,
triggerState: {
@@ -194,7 +198,12 @@ const mapCityRow = (row: PrismaCity): City => ({
defenceMax: row.defenceMax,
wall: row.wall,
wallMax: row.wallMax,
meta: asTriggerRecord(row.meta),
meta: {
...asTriggerRecord(row.meta),
trust: row.trust,
trade: row.trade,
region: row.region,
},
});
const mapNationRow = (row: PrismaNation): Nation => ({
@@ -208,7 +217,10 @@ const mapNationRow = (row: PrismaNation): Nation => ({
power: 0,
level: row.level,
typeCode: row.typeCode,
meta: asTriggerRecord(row.meta),
meta: {
...asTriggerRecord(row.meta),
tech: row.tech,
},
});
const mapDiplomacyRow = (row: PrismaDiplomacy): ScenarioDiplomacy => ({
@@ -263,6 +275,11 @@ export const loadTurnWorldFromDatabase = async (
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);
@@ -313,6 +330,7 @@ export const loadTurnWorldFromDatabase = async (
scenarioConfig,
...(scenarioMeta ? { scenarioMeta } : {}),
map,
unitSet,
nations,
cities,
generals,