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:
@@ -44,6 +44,7 @@ export interface GeneralRow {
|
|||||||
crew: number;
|
crew: number;
|
||||||
crewTypeId: number;
|
crewTypeId: number;
|
||||||
train: number;
|
train: number;
|
||||||
|
atmos: number;
|
||||||
age: number;
|
age: number;
|
||||||
npcState: number;
|
npcState: number;
|
||||||
meta: unknown;
|
meta: unknown;
|
||||||
@@ -79,12 +80,15 @@ export interface CityRow {
|
|||||||
commerceMax: number;
|
commerceMax: number;
|
||||||
security: number;
|
security: number;
|
||||||
securityMax: number;
|
securityMax: number;
|
||||||
|
trust: number;
|
||||||
|
trade: number;
|
||||||
supplyState: number;
|
supplyState: number;
|
||||||
frontState: number;
|
frontState: number;
|
||||||
defence: number;
|
defence: number;
|
||||||
defenceMax: number;
|
defenceMax: number;
|
||||||
wall: number;
|
wall: number;
|
||||||
wallMax: number;
|
wallMax: number;
|
||||||
|
region: number;
|
||||||
meta: unknown;
|
meta: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +99,7 @@ export interface NationRow {
|
|||||||
capitalCityId: number | null;
|
capitalCityId: number | null;
|
||||||
gold: number;
|
gold: number;
|
||||||
rice: number;
|
rice: number;
|
||||||
|
tech: number;
|
||||||
level: number;
|
level: number;
|
||||||
typeCode: string;
|
typeCode: string;
|
||||||
meta: unknown;
|
meta: unknown;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
evaluateConstraints,
|
evaluateConstraints,
|
||||||
FireAttackActionDefinition,
|
FireAttackActionDefinition,
|
||||||
NationRestActionDefinition,
|
NationRestActionDefinition,
|
||||||
|
RecruitActionDefinition,
|
||||||
RestActionDefinition,
|
RestActionDefinition,
|
||||||
TalentScoutActionDefinition,
|
TalentScoutActionDefinition,
|
||||||
VolunteerRecruitActionDefinition,
|
VolunteerRecruitActionDefinition,
|
||||||
@@ -324,6 +325,7 @@ const mapGeneralRow = (row: GeneralRow): General => ({
|
|||||||
crew: row.crew,
|
crew: row.crew,
|
||||||
crewTypeId: row.crewTypeId,
|
crewTypeId: row.crewTypeId,
|
||||||
train: row.train,
|
train: row.train,
|
||||||
|
atmos: row.atmos,
|
||||||
age: row.age,
|
age: row.age,
|
||||||
npcState: row.npcState,
|
npcState: row.npcState,
|
||||||
triggerState: {
|
triggerState: {
|
||||||
@@ -354,7 +356,12 @@ const mapCityRow = (row: CityRow): City => ({
|
|||||||
defenceMax: row.defenceMax,
|
defenceMax: row.defenceMax,
|
||||||
wall: row.wall,
|
wall: row.wall,
|
||||||
wallMax: row.wallMax,
|
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 => ({
|
const mapNationRow = (row: NationRow): Nation => ({
|
||||||
@@ -368,7 +375,10 @@ const mapNationRow = (row: NationRow): Nation => ({
|
|||||||
power: 0,
|
power: 0,
|
||||||
level: row.level,
|
level: row.level,
|
||||||
typeCode: row.typeCode,
|
typeCode: row.typeCode,
|
||||||
meta: asTriggerRecord(row.meta),
|
meta: {
|
||||||
|
...asTriggerRecord(row.meta),
|
||||||
|
tech: row.tech,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildStateView = (
|
const buildStateView = (
|
||||||
@@ -465,6 +475,12 @@ const buildEntries = (env: CommandEnv): {
|
|||||||
reqArg: false,
|
reqArg: false,
|
||||||
args: {},
|
args: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
category: '내정',
|
||||||
|
definition: new RecruitActionDefinition(emptyModules, {}),
|
||||||
|
reqArg: true,
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
category: '계략',
|
category: '계략',
|
||||||
definition: new FireAttackActionDefinition(emptyModules, {
|
definition: new FireAttackActionDefinition(emptyModules, {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type { MapLoaderOptions } from './mapLoader.js';
|
|||||||
import { loadMapDefinitionByName } from './mapLoader.js';
|
import { loadMapDefinitionByName } from './mapLoader.js';
|
||||||
import type { ScenarioLoaderOptions } from './scenarioLoader.js';
|
import type { ScenarioLoaderOptions } from './scenarioLoader.js';
|
||||||
import { loadScenarioDefinitionById } 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_TICK_SECONDS = 120 * 60;
|
||||||
const DEFAULT_GENERAL_GOLD = 1000;
|
const DEFAULT_GENERAL_GOLD = 1000;
|
||||||
@@ -21,6 +23,7 @@ export interface ScenarioSeedOptions {
|
|||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
scenarioOptions?: ScenarioLoaderOptions;
|
scenarioOptions?: ScenarioLoaderOptions;
|
||||||
mapOptions?: MapLoaderOptions;
|
mapOptions?: MapLoaderOptions;
|
||||||
|
unitSetOptions?: UnitSetLoaderOptions;
|
||||||
resetTables?: boolean;
|
resetTables?: boolean;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
tickSeconds?: number;
|
tickSeconds?: number;
|
||||||
@@ -97,10 +100,15 @@ export const seedScenarioToDatabase = async (
|
|||||||
scenario.config.environment.mapName,
|
scenario.config.environment.mapName,
|
||||||
options.mapOptions
|
options.mapOptions
|
||||||
);
|
);
|
||||||
|
const unitSet = await loadUnitSetDefinitionByName(
|
||||||
|
scenario.config.environment.unitSet,
|
||||||
|
options.unitSetOptions
|
||||||
|
);
|
||||||
|
|
||||||
const { seed, warnings } = buildScenarioBootstrap({
|
const { seed, warnings } = buildScenarioBootstrap({
|
||||||
scenario,
|
scenario,
|
||||||
map,
|
map,
|
||||||
|
unitSet,
|
||||||
options: {
|
options: {
|
||||||
includeNeutralNationInSeed:
|
includeNeutralNationInSeed:
|
||||||
options.includeNeutralNationInSeed ?? true,
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -18,6 +18,14 @@ const asJson = (value: unknown): Prisma.InputJsonValue =>
|
|||||||
const toCode = (value: string | null | undefined): string =>
|
const toCode = (value: string | null | undefined): string =>
|
||||||
value && value !== 'None' ? value : 'None';
|
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 = (
|
const buildGeneralUpdate = (
|
||||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||||
): Prisma.GeneralUpdateInput => ({
|
): Prisma.GeneralUpdateInput => ({
|
||||||
@@ -37,6 +45,7 @@ const buildGeneralUpdate = (
|
|||||||
crew: general.crew,
|
crew: general.crew,
|
||||||
crewTypeId: general.crewTypeId,
|
crewTypeId: general.crewTypeId,
|
||||||
train: general.train,
|
train: general.train,
|
||||||
|
atmos: general.atmos,
|
||||||
age: general.age,
|
age: general.age,
|
||||||
npcState: general.npcState,
|
npcState: general.npcState,
|
||||||
horseCode: toCode(general.role.items.horse),
|
horseCode: toCode(general.role.items.horse),
|
||||||
@@ -72,6 +81,7 @@ const buildGeneralCreate = (
|
|||||||
crew: general.crew,
|
crew: general.crew,
|
||||||
crewTypeId: general.crewTypeId,
|
crewTypeId: general.crewTypeId,
|
||||||
train: general.train,
|
train: general.train,
|
||||||
|
atmos: general.atmos,
|
||||||
age: general.age,
|
age: general.age,
|
||||||
horseCode: toCode(general.role.items.horse),
|
horseCode: toCode(general.role.items.horse),
|
||||||
weaponCode: toCode(general.role.items.weapon),
|
weaponCode: toCode(general.role.items.weapon),
|
||||||
@@ -87,26 +97,45 @@ const buildGeneralCreate = (
|
|||||||
|
|
||||||
const buildCityUpdate = (
|
const buildCityUpdate = (
|
||||||
city: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['cities'][number]
|
city: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['cities'][number]
|
||||||
): Prisma.CityUpdateInput => ({
|
): Prisma.CityUpdateInput => {
|
||||||
name: city.name,
|
const meta = city.meta as Record<string, unknown>;
|
||||||
nationId: city.nationId,
|
const trust = readMetaNumber(meta, 'trust');
|
||||||
level: city.level,
|
const trade = readMetaNumber(meta, 'trade');
|
||||||
population: city.population,
|
const region = readMetaNumber(meta, 'region');
|
||||||
populationMax: city.populationMax,
|
|
||||||
agriculture: city.agriculture,
|
const data: Prisma.CityUpdateInput = {
|
||||||
agricultureMax: city.agricultureMax,
|
name: city.name,
|
||||||
commerce: city.commerce,
|
nationId: city.nationId,
|
||||||
commerceMax: city.commerceMax,
|
level: city.level,
|
||||||
security: city.security,
|
population: city.population,
|
||||||
securityMax: city.securityMax,
|
populationMax: city.populationMax,
|
||||||
supplyState: city.supplyState,
|
agriculture: city.agriculture,
|
||||||
frontState: city.frontState,
|
agricultureMax: city.agricultureMax,
|
||||||
defence: city.defence,
|
commerce: city.commerce,
|
||||||
defenceMax: city.defenceMax,
|
commerceMax: city.commerceMax,
|
||||||
wall: city.wall,
|
security: city.security,
|
||||||
wallMax: city.wallMax,
|
securityMax: city.securityMax,
|
||||||
meta: asJson(city.meta),
|
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 = (
|
const buildNationUpdate = (
|
||||||
nation: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['nations'][number]
|
nation: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['nations'][number]
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import type {
|
|||||||
GeneralActionDefinition,
|
GeneralActionDefinition,
|
||||||
GeneralActionResolveContext,
|
GeneralActionResolveContext,
|
||||||
LogEntryDraft,
|
LogEntryDraft,
|
||||||
|
MapDefinition,
|
||||||
Nation,
|
Nation,
|
||||||
ScenarioConfig,
|
ScenarioConfig,
|
||||||
ScenarioDiplomacy,
|
ScenarioDiplomacy,
|
||||||
ScenarioMeta,
|
ScenarioMeta,
|
||||||
Troop,
|
Troop,
|
||||||
|
UnitSetDefinition,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
AssignmentActionDefinition,
|
AssignmentActionDefinition,
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
evaluateConstraints,
|
evaluateConstraints,
|
||||||
FireAttackActionDefinition,
|
FireAttackActionDefinition,
|
||||||
NationRestActionDefinition,
|
NationRestActionDefinition,
|
||||||
|
RecruitActionDefinition,
|
||||||
resolveGeneralAction,
|
resolveGeneralAction,
|
||||||
RestActionDefinition,
|
RestActionDefinition,
|
||||||
TalentScoutActionDefinition,
|
TalentScoutActionDefinition,
|
||||||
@@ -110,7 +113,10 @@ const resolveOptionalString = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildCommandEnv = (config: ScenarioConfig): CommandEnv => {
|
const buildCommandEnv = (
|
||||||
|
config: ScenarioConfig,
|
||||||
|
unitSet?: UnitSetDefinition
|
||||||
|
): CommandEnv => {
|
||||||
const constValues = asRecord(config.const);
|
const constValues = asRecord(config.const);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -167,7 +173,7 @@ const buildCommandEnv = (config: ScenarioConfig): CommandEnv => {
|
|||||||
defaultCrewTypeId: resolveNumber(
|
defaultCrewTypeId: resolveNumber(
|
||||||
constValues,
|
constValues,
|
||||||
['defaultCrewTypeId'],
|
['defaultCrewTypeId'],
|
||||||
DEFAULT_CREW_TYPE_ID
|
unitSet?.defaultCrewTypeId ?? DEFAULT_CREW_TYPE_ID
|
||||||
),
|
),
|
||||||
defaultSpecialDomestic: resolveOptionalString(
|
defaultSpecialDomestic: resolveOptionalString(
|
||||||
constValues,
|
constValues,
|
||||||
@@ -422,6 +428,7 @@ const buildGeneralDefinitions = (
|
|||||||
definitions.set('che_화계', new FireAttackActionDefinition([], env));
|
definitions.set('che_화계', new FireAttackActionDefinition([], env));
|
||||||
definitions.set('che_인재탐색', new TalentScoutActionDefinition([], env));
|
definitions.set('che_인재탐색', new TalentScoutActionDefinition([], env));
|
||||||
definitions.set('che_의병모집', new VolunteerRecruitActionDefinition([], env));
|
definitions.set('che_의병모집', new VolunteerRecruitActionDefinition([], env));
|
||||||
|
definitions.set('che_징병', new RecruitActionDefinition([], {}));
|
||||||
definitions.set('휴식', new RestActionDefinition());
|
definitions.set('휴식', new RestActionDefinition());
|
||||||
return definitions;
|
return definitions;
|
||||||
};
|
};
|
||||||
@@ -468,6 +475,8 @@ const buildActionContext = (
|
|||||||
options: {
|
options: {
|
||||||
world: TurnWorldState;
|
world: TurnWorldState;
|
||||||
scenarioMeta?: ScenarioMeta;
|
scenarioMeta?: ScenarioMeta;
|
||||||
|
map?: MapDefinition;
|
||||||
|
unitSet?: UnitSetDefinition;
|
||||||
worldRef: InMemoryTurnWorld | null;
|
worldRef: InMemoryTurnWorld | null;
|
||||||
actionArgs: Record<string, unknown>;
|
actionArgs: Record<string, unknown>;
|
||||||
createGeneralId: () => number;
|
createGeneralId: () => number;
|
||||||
@@ -535,6 +544,18 @@ const buildActionContext = (
|
|||||||
destGeneralTurnTime: destGeneral.turnTime,
|
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:
|
default:
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
@@ -573,9 +594,11 @@ export const createReservedTurnHandler = (options: {
|
|||||||
scenarioConfig: ScenarioConfig;
|
scenarioConfig: ScenarioConfig;
|
||||||
scenarioMeta?: ScenarioMeta;
|
scenarioMeta?: ScenarioMeta;
|
||||||
diplomacy: ScenarioDiplomacy[];
|
diplomacy: ScenarioDiplomacy[];
|
||||||
|
map?: MapDefinition;
|
||||||
|
unitSet?: UnitSetDefinition;
|
||||||
getWorld: () => InMemoryTurnWorld | null;
|
getWorld: () => InMemoryTurnWorld | null;
|
||||||
}): GeneralTurnHandler => {
|
}): GeneralTurnHandler => {
|
||||||
const env = buildCommandEnv(options.scenarioConfig);
|
const env = buildCommandEnv(options.scenarioConfig, options.unitSet);
|
||||||
const generalDefinitions = buildGeneralDefinitions(env);
|
const generalDefinitions = buildGeneralDefinitions(env);
|
||||||
const nationDefinitions = buildNationDefinitions(env);
|
const nationDefinitions = buildNationDefinitions(env);
|
||||||
const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!;
|
const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!;
|
||||||
@@ -597,10 +620,12 @@ export const createReservedTurnHandler = (options: {
|
|||||||
return {
|
return {
|
||||||
execute(context): GeneralTurnResult {
|
execute(context): GeneralTurnResult {
|
||||||
const worldRef = options.getWorld();
|
const worldRef = options.getWorld();
|
||||||
const constraintEnv = resolveConstraintEnv(
|
const constraintEnv = {
|
||||||
context.world,
|
...resolveConstraintEnv(context.world, options.scenarioMeta),
|
||||||
options.scenarioMeta
|
...(options.map ? { map: options.map } : {}),
|
||||||
);
|
...(options.unitSet ? { unitSet: options.unitSet } : {}),
|
||||||
|
cities: worldRef?.listCities() ?? [],
|
||||||
|
};
|
||||||
const logs: LogEntryDraft[] = [];
|
const logs: LogEntryDraft[] = [];
|
||||||
const patches = {
|
const patches = {
|
||||||
generals: [] as Array<{ id: number; patch: Partial<TurnGeneral> }>,
|
generals: [] as Array<{ id: number; patch: Partial<TurnGeneral> }>,
|
||||||
@@ -694,6 +719,8 @@ export const createReservedTurnHandler = (options: {
|
|||||||
let specificContext = buildActionContext(actionKey, baseContext, {
|
let specificContext = buildActionContext(actionKey, baseContext, {
|
||||||
world: context.world,
|
world: context.world,
|
||||||
scenarioMeta: options.scenarioMeta,
|
scenarioMeta: options.scenarioMeta,
|
||||||
|
map: options.map,
|
||||||
|
unitSet: options.unitSet,
|
||||||
worldRef,
|
worldRef,
|
||||||
actionArgs: actionArgsRecord,
|
actionArgs: actionArgsRecord,
|
||||||
createGeneralId,
|
createGeneralId,
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export const createTurnDaemonRuntime = async (
|
|||||||
scenarioConfig: snapshot.scenarioConfig,
|
scenarioConfig: snapshot.scenarioConfig,
|
||||||
scenarioMeta: snapshot.scenarioMeta,
|
scenarioMeta: snapshot.scenarioMeta,
|
||||||
diplomacy: snapshot.diplomacy,
|
diplomacy: snapshot.diplomacy,
|
||||||
|
map: snapshot.map,
|
||||||
|
unitSet: snapshot.unitSet,
|
||||||
getWorld: () => worldRef,
|
getWorld: () => worldRef,
|
||||||
}),
|
}),
|
||||||
calendarHandler: options.calendarHandler,
|
calendarHandler: options.calendarHandler,
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ import { z } from 'zod';
|
|||||||
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
||||||
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
|
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
|
||||||
import { loadMapDefinitionByName } 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';
|
import type { TurnGeneral, TurnWorldLoadResult } from './types.js';
|
||||||
|
|
||||||
interface TurnWorldLoaderOptions {
|
interface TurnWorldLoaderOptions {
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
mapOptions?: MapLoaderOptions;
|
mapOptions?: MapLoaderOptions;
|
||||||
|
unitSetOptions?: UnitSetLoaderOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>;
|
type JsonRecord = Record<string, unknown>;
|
||||||
@@ -162,6 +165,7 @@ const mapGeneralRow = (row: PrismaGeneral): TurnGeneral => ({
|
|||||||
crew: row.crew,
|
crew: row.crew,
|
||||||
crewTypeId: row.crewTypeId,
|
crewTypeId: row.crewTypeId,
|
||||||
train: row.train,
|
train: row.train,
|
||||||
|
atmos: row.atmos,
|
||||||
age: row.age,
|
age: row.age,
|
||||||
npcState: row.npcState,
|
npcState: row.npcState,
|
||||||
triggerState: {
|
triggerState: {
|
||||||
@@ -194,7 +198,12 @@ const mapCityRow = (row: PrismaCity): City => ({
|
|||||||
defenceMax: row.defenceMax,
|
defenceMax: row.defenceMax,
|
||||||
wall: row.wall,
|
wall: row.wall,
|
||||||
wallMax: row.wallMax,
|
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 => ({
|
const mapNationRow = (row: PrismaNation): Nation => ({
|
||||||
@@ -208,7 +217,10 @@ const mapNationRow = (row: PrismaNation): Nation => ({
|
|||||||
power: 0,
|
power: 0,
|
||||||
level: row.level,
|
level: row.level,
|
||||||
typeCode: row.typeCode,
|
typeCode: row.typeCode,
|
||||||
meta: asTriggerRecord(row.meta),
|
meta: {
|
||||||
|
...asTriggerRecord(row.meta),
|
||||||
|
tech: row.tech,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDiplomacyRow = (row: PrismaDiplomacy): ScenarioDiplomacy => ({
|
const mapDiplomacyRow = (row: PrismaDiplomacy): ScenarioDiplomacy => ({
|
||||||
@@ -263,6 +275,11 @@ export const loadTurnWorldFromDatabase = async (
|
|||||||
const scenarioConfig = mapScenarioConfig(worldState.config);
|
const scenarioConfig = mapScenarioConfig(worldState.config);
|
||||||
const mapName = scenarioConfig.environment?.mapName ?? 'che';
|
const mapName = scenarioConfig.environment?.mapName ?? 'che';
|
||||||
const map = await loadMapDefinitionByName(mapName, options.mapOptions);
|
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 meta = asRecord(worldState.meta);
|
||||||
const scenarioMeta = parseScenarioMeta(meta);
|
const scenarioMeta = parseScenarioMeta(meta);
|
||||||
@@ -313,6 +330,7 @@ export const loadTurnWorldFromDatabase = async (
|
|||||||
scenarioConfig,
|
scenarioConfig,
|
||||||
...(scenarioMeta ? { scenarioMeta } : {}),
|
...(scenarioMeta ? { scenarioMeta } : {}),
|
||||||
map,
|
map,
|
||||||
|
unitSet,
|
||||||
nations,
|
nations,
|
||||||
cities,
|
cities,
|
||||||
generals,
|
generals,
|
||||||
|
|||||||
@@ -0,0 +1,602 @@
|
|||||||
|
import type {
|
||||||
|
City,
|
||||||
|
General,
|
||||||
|
GeneralTriggerState,
|
||||||
|
Nation,
|
||||||
|
TriggerValue,
|
||||||
|
} from '../../../domain/entities.js';
|
||||||
|
import type {
|
||||||
|
Constraint,
|
||||||
|
ConstraintContext,
|
||||||
|
RequirementKey,
|
||||||
|
StateView,
|
||||||
|
} from '../../../constraints/types.js';
|
||||||
|
import {
|
||||||
|
notBeNeutral,
|
||||||
|
occupiedCity,
|
||||||
|
reqCityCapacity,
|
||||||
|
reqCityTrust,
|
||||||
|
reqGeneralCrewMargin,
|
||||||
|
reqGeneralGold,
|
||||||
|
reqGeneralRice,
|
||||||
|
} from '../../../constraints/presets.js';
|
||||||
|
import {
|
||||||
|
GeneralActionPipeline,
|
||||||
|
type GeneralActionModule,
|
||||||
|
} from '../../../triggers/general-action.js';
|
||||||
|
import type { GeneralActionDefinition } from '../../definition.js';
|
||||||
|
import type {
|
||||||
|
GeneralActionEffect,
|
||||||
|
GeneralActionOutcome,
|
||||||
|
GeneralActionResolveContext,
|
||||||
|
} from '../../engine.js';
|
||||||
|
import {
|
||||||
|
createCityPatchEffect,
|
||||||
|
createGeneralPatchEffect,
|
||||||
|
createLogEffect,
|
||||||
|
} from '../../engine.js';
|
||||||
|
import type { MapDefinition, UnitSetDefinition } from '../../../world/types.js';
|
||||||
|
import {
|
||||||
|
type CrewTypeAvailabilityContext,
|
||||||
|
findCrewTypeById,
|
||||||
|
getTechCost,
|
||||||
|
isCrewTypeAvailable,
|
||||||
|
} from '../../../world/unitSet.js';
|
||||||
|
|
||||||
|
export interface RecruitArgs {
|
||||||
|
crewType: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecruitEnvironment {
|
||||||
|
costOffset?: number;
|
||||||
|
defaultTrain?: number;
|
||||||
|
defaultAtmos?: number;
|
||||||
|
minAvailableRecruitPop?: number;
|
||||||
|
defaultTrust?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecruitResolveContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
map: MapDefinition;
|
||||||
|
unitSet: UnitSetDefinition;
|
||||||
|
cities: City[];
|
||||||
|
currentYear?: number;
|
||||||
|
startYear?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_NAME = '징병';
|
||||||
|
const DEFAULT_COST_OFFSET = 1;
|
||||||
|
const DEFAULT_TRAIN = 40;
|
||||||
|
const DEFAULT_ATMOS = 40;
|
||||||
|
const DEFAULT_MIN_POP = 30000;
|
||||||
|
const DEFAULT_TRUST = 50;
|
||||||
|
const MIN_CREW = 100;
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number | null, max: number | null): number => {
|
||||||
|
if (max !== null && min !== null && max < min) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
if (min !== null && value < min) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
if (max !== null && value > max) {
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readNationTech = (nation: Nation | null | undefined): number => {
|
||||||
|
if (!nation) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const tech = nation.meta.tech;
|
||||||
|
return typeof tech === 'number' ? tech : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readCityTrust = (
|
||||||
|
city: City,
|
||||||
|
fallback: number
|
||||||
|
): number => {
|
||||||
|
const meta = city.meta as Record<string, unknown>;
|
||||||
|
const trust = meta?.trust;
|
||||||
|
return typeof trust === 'number' ? trust : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMetaNumber = (
|
||||||
|
meta: Record<string, TriggerValue>,
|
||||||
|
key: string,
|
||||||
|
delta: number
|
||||||
|
): Record<string, TriggerValue> => {
|
||||||
|
const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0;
|
||||||
|
return { ...meta, [key]: current + delta };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCrewTypeId = (args: Record<string, unknown>): number | null => {
|
||||||
|
const raw = args.crewType ?? args.crewTypeId;
|
||||||
|
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = Math.floor(raw);
|
||||||
|
return value > 0 ? value : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCrewAmount = (args: Record<string, unknown>): number | null => {
|
||||||
|
const raw = args.amount;
|
||||||
|
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = Math.floor(raw);
|
||||||
|
if (value < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCrewTypeContext = (
|
||||||
|
ctx: ConstraintContext,
|
||||||
|
view: StateView
|
||||||
|
): CrewTypeAvailabilityContext | null => {
|
||||||
|
const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||||
|
const general = view.get(generalReq) as General | null;
|
||||||
|
if (!general) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nationId = ctx.nationId ?? general.nationId;
|
||||||
|
const nationReq: RequirementKey = { kind: 'nation', id: nationId };
|
||||||
|
const nation =
|
||||||
|
nationId > 0 ? ((view.get(nationReq) as Nation | null) ?? null) : null;
|
||||||
|
const map = ctx.env.map;
|
||||||
|
const cities = ctx.env.cities;
|
||||||
|
if (!map || !cities || !Array.isArray(cities)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const currentYear =
|
||||||
|
typeof ctx.env.currentYear === 'number'
|
||||||
|
? ctx.env.currentYear
|
||||||
|
: typeof ctx.env.year === 'number'
|
||||||
|
? ctx.env.year
|
||||||
|
: undefined;
|
||||||
|
const startYear =
|
||||||
|
typeof ctx.env.startYear === 'number' ? ctx.env.startYear : undefined;
|
||||||
|
const result: CrewTypeAvailabilityContext = {
|
||||||
|
general,
|
||||||
|
nation,
|
||||||
|
map: map as MapDefinition,
|
||||||
|
cities: cities as City[],
|
||||||
|
};
|
||||||
|
if (currentYear !== undefined) {
|
||||||
|
result.currentYear = currentYear;
|
||||||
|
}
|
||||||
|
if (startYear !== undefined) {
|
||||||
|
result.startYear = startYear;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecruitCalcContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||||
|
> = {
|
||||||
|
general: General<TriggerState>;
|
||||||
|
city?: City;
|
||||||
|
nation?: Nation | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCalcContext = <TriggerState extends GeneralTriggerState>(
|
||||||
|
ctx: ConstraintContext,
|
||||||
|
view: StateView
|
||||||
|
): RecruitCalcContext<TriggerState> | null => {
|
||||||
|
const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||||
|
const general = view.get(generalReq) as General<TriggerState> | null;
|
||||||
|
if (!general) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nationId = ctx.nationId ?? general.nationId;
|
||||||
|
const nationReq: RequirementKey = { kind: 'nation', id: nationId };
|
||||||
|
const nation =
|
||||||
|
nationId > 0 ? ((view.get(nationReq) as Nation | null) ?? null) : null;
|
||||||
|
const city =
|
||||||
|
ctx.cityId !== undefined
|
||||||
|
? ((view.get({ kind: 'city', id: ctx.cityId }) as City | null) ??
|
||||||
|
undefined)
|
||||||
|
: undefined;
|
||||||
|
const result: RecruitCalcContext<TriggerState> = { general };
|
||||||
|
if (city) {
|
||||||
|
result.city = city;
|
||||||
|
}
|
||||||
|
if (nation !== undefined) {
|
||||||
|
result.nation = nation;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CommandResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||||
|
> {
|
||||||
|
// 징병 명령의 비용/훈련/사기 계산을 담당한다.
|
||||||
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
private readonly env: RecruitEnvironment;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||||
|
env: RecruitEnvironment
|
||||||
|
) {
|
||||||
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
|
this.env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveLeadership(context: RecruitCalcContext<TriggerState>): number {
|
||||||
|
const base = context.general.stats.leadership;
|
||||||
|
return Math.round(
|
||||||
|
this.pipeline.onCalcStat(context, 'leadership', base)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveCrewPlan(
|
||||||
|
context: RecruitCalcContext<TriggerState>,
|
||||||
|
crewTypeId: number,
|
||||||
|
amount: number
|
||||||
|
): { requested: number; applied: number } {
|
||||||
|
const leadership = this.resolveLeadership(context);
|
||||||
|
let maxCrew = leadership * 100;
|
||||||
|
if (crewTypeId === context.general.crewTypeId) {
|
||||||
|
maxCrew -= context.general.crew;
|
||||||
|
}
|
||||||
|
const requested = clamp(amount, MIN_CREW, null);
|
||||||
|
const applied = clamp(amount, MIN_CREW, maxCrew);
|
||||||
|
return { requested, applied };
|
||||||
|
}
|
||||||
|
|
||||||
|
getCost(
|
||||||
|
context: RecruitCalcContext<TriggerState>,
|
||||||
|
crewTypeId: number,
|
||||||
|
amount: number,
|
||||||
|
crewType?: { armType: number; cost: number }
|
||||||
|
): { gold: number; rice: number; applied: number; requested: number } {
|
||||||
|
const plan = this.resolveCrewPlan(context, crewTypeId, amount);
|
||||||
|
const tech = readNationTech(context.nation ?? null);
|
||||||
|
const costOffset = this.env.costOffset ?? DEFAULT_COST_OFFSET;
|
||||||
|
const baseGold = crewType
|
||||||
|
? crewType.cost * getTechCost(tech) * plan.applied / 100
|
||||||
|
: 0;
|
||||||
|
const adjustedGold = this.pipeline.onCalcDomestic(
|
||||||
|
context,
|
||||||
|
ACTION_NAME,
|
||||||
|
'cost',
|
||||||
|
baseGold,
|
||||||
|
crewType ? { armType: crewType.armType } : undefined
|
||||||
|
);
|
||||||
|
const baseRice = plan.applied / 100;
|
||||||
|
const adjustedRice = this.pipeline.onCalcDomestic(
|
||||||
|
context,
|
||||||
|
ACTION_NAME,
|
||||||
|
'rice',
|
||||||
|
baseRice,
|
||||||
|
crewType ? { armType: crewType.armType } : undefined
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
gold: Math.round(adjustedGold * costOffset),
|
||||||
|
rice: Math.round(adjustedRice),
|
||||||
|
applied: plan.applied,
|
||||||
|
requested: plan.requested,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrain(
|
||||||
|
context: RecruitCalcContext<TriggerState>,
|
||||||
|
crewType?: { armType: number }
|
||||||
|
): number {
|
||||||
|
const base = this.env.defaultTrain ?? DEFAULT_TRAIN;
|
||||||
|
return this.pipeline.onCalcDomestic(
|
||||||
|
context,
|
||||||
|
ACTION_NAME,
|
||||||
|
'train',
|
||||||
|
base,
|
||||||
|
crewType ? { armType: crewType.armType } : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAtmos(
|
||||||
|
context: RecruitCalcContext<TriggerState>,
|
||||||
|
crewType?: { armType: number }
|
||||||
|
): number {
|
||||||
|
const base = this.env.defaultAtmos ?? DEFAULT_ATMOS;
|
||||||
|
return this.pipeline.onCalcDomestic(
|
||||||
|
context,
|
||||||
|
ACTION_NAME,
|
||||||
|
'atmos',
|
||||||
|
base,
|
||||||
|
crewType ? { armType: crewType.armType } : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecruitPopulation(
|
||||||
|
context: RecruitCalcContext<TriggerState>,
|
||||||
|
amount: number
|
||||||
|
): number {
|
||||||
|
const base = this.pipeline.onCalcDomestic(
|
||||||
|
context,
|
||||||
|
'징집인구',
|
||||||
|
'score',
|
||||||
|
amount
|
||||||
|
);
|
||||||
|
return Math.round(base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||||
|
> {
|
||||||
|
// 징병 실행 결과를 계산하고 효과로 변환한다.
|
||||||
|
private readonly env: RecruitEnvironment;
|
||||||
|
private readonly command: CommandResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||||
|
env: RecruitEnvironment
|
||||||
|
) {
|
||||||
|
this.env = env;
|
||||||
|
this.command = new CommandResolver(modules, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
context: RecruitResolveContext<TriggerState>,
|
||||||
|
args: RecruitArgs
|
||||||
|
): GeneralActionOutcome<TriggerState> {
|
||||||
|
const { general, city } = context;
|
||||||
|
if (!city) {
|
||||||
|
return {
|
||||||
|
effects: [createLogEffect('도시 정보가 없습니다.')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const crewType = findCrewTypeById(context.unitSet, args.crewType);
|
||||||
|
if (!crewType) {
|
||||||
|
return {
|
||||||
|
effects: [createLogEffect('병종 정보가 없습니다.')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const availabilityContext: CrewTypeAvailabilityContext = {
|
||||||
|
general,
|
||||||
|
nation: context.nation ?? null,
|
||||||
|
map: context.map,
|
||||||
|
cities: context.cities,
|
||||||
|
};
|
||||||
|
if (context.currentYear !== undefined) {
|
||||||
|
availabilityContext.currentYear = context.currentYear;
|
||||||
|
}
|
||||||
|
if (context.startYear !== undefined) {
|
||||||
|
availabilityContext.startYear = context.startYear;
|
||||||
|
}
|
||||||
|
if (!isCrewTypeAvailable(context.unitSet, crewType.id, availabilityContext)) {
|
||||||
|
return {
|
||||||
|
effects: [createLogEffect('현재 선택할 수 없는 병종입니다.')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = this.command.getCost(
|
||||||
|
context,
|
||||||
|
crewType.id,
|
||||||
|
args.amount,
|
||||||
|
crewType
|
||||||
|
);
|
||||||
|
const setTrain = this.command.getTrain(context, crewType);
|
||||||
|
const setAtmos = this.command.getAtmos(context, crewType);
|
||||||
|
const appliedCrew = plan.applied;
|
||||||
|
|
||||||
|
const costOffset = this.env.costOffset ?? DEFAULT_COST_OFFSET;
|
||||||
|
const recruitPop = this.command.getRecruitPopulation(
|
||||||
|
context,
|
||||||
|
appliedCrew
|
||||||
|
);
|
||||||
|
const nextPopulation = Math.max(city.population - recruitPop, 0);
|
||||||
|
const baseTrust = readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST);
|
||||||
|
const trustLoss =
|
||||||
|
city.population > 0
|
||||||
|
? (recruitPop / city.population) / costOffset * 100
|
||||||
|
: 0;
|
||||||
|
const nextTrust = Math.max(baseTrust - trustLoss, 0);
|
||||||
|
|
||||||
|
let nextCrewTypeId = general.crewTypeId;
|
||||||
|
let nextCrew = general.crew;
|
||||||
|
let nextTrain = general.train;
|
||||||
|
let nextAtmos = general.atmos;
|
||||||
|
let logMessage = '';
|
||||||
|
|
||||||
|
const crewLabel = `${crewType.name} ${appliedCrew}명`;
|
||||||
|
if (crewType.id === general.crewTypeId && general.crew > 0) {
|
||||||
|
nextCrew = general.crew + appliedCrew;
|
||||||
|
nextTrain = Math.round(
|
||||||
|
(general.crew * general.train + appliedCrew * setTrain) /
|
||||||
|
(general.crew + appliedCrew)
|
||||||
|
);
|
||||||
|
nextAtmos = Math.round(
|
||||||
|
(general.crew * general.atmos + appliedCrew * setAtmos) /
|
||||||
|
(general.crew + appliedCrew)
|
||||||
|
);
|
||||||
|
logMessage = `${crewLabel} 추가 ${ACTION_NAME}했습니다.`;
|
||||||
|
} else {
|
||||||
|
nextCrewTypeId = crewType.id;
|
||||||
|
nextCrew = appliedCrew;
|
||||||
|
nextTrain = Math.round(setTrain);
|
||||||
|
nextAtmos = Math.round(setAtmos);
|
||||||
|
logMessage = `${crewLabel} ${ACTION_NAME}했습니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextGold = Math.max(0, general.gold - plan.gold);
|
||||||
|
const nextRice = Math.max(0, general.rice - plan.rice);
|
||||||
|
const expGain = Math.round(appliedCrew / 100);
|
||||||
|
const dedGain = Math.round(appliedCrew / 100);
|
||||||
|
|
||||||
|
const metaUpdated = addMetaNumber(general.meta, 'leadership_exp', 1);
|
||||||
|
|
||||||
|
const effects: Array<GeneralActionEffect<TriggerState>> = [
|
||||||
|
createCityPatchEffect({
|
||||||
|
population: nextPopulation,
|
||||||
|
meta: {
|
||||||
|
trust: nextTrust,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createGeneralPatchEffect({
|
||||||
|
crewTypeId: nextCrewTypeId,
|
||||||
|
crew: nextCrew,
|
||||||
|
train: nextTrain,
|
||||||
|
atmos: nextAtmos,
|
||||||
|
gold: nextGold,
|
||||||
|
rice: nextRice,
|
||||||
|
experience: general.experience + expGain,
|
||||||
|
dedication: general.dedication + dedGain,
|
||||||
|
meta: metaUpdated,
|
||||||
|
}),
|
||||||
|
createLogEffect(logMessage),
|
||||||
|
];
|
||||||
|
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||||
|
> implements GeneralActionDefinition<TriggerState, RecruitArgs, RecruitResolveContext<TriggerState>> {
|
||||||
|
public readonly key = 'che_징병';
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly command: CommandResolver<TriggerState>;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
private readonly env: RecruitEnvironment;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||||
|
env: RecruitEnvironment
|
||||||
|
) {
|
||||||
|
this.command = new CommandResolver(modules, env);
|
||||||
|
this.resolver = new ActionResolver(modules, env);
|
||||||
|
this.env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(raw: unknown): RecruitArgs | null {
|
||||||
|
const data = isRecord(raw) ? raw : {};
|
||||||
|
const crewTypeId = resolveCrewTypeId(data);
|
||||||
|
const amount = resolveCrewAmount(data);
|
||||||
|
if (crewTypeId === null || amount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { crewType: crewTypeId, amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(
|
||||||
|
ctx: ConstraintContext,
|
||||||
|
_args: RecruitArgs
|
||||||
|
): Constraint[] {
|
||||||
|
const requirements: RequirementKey[] = [
|
||||||
|
{ kind: 'arg', key: 'crewType' },
|
||||||
|
{ kind: 'arg', key: 'amount' },
|
||||||
|
];
|
||||||
|
if (ctx.cityId !== undefined) {
|
||||||
|
requirements.push({ kind: 'city', id: ctx.cityId });
|
||||||
|
}
|
||||||
|
if (ctx.nationId !== undefined) {
|
||||||
|
requirements.push({ kind: 'nation', id: ctx.nationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const minPopBase = this.env.minAvailableRecruitPop ?? DEFAULT_MIN_POP;
|
||||||
|
const resolveRequestedCrew = (context: ConstraintContext): number => {
|
||||||
|
const amount = resolveCrewAmount(context.args);
|
||||||
|
return clamp(amount ?? MIN_CREW, MIN_CREW, null);
|
||||||
|
};
|
||||||
|
const getCost = (context: ConstraintContext, view: StateView): number => {
|
||||||
|
const crewTypeId = resolveCrewTypeId(context.args);
|
||||||
|
if (crewTypeId === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const calcContext = buildCalcContext<TriggerState>(context, view);
|
||||||
|
if (!calcContext) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const unitSet = context.env.unitSet as UnitSetDefinition | undefined;
|
||||||
|
const crewType = unitSet ? findCrewTypeById(unitSet, crewTypeId) : null;
|
||||||
|
return this.command.getCost(
|
||||||
|
calcContext,
|
||||||
|
crewTypeId,
|
||||||
|
resolveCrewAmount(context.args) ?? 0,
|
||||||
|
crewType ?? undefined
|
||||||
|
).gold;
|
||||||
|
};
|
||||||
|
const getRice = (context: ConstraintContext, view: StateView): number => {
|
||||||
|
const crewTypeId = resolveCrewTypeId(context.args);
|
||||||
|
if (crewTypeId === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const calcContext = buildCalcContext<TriggerState>(context, view);
|
||||||
|
if (!calcContext) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const unitSet = context.env.unitSet as UnitSetDefinition | undefined;
|
||||||
|
const crewType = unitSet ? findCrewTypeById(unitSet, crewTypeId) : null;
|
||||||
|
return this.command.getCost(
|
||||||
|
calcContext,
|
||||||
|
crewTypeId,
|
||||||
|
resolveCrewAmount(context.args) ?? 0,
|
||||||
|
crewType ?? undefined
|
||||||
|
).rice;
|
||||||
|
};
|
||||||
|
const checkCrewTypeAvailable = (): Constraint => ({
|
||||||
|
name: 'AvailableRecruitCrewType',
|
||||||
|
requires: () => requirements,
|
||||||
|
test: (context, view) => {
|
||||||
|
const crewTypeId = resolveCrewTypeId(context.args);
|
||||||
|
if (crewTypeId === null) {
|
||||||
|
return { kind: 'deny', reason: '병종 정보가 없습니다.' };
|
||||||
|
}
|
||||||
|
const unitSet = context.env.unitSet as UnitSetDefinition | undefined;
|
||||||
|
if (!unitSet) {
|
||||||
|
return { kind: 'deny', reason: '병종 정보가 없습니다.' };
|
||||||
|
}
|
||||||
|
const availabilityContext = buildCrewTypeContext(context, view);
|
||||||
|
if (!availabilityContext) {
|
||||||
|
return { kind: 'deny', reason: '병종 정보가 없습니다.' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isCrewTypeAvailable(unitSet, crewTypeId, availabilityContext)
|
||||||
|
) {
|
||||||
|
return { kind: 'allow' };
|
||||||
|
}
|
||||||
|
return { kind: 'deny', reason: '현재 선택할 수 없는 병종입니다.' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const constraints: Constraint[] = [
|
||||||
|
notBeNeutral(),
|
||||||
|
occupiedCity(),
|
||||||
|
reqCityCapacity(
|
||||||
|
'population',
|
||||||
|
'주민',
|
||||||
|
minPopBase + resolveRequestedCrew(ctx)
|
||||||
|
),
|
||||||
|
reqCityTrust(20),
|
||||||
|
reqGeneralGold(getCost, requirements),
|
||||||
|
reqGeneralRice(getRice, requirements),
|
||||||
|
reqGeneralCrewMargin(
|
||||||
|
(context) => resolveCrewTypeId(context.args),
|
||||||
|
requirements
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (ctx.mode === 'full') {
|
||||||
|
constraints.push(checkCrewTypeAvailable());
|
||||||
|
}
|
||||||
|
|
||||||
|
return constraints;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
context: RecruitResolveContext<TriggerState>,
|
||||||
|
args: RecruitArgs
|
||||||
|
): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,14 @@ export type GeneralTurnCommandKey =
|
|||||||
| 'che_화계'
|
| 'che_화계'
|
||||||
| 'che_인재탐색'
|
| 'che_인재탐색'
|
||||||
| 'che_의병모집'
|
| 'che_의병모집'
|
||||||
|
| 'che_징병'
|
||||||
| '휴식';
|
| '휴식';
|
||||||
|
|
||||||
import type * as CommerceInvestmentModule from './che_상업투자.js';
|
import type * as CommerceInvestmentModule from './che_상업투자.js';
|
||||||
import type * as FireAttackModule from './che_화계.js';
|
import type * as FireAttackModule from './che_화계.js';
|
||||||
import type * as TalentScoutModule from './che_인재탐색.js';
|
import type * as TalentScoutModule from './che_인재탐색.js';
|
||||||
import type * as VolunteerRecruitModule from './che_의병모집.js';
|
import type * as VolunteerRecruitModule from './che_의병모집.js';
|
||||||
|
import type * as RecruitModule from './che_징병.js';
|
||||||
import type * as RestModule from './휴식.js';
|
import type * as RestModule from './휴식.js';
|
||||||
|
|
||||||
export type GeneralTurnCommandModule =
|
export type GeneralTurnCommandModule =
|
||||||
@@ -16,6 +18,7 @@ export type GeneralTurnCommandModule =
|
|||||||
| typeof FireAttackModule
|
| typeof FireAttackModule
|
||||||
| typeof TalentScoutModule
|
| typeof TalentScoutModule
|
||||||
| typeof VolunteerRecruitModule
|
| typeof VolunteerRecruitModule
|
||||||
|
| typeof RecruitModule
|
||||||
| typeof RestModule;
|
| typeof RestModule;
|
||||||
|
|
||||||
export type GeneralTurnCommandImporter = () => Promise<GeneralTurnCommandModule>;
|
export type GeneralTurnCommandImporter = () => Promise<GeneralTurnCommandModule>;
|
||||||
@@ -28,6 +31,7 @@ const defaultImporters: Record<
|
|||||||
che_화계: async () => import('./che_화계.js'),
|
che_화계: async () => import('./che_화계.js'),
|
||||||
che_인재탐색: async () => import('./che_인재탐색.js'),
|
che_인재탐색: async () => import('./che_인재탐색.js'),
|
||||||
che_의병모집: async () => import('./che_의병모집.js'),
|
che_의병모집: async () => import('./che_의병모집.js'),
|
||||||
|
che_징병: async () => import('./che_징병.js'),
|
||||||
휴식: async () => import('./휴식.js'),
|
휴식: async () => import('./휴식.js'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,6 +74,11 @@ export {
|
|||||||
ActionResolver as VolunteerRecruitActionResolver,
|
ActionResolver as VolunteerRecruitActionResolver,
|
||||||
CommandResolver as VolunteerRecruitCommandResolver,
|
CommandResolver as VolunteerRecruitCommandResolver,
|
||||||
} from './che_의병모집.js';
|
} from './che_의병모집.js';
|
||||||
|
export {
|
||||||
|
ActionDefinition as RecruitActionDefinition,
|
||||||
|
ActionResolver as RecruitActionResolver,
|
||||||
|
CommandResolver as RecruitCommandResolver,
|
||||||
|
} from './che_징병.js';
|
||||||
export {
|
export {
|
||||||
ActionDefinition as RestActionDefinition,
|
ActionDefinition as RestActionDefinition,
|
||||||
ActionResolver as RestActionResolver,
|
ActionResolver as RestActionResolver,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface GeneralRecruitmentInput<
|
|||||||
experience: number;
|
experience: number;
|
||||||
dedication: number;
|
dedication: number;
|
||||||
crewTypeId: number;
|
crewTypeId: number;
|
||||||
|
atmos?: number;
|
||||||
role: {
|
role: {
|
||||||
personality: string | null;
|
personality: string | null;
|
||||||
specialDomestic: string | null;
|
specialDomestic: string | null;
|
||||||
@@ -75,6 +76,7 @@ export const buildRecruitmentGeneral = <
|
|||||||
crew: 0,
|
crew: 0,
|
||||||
crewTypeId: input.crewTypeId,
|
crewTypeId: input.crewTypeId,
|
||||||
train: 0,
|
train: 0,
|
||||||
|
atmos: input.atmos ?? 0,
|
||||||
age: input.age,
|
age: input.age,
|
||||||
npcState: input.npcState,
|
npcState: input.npcState,
|
||||||
triggerState:
|
triggerState:
|
||||||
|
|||||||
@@ -139,6 +139,22 @@ const readDiplomacyState = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readMetaNumberFromUnknown = (
|
||||||
|
meta: Record<string, unknown>,
|
||||||
|
key: string
|
||||||
|
): number | null => {
|
||||||
|
const value = meta[key];
|
||||||
|
return typeof value === 'number' ? value : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePercent = (value: string): number | null => {
|
||||||
|
const match = /^(\d+(?:\.\d+)?)%$/.exec(value);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Number(match[1]) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
export const notBeNeutral = (): Constraint => ({
|
export const notBeNeutral = (): Constraint => ({
|
||||||
name: 'NotBeNeutral',
|
name: 'NotBeNeutral',
|
||||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||||
@@ -545,6 +561,106 @@ export const remainCityCapacity = (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const reqCityCapacity = (
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
required: number | string
|
||||||
|
): Constraint => ({
|
||||||
|
name: 'ReqCityCapacity',
|
||||||
|
requires: (ctx) =>
|
||||||
|
ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : [],
|
||||||
|
test: (ctx, view) => {
|
||||||
|
const city = readCity(view, ctx.cityId);
|
||||||
|
if (!city) {
|
||||||
|
if (ctx.cityId === undefined) {
|
||||||
|
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||||
|
}
|
||||||
|
const req: RequirementKey = { kind: 'city', id: ctx.cityId };
|
||||||
|
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
|
||||||
|
}
|
||||||
|
const record = city as unknown as Record<string, number | undefined>;
|
||||||
|
const current = record[key];
|
||||||
|
if (current === undefined) {
|
||||||
|
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||||
|
}
|
||||||
|
if (typeof required === 'string') {
|
||||||
|
const ratio = parsePercent(required);
|
||||||
|
const maxKey = `${key}Max`;
|
||||||
|
const max = record[maxKey];
|
||||||
|
if (ratio === null || max === undefined) {
|
||||||
|
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||||
|
}
|
||||||
|
if (current >= max * ratio) {
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
} else if (current >= required) {
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
return { kind: 'deny', reason: `${label}이 부족합니다.` };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reqCityTrust = (minTrust: number): Constraint => ({
|
||||||
|
name: 'ReqCityTrust',
|
||||||
|
requires: (ctx) =>
|
||||||
|
ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : [],
|
||||||
|
test: (ctx, view) => {
|
||||||
|
const city = readCity(view, ctx.cityId);
|
||||||
|
if (!city) {
|
||||||
|
if (ctx.cityId === undefined) {
|
||||||
|
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||||
|
}
|
||||||
|
const req: RequirementKey = { kind: 'city', id: ctx.cityId };
|
||||||
|
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
|
||||||
|
}
|
||||||
|
const trust =
|
||||||
|
readMetaNumberFromUnknown(
|
||||||
|
city.meta as Record<string, unknown>,
|
||||||
|
'trust'
|
||||||
|
) ?? null;
|
||||||
|
if (trust === null) {
|
||||||
|
return unknownOrDeny(ctx, [], '민심 정보가 없습니다.');
|
||||||
|
}
|
||||||
|
if (trust >= minTrust) {
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
return { kind: 'deny', reason: '민심이 낮아 주민들이 도망갑니다.' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reqGeneralCrewMargin = (
|
||||||
|
getCrewTypeId: (ctx: ConstraintContext, view: StateView) => number | null,
|
||||||
|
requirements: RequirementKey[] = []
|
||||||
|
): Constraint => ({
|
||||||
|
name: 'ReqGeneralCrewMargin',
|
||||||
|
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }, ...requirements],
|
||||||
|
test: (ctx, view) => {
|
||||||
|
const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||||
|
const missing = [generalReq, ...requirements].filter(
|
||||||
|
(req) => !view.has(req)
|
||||||
|
);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return unknownOrDeny(ctx, missing, '장수 정보가 없습니다.');
|
||||||
|
}
|
||||||
|
const general = view.get(generalReq) as General | null;
|
||||||
|
if (!general) {
|
||||||
|
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
|
||||||
|
}
|
||||||
|
const crewTypeId = getCrewTypeId(ctx, view);
|
||||||
|
if (crewTypeId === null) {
|
||||||
|
return unknownOrDeny(ctx, requirements, '병종 정보가 없습니다.');
|
||||||
|
}
|
||||||
|
if (crewTypeId !== general.crewTypeId) {
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
const maxCrew = general.stats.leadership * 100;
|
||||||
|
if (maxCrew > general.crew) {
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
return { kind: 'deny', reason: '이미 많은 병력을 보유하고 있습니다.' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const existsDestCity = (): Constraint => ({
|
export const existsDestCity = (): Constraint => ({
|
||||||
name: 'ExistsDestCity',
|
name: 'ExistsDestCity',
|
||||||
requires: (ctx) =>
|
requires: (ctx) =>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface General<TriggerState extends GeneralTriggerState = GeneralTrigg
|
|||||||
crew: number;
|
crew: number;
|
||||||
crewTypeId: number;
|
crewTypeId: number;
|
||||||
train: number;
|
train: number;
|
||||||
|
atmos: number;
|
||||||
age: number;
|
age: number;
|
||||||
npcState: number;
|
npcState: number;
|
||||||
triggerState: TriggerState;
|
triggerState: TriggerState;
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ const buildGeneralSeeds = (
|
|||||||
crew: 0,
|
crew: 0,
|
||||||
crewTypeId: defaultCrewTypeId,
|
crewTypeId: defaultCrewTypeId,
|
||||||
train: 0,
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
age,
|
age,
|
||||||
npcState: npcType,
|
npcState: npcType,
|
||||||
triggerState: createEmptyTriggerState(),
|
triggerState: createEmptyTriggerState(),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
export * from './bootstrap.js';
|
export * from './bootstrap.js';
|
||||||
export * from './loader.js';
|
export * from './loader.js';
|
||||||
|
export * from './unitSet.js';
|
||||||
|
|||||||
@@ -62,9 +62,44 @@ export interface UnitSetDefinition {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
defaultCrewTypeId?: number;
|
defaultCrewTypeId?: number;
|
||||||
|
armTypes?: Record<string, string>;
|
||||||
|
crewTypes?: CrewTypeDefinition[];
|
||||||
meta?: Record<string, unknown>;
|
meta?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CrewTypeRequirement =
|
||||||
|
| { type: 'ReqTech'; tech: number }
|
||||||
|
| { type: 'ReqRegions'; regions: string[] }
|
||||||
|
| { type: 'ReqCities'; cities: string[] }
|
||||||
|
| { type: 'ReqCitiesWithCityLevel'; level: number; cities: string[] }
|
||||||
|
| { type: 'ReqHighLevelCities'; level: number; count: number }
|
||||||
|
| { type: 'ReqNationAux'; key: string; op: string; value: number | string }
|
||||||
|
| { type: 'ReqMinRelYear'; year: number }
|
||||||
|
| { type: 'ReqChief' }
|
||||||
|
| { type: 'ReqNotChief' }
|
||||||
|
| { type: 'Impossible' }
|
||||||
|
| { type: string; [key: string]: unknown };
|
||||||
|
|
||||||
|
export interface CrewTypeDefinition {
|
||||||
|
id: number;
|
||||||
|
armType: number;
|
||||||
|
name: string;
|
||||||
|
attack: number;
|
||||||
|
defence: number;
|
||||||
|
speed: number;
|
||||||
|
avoid: number;
|
||||||
|
magicCoef: number;
|
||||||
|
cost: number;
|
||||||
|
rice: number;
|
||||||
|
requirements: CrewTypeRequirement[];
|
||||||
|
attackCoef: Record<string, number>;
|
||||||
|
defenceCoef: Record<string, number>;
|
||||||
|
info: string[];
|
||||||
|
initSkillTrigger: string[] | null;
|
||||||
|
phaseSkillTrigger: string[] | null;
|
||||||
|
iActionList: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NationSeed {
|
export interface NationSeed {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import type { City, General, Nation } from '../domain/entities.js';
|
||||||
|
import type {
|
||||||
|
CrewTypeDefinition,
|
||||||
|
CrewTypeRequirement,
|
||||||
|
MapDefinition,
|
||||||
|
UnitSetDefinition,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const DEFAULT_REGION_MAP: Record<string, number> = {
|
||||||
|
하북: 1,
|
||||||
|
중원: 2,
|
||||||
|
서북: 3,
|
||||||
|
서촉: 4,
|
||||||
|
남중: 5,
|
||||||
|
초: 6,
|
||||||
|
오월: 7,
|
||||||
|
동이: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_MAX_TECH_LEVEL = 12;
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||||
|
isRecord(value) ? value : {};
|
||||||
|
|
||||||
|
const asNumber = (value: unknown, fallback: number): number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
|
||||||
|
const asString = (value: unknown, fallback: string): string =>
|
||||||
|
typeof value === 'string' ? value : fallback;
|
||||||
|
|
||||||
|
const asStringArray = (value: unknown): string[] =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value.filter((item): item is string => typeof item === 'string')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const asNullableStringArray = (value: unknown): string[] | null => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return asStringArray(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCoef = (value: unknown): Record<string, number> => {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const result: Record<string, number> = {};
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
if (typeof entry === 'number' && Number.isFinite(entry)) {
|
||||||
|
result[key] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseRequirement = (value: unknown): CrewTypeRequirement | null => {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const type = asString(value.type, '');
|
||||||
|
if (!type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case 'ReqTech':
|
||||||
|
return { type, tech: asNumber(value.tech, 0) };
|
||||||
|
case 'ReqRegions':
|
||||||
|
return { type, regions: asStringArray(value.regions) };
|
||||||
|
case 'ReqCities':
|
||||||
|
return { type, cities: asStringArray(value.cities) };
|
||||||
|
case 'ReqCitiesWithCityLevel':
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
level: asNumber(value.level, 0),
|
||||||
|
cities: asStringArray(value.cities),
|
||||||
|
};
|
||||||
|
case 'ReqHighLevelCities':
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
level: asNumber(value.level, 0),
|
||||||
|
count: asNumber(value.count, 0),
|
||||||
|
};
|
||||||
|
case 'ReqNationAux':
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
key: asString(value.key, ''),
|
||||||
|
op: asString(value.op, '=='),
|
||||||
|
value:
|
||||||
|
typeof value.value === 'number' ||
|
||||||
|
typeof value.value === 'string'
|
||||||
|
? value.value
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
case 'ReqMinRelYear':
|
||||||
|
return { type, year: asNumber(value.year, 0) };
|
||||||
|
case 'ReqChief':
|
||||||
|
case 'ReqNotChief':
|
||||||
|
case 'Impossible':
|
||||||
|
return { type };
|
||||||
|
default:
|
||||||
|
return { type, ...value };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseCrewType = (value: unknown): CrewTypeDefinition | null => {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = asNumber(value.id, 0);
|
||||||
|
if (id <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const requirements = Array.isArray(value.requirements)
|
||||||
|
? value.requirements
|
||||||
|
.map(parseRequirement)
|
||||||
|
.filter((entry): entry is CrewTypeRequirement => entry !== null)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
armType: asNumber(value.armType, 0),
|
||||||
|
name: asString(value.name, ''),
|
||||||
|
attack: asNumber(value.attack, 0),
|
||||||
|
defence: asNumber(value.defence, 0),
|
||||||
|
speed: asNumber(value.speed, 0),
|
||||||
|
avoid: asNumber(value.avoid, 0),
|
||||||
|
magicCoef: asNumber(value.magicCoef, 0),
|
||||||
|
cost: asNumber(value.cost, 0),
|
||||||
|
rice: asNumber(value.rice, 0),
|
||||||
|
requirements,
|
||||||
|
attackCoef: normalizeCoef(value.attackCoef),
|
||||||
|
defenceCoef: normalizeCoef(value.defenceCoef),
|
||||||
|
info: asStringArray(value.info),
|
||||||
|
initSkillTrigger: asNullableStringArray(value.initSkillTrigger),
|
||||||
|
phaseSkillTrigger: asNullableStringArray(value.phaseSkillTrigger),
|
||||||
|
iActionList: asNullableStringArray(value.iActionList),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseUnitSetDefinition = (value: unknown): UnitSetDefinition => {
|
||||||
|
const data = asRecord(value);
|
||||||
|
const id = asString(data.id, 'unknown');
|
||||||
|
const name = asString(data.name, id);
|
||||||
|
const defaultCrewTypeId =
|
||||||
|
typeof data.defaultCrewTypeId === 'number' && Number.isFinite(data.defaultCrewTypeId)
|
||||||
|
? data.defaultCrewTypeId
|
||||||
|
: null;
|
||||||
|
const armTypes = isRecord(data.armTypes)
|
||||||
|
? Object.entries(data.armTypes).reduce<Record<string, string>>(
|
||||||
|
(acc, [key, entry]) => {
|
||||||
|
if (typeof entry === 'string') {
|
||||||
|
acc[key] = entry;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const crewTypes = Array.isArray(data.crewTypes)
|
||||||
|
? data.crewTypes
|
||||||
|
.map(parseCrewType)
|
||||||
|
.filter((entry): entry is CrewTypeDefinition => entry !== null)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const result: UnitSetDefinition = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
crewTypes,
|
||||||
|
};
|
||||||
|
if (defaultCrewTypeId !== null) {
|
||||||
|
result.defaultCrewTypeId = defaultCrewTypeId;
|
||||||
|
}
|
||||||
|
if (armTypes) {
|
||||||
|
result.armTypes = armTypes;
|
||||||
|
}
|
||||||
|
if (isRecord(data.meta)) {
|
||||||
|
result.meta = data.meta;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildCrewTypeIndex = (
|
||||||
|
unitSet: UnitSetDefinition | null | undefined
|
||||||
|
): Map<number, CrewTypeDefinition> => {
|
||||||
|
const index = new Map<number, CrewTypeDefinition>();
|
||||||
|
for (const crewType of unitSet?.crewTypes ?? []) {
|
||||||
|
index.set(crewType.id, crewType);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findCrewTypeById = (
|
||||||
|
unitSet: UnitSetDefinition | null | undefined,
|
||||||
|
crewTypeId: number
|
||||||
|
): CrewTypeDefinition | null => {
|
||||||
|
if (!unitSet?.crewTypes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return unitSet.crewTypes.find((crewType) => crewType.id === crewTypeId) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTechLevel = (
|
||||||
|
tech: number,
|
||||||
|
maxLevel = DEFAULT_MAX_TECH_LEVEL
|
||||||
|
): number => {
|
||||||
|
if (!Number.isFinite(tech)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const level = Math.floor(tech / 1000);
|
||||||
|
return Math.max(0, Math.min(level, maxLevel));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTechAbility = (tech: number): number => getTechLevel(tech) * 25;
|
||||||
|
|
||||||
|
export const getTechCost = (tech: number): number => 1 + getTechLevel(tech) * 0.15;
|
||||||
|
|
||||||
|
export interface CrewTypeAvailabilityContext {
|
||||||
|
general: General;
|
||||||
|
nation: Nation | null;
|
||||||
|
map: MapDefinition;
|
||||||
|
cities: City[];
|
||||||
|
currentYear?: number;
|
||||||
|
startYear?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveNationTech = (nation: Nation | null): number => {
|
||||||
|
if (!nation) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const tech = nation.meta.tech;
|
||||||
|
return typeof tech === 'number' ? tech : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveNationAux = (nation: Nation | null): Record<string, unknown> => {
|
||||||
|
if (!nation) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const meta = asRecord(nation.meta);
|
||||||
|
const aux = asRecord(meta.aux);
|
||||||
|
return Object.keys(aux).length > 0 ? aux : meta;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveRelativeYear = (context: CrewTypeAvailabilityContext): number => {
|
||||||
|
const { currentYear, startYear } = context;
|
||||||
|
if (typeof currentYear === 'number' && typeof startYear === 'number') {
|
||||||
|
return Math.max(currentYear - startYear, 0);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveRegionMap = (map: MapDefinition): Record<string, number> => {
|
||||||
|
const meta = asRecord(map.meta);
|
||||||
|
const raw = asRecord(meta.regionMap);
|
||||||
|
const extracted: Record<string, number> = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
extracted[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(extracted).length > 0 ? extracted : DEFAULT_REGION_MAP;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCityIndex = (map: MapDefinition): {
|
||||||
|
nameToId: Map<string, number>;
|
||||||
|
regionById: Map<number, number>;
|
||||||
|
} => {
|
||||||
|
const nameToId = new Map<string, number>();
|
||||||
|
const regionById = new Map<number, number>();
|
||||||
|
for (const city of map.cities) {
|
||||||
|
nameToId.set(city.name, city.id);
|
||||||
|
regionById.set(city.id, city.region);
|
||||||
|
}
|
||||||
|
return { nameToId, regionById };
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareAuxValue = (
|
||||||
|
actual: unknown,
|
||||||
|
op: string,
|
||||||
|
expected: number | string
|
||||||
|
): boolean => {
|
||||||
|
const actualNum =
|
||||||
|
typeof actual === 'number'
|
||||||
|
? actual
|
||||||
|
: typeof actual === 'string'
|
||||||
|
? Number(actual)
|
||||||
|
: Number.NaN;
|
||||||
|
const expectedNum =
|
||||||
|
typeof expected === 'number'
|
||||||
|
? expected
|
||||||
|
: typeof expected === 'string'
|
||||||
|
? Number(expected)
|
||||||
|
: Number.NaN;
|
||||||
|
|
||||||
|
if (!Number.isNaN(actualNum) && !Number.isNaN(expectedNum)) {
|
||||||
|
switch (op) {
|
||||||
|
case '==':
|
||||||
|
return actualNum === expectedNum;
|
||||||
|
case '!=':
|
||||||
|
return actualNum !== expectedNum;
|
||||||
|
case '>=':
|
||||||
|
return actualNum >= expectedNum;
|
||||||
|
case '<=':
|
||||||
|
return actualNum <= expectedNum;
|
||||||
|
case '>':
|
||||||
|
return actualNum > expectedNum;
|
||||||
|
case '<':
|
||||||
|
return actualNum < expectedNum;
|
||||||
|
default:
|
||||||
|
return actualNum === expectedNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op === '!=' || op === '==') {
|
||||||
|
return op === '!='
|
||||||
|
? String(actual ?? '') !== String(expected)
|
||||||
|
: String(actual ?? '') === String(expected);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 병종 요구 조건을 평가해 현재 상황에서 선택 가능한지 계산한다.
|
||||||
|
export const isCrewTypeAvailable = (
|
||||||
|
unitSet: UnitSetDefinition,
|
||||||
|
crewTypeId: number,
|
||||||
|
context: CrewTypeAvailabilityContext
|
||||||
|
): boolean => {
|
||||||
|
const crewType = findCrewTypeById(unitSet, crewTypeId);
|
||||||
|
if (!crewType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nationId = context.nation?.id ?? context.general.nationId;
|
||||||
|
const ownedCities =
|
||||||
|
nationId !== undefined
|
||||||
|
? context.cities.filter((city) => city.nationId === nationId)
|
||||||
|
: [];
|
||||||
|
const ownedCityMap = new Map<number, City>();
|
||||||
|
for (const city of ownedCities) {
|
||||||
|
ownedCityMap.set(city.id, city);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nameToId, regionById } = buildCityIndex(context.map);
|
||||||
|
const regionMap = resolveRegionMap(context.map);
|
||||||
|
const ownedRegions = new Set<number>();
|
||||||
|
for (const city of ownedCities) {
|
||||||
|
const region = regionById.get(city.id);
|
||||||
|
if (region !== undefined) {
|
||||||
|
ownedRegions.add(region);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tech = resolveNationTech(context.nation ?? null);
|
||||||
|
const relYear = resolveRelativeYear(context);
|
||||||
|
const aux = resolveNationAux(context.nation ?? null);
|
||||||
|
|
||||||
|
for (const requirement of crewType.requirements) {
|
||||||
|
switch (requirement.type) {
|
||||||
|
case 'ReqTech':
|
||||||
|
if (
|
||||||
|
tech <
|
||||||
|
(requirement as Extract<CrewTypeRequirement, { type: 'ReqTech' }>).tech
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ReqRegions': {
|
||||||
|
const req =
|
||||||
|
requirement as Extract<
|
||||||
|
CrewTypeRequirement,
|
||||||
|
{ type: 'ReqRegions' }
|
||||||
|
>;
|
||||||
|
const resolved = req.regions
|
||||||
|
.map((name: string) => regionMap[name])
|
||||||
|
.filter((id): id is number => typeof id === 'number');
|
||||||
|
if (!resolved.some((id) => ownedRegions.has(id))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ReqCities': {
|
||||||
|
const req =
|
||||||
|
requirement as Extract<
|
||||||
|
CrewTypeRequirement,
|
||||||
|
{ type: 'ReqCities' }
|
||||||
|
>;
|
||||||
|
const resolved = req.cities
|
||||||
|
.map((name: string) => nameToId.get(name))
|
||||||
|
.filter((id): id is number => typeof id === 'number');
|
||||||
|
if (!resolved.some((id) => ownedCityMap.has(id))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ReqCitiesWithCityLevel': {
|
||||||
|
const req =
|
||||||
|
requirement as Extract<
|
||||||
|
CrewTypeRequirement,
|
||||||
|
{ type: 'ReqCitiesWithCityLevel' }
|
||||||
|
>;
|
||||||
|
const resolved = req.cities
|
||||||
|
.map((name: string) => nameToId.get(name))
|
||||||
|
.filter((id): id is number => typeof id === 'number');
|
||||||
|
if (
|
||||||
|
!resolved.some((id) => {
|
||||||
|
const city = ownedCityMap.get(id);
|
||||||
|
return city ? city.level >= req.level : false;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ReqHighLevelCities': {
|
||||||
|
const req =
|
||||||
|
requirement as Extract<
|
||||||
|
CrewTypeRequirement,
|
||||||
|
{ type: 'ReqHighLevelCities' }
|
||||||
|
>;
|
||||||
|
const count = ownedCities.filter(
|
||||||
|
(city) => city.level >= req.level
|
||||||
|
).length;
|
||||||
|
if (count < req.count) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ReqNationAux': {
|
||||||
|
const req =
|
||||||
|
requirement as Extract<
|
||||||
|
CrewTypeRequirement,
|
||||||
|
{ type: 'ReqNationAux' }
|
||||||
|
>;
|
||||||
|
const value = aux[req.key];
|
||||||
|
if (!compareAuxValue(value, req.op, req.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ReqMinRelYear':
|
||||||
|
if (
|
||||||
|
relYear <
|
||||||
|
(requirement as Extract<CrewTypeRequirement, { type: 'ReqMinRelYear' }>).year
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ReqChief':
|
||||||
|
if (context.general.officerLevel <= 4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ReqNotChief':
|
||||||
|
if (context.general.officerLevel > 4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Impossible':
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { City, General, Nation } from '../src/domain/entities.js';
|
||||||
|
import type { MapDefinition } from '../src/world/types.js';
|
||||||
|
import {
|
||||||
|
isCrewTypeAvailable,
|
||||||
|
parseUnitSetDefinition,
|
||||||
|
} from '../src/world/unitSet.js';
|
||||||
|
|
||||||
|
const buildMap = (): MapDefinition => ({
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'A',
|
||||||
|
level: 6,
|
||||||
|
region: 1,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
connections: [],
|
||||||
|
max: {
|
||||||
|
population: 100000,
|
||||||
|
agriculture: 1000,
|
||||||
|
commerce: 1000,
|
||||||
|
security: 1000,
|
||||||
|
defence: 1000,
|
||||||
|
wall: 1000,
|
||||||
|
},
|
||||||
|
initial: {
|
||||||
|
population: 50000,
|
||||||
|
agriculture: 500,
|
||||||
|
commerce: 500,
|
||||||
|
security: 500,
|
||||||
|
defence: 500,
|
||||||
|
wall: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
regionMap: {
|
||||||
|
A: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildNation = (): Nation => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'TestNation',
|
||||||
|
color: '#000000',
|
||||||
|
capitalCityId: 1,
|
||||||
|
chiefGeneralId: null,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'test',
|
||||||
|
meta: {
|
||||||
|
tech: 3000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildGeneral = (): General => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'TestGeneral',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
officerLevel: 3,
|
||||||
|
role: {
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: {
|
||||||
|
horse: null,
|
||||||
|
weapon: null,
|
||||||
|
book: null,
|
||||||
|
item: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
injury: 0,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 1100,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 20,
|
||||||
|
npcState: 0,
|
||||||
|
triggerState: {
|
||||||
|
flags: {},
|
||||||
|
counters: {},
|
||||||
|
modifiers: {},
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildCities = (): City[] => [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'A',
|
||||||
|
nationId: 1,
|
||||||
|
level: 6,
|
||||||
|
population: 50000,
|
||||||
|
populationMax: 100000,
|
||||||
|
agriculture: 500,
|
||||||
|
agricultureMax: 1000,
|
||||||
|
commerce: 500,
|
||||||
|
commerceMax: 1000,
|
||||||
|
security: 500,
|
||||||
|
securityMax: 1000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
defence: 500,
|
||||||
|
defenceMax: 1000,
|
||||||
|
wall: 500,
|
||||||
|
wallMax: 1000,
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('crew type availability', () => {
|
||||||
|
it('parses unit set data and normalizes coefficients', () => {
|
||||||
|
const parsed = parseUnitSetDefinition({
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
defaultCrewTypeId: 1100,
|
||||||
|
crewTypes: [
|
||||||
|
{
|
||||||
|
id: 1100,
|
||||||
|
armType: 1,
|
||||||
|
name: '보병',
|
||||||
|
attack: 100,
|
||||||
|
defence: 100,
|
||||||
|
speed: 7,
|
||||||
|
avoid: 10,
|
||||||
|
magicCoef: 0,
|
||||||
|
cost: 9,
|
||||||
|
rice: 9,
|
||||||
|
requirements: [],
|
||||||
|
attackCoef: [],
|
||||||
|
defenceCoef: { 1: 1.2 },
|
||||||
|
info: ['테스트'],
|
||||||
|
initSkillTrigger: null,
|
||||||
|
phaseSkillTrigger: null,
|
||||||
|
iActionList: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.defaultCrewTypeId).toBe(1100);
|
||||||
|
expect(parsed.crewTypes?.[0]?.attackCoef).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks tech/region/city requirements', () => {
|
||||||
|
const unitSet = parseUnitSetDefinition({
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
crewTypes: [
|
||||||
|
{
|
||||||
|
id: 1100,
|
||||||
|
armType: 1,
|
||||||
|
name: '기본',
|
||||||
|
attack: 100,
|
||||||
|
defence: 100,
|
||||||
|
speed: 7,
|
||||||
|
avoid: 10,
|
||||||
|
magicCoef: 0,
|
||||||
|
cost: 9,
|
||||||
|
rice: 9,
|
||||||
|
requirements: [],
|
||||||
|
attackCoef: {},
|
||||||
|
defenceCoef: {},
|
||||||
|
info: [],
|
||||||
|
initSkillTrigger: null,
|
||||||
|
phaseSkillTrigger: null,
|
||||||
|
iActionList: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1200,
|
||||||
|
armType: 1,
|
||||||
|
name: '기술병',
|
||||||
|
attack: 100,
|
||||||
|
defence: 100,
|
||||||
|
speed: 7,
|
||||||
|
avoid: 10,
|
||||||
|
magicCoef: 0,
|
||||||
|
cost: 9,
|
||||||
|
rice: 9,
|
||||||
|
requirements: [
|
||||||
|
{ type: 'ReqTech', tech: 2000 },
|
||||||
|
{ type: 'ReqRegions', regions: ['A'] },
|
||||||
|
],
|
||||||
|
attackCoef: {},
|
||||||
|
defenceCoef: {},
|
||||||
|
info: [],
|
||||||
|
initSkillTrigger: null,
|
||||||
|
phaseSkillTrigger: null,
|
||||||
|
iActionList: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1300,
|
||||||
|
armType: 1,
|
||||||
|
name: '도시병',
|
||||||
|
attack: 100,
|
||||||
|
defence: 100,
|
||||||
|
speed: 7,
|
||||||
|
avoid: 10,
|
||||||
|
magicCoef: 0,
|
||||||
|
cost: 9,
|
||||||
|
rice: 9,
|
||||||
|
requirements: [
|
||||||
|
{ type: 'ReqCitiesWithCityLevel', level: 7, cities: ['A'] },
|
||||||
|
],
|
||||||
|
attackCoef: {},
|
||||||
|
defenceCoef: {},
|
||||||
|
info: [],
|
||||||
|
initSkillTrigger: null,
|
||||||
|
phaseSkillTrigger: null,
|
||||||
|
iActionList: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
general: buildGeneral(),
|
||||||
|
nation: buildNation(),
|
||||||
|
map: buildMap(),
|
||||||
|
cities: buildCities(),
|
||||||
|
currentYear: 10,
|
||||||
|
startYear: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isCrewTypeAvailable(unitSet, 1100, context)).toBe(true);
|
||||||
|
expect(isCrewTypeAvailable(unitSet, 1200, context)).toBe(true);
|
||||||
|
expect(isCrewTypeAvailable(unitSet, 1300, context)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user