feat: restore Ref recruitment command details
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { ITEM_KEYS, loadItemModules } from '@sammo-ts/logic';
|
||||
import { loadActionModuleBundle } from '@sammo-ts/logic';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
|
||||
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
|
||||
import { buildTurnCommandTable, evaluateReservedTurnPermission } from '../../turns/commandTable.js';
|
||||
import {
|
||||
buildRecruitmentCommandInfo,
|
||||
buildTurnCommandTable,
|
||||
evaluateReservedTurnPermission,
|
||||
} from '../../turns/commandTable.js';
|
||||
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
||||
import {
|
||||
parseReservedTurnArgs,
|
||||
TURN_COMMAND_NATION_COLORS,
|
||||
@@ -89,6 +95,13 @@ const getReservationWorldState = async (ctx: GameApiContext): Promise<WorldState
|
||||
return worldState;
|
||||
};
|
||||
|
||||
const resolveMapName = (worldState: WorldStateRow, fallback: string): string => {
|
||||
const config = asRecord(worldState.config);
|
||||
const environment = asRecord(config.environment ?? config.map);
|
||||
const mapName = environment.mapName;
|
||||
return typeof mapName === 'string' && mapName.trim().length > 0 ? mapName : fallback;
|
||||
};
|
||||
|
||||
const assertReservedTurnPermission = async (
|
||||
worldState: WorldStateRow,
|
||||
general: GeneralRow,
|
||||
@@ -123,7 +136,11 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
});
|
||||
}
|
||||
|
||||
const [city, nation, nationGenerals, cities, nations, generals, environment, traits, itemModules] =
|
||||
const environmentPromise = buildBattleSimEnvironment(worldState, ctx.profile.id);
|
||||
const moduleBundlePromise = environmentPromise.then((environment) =>
|
||||
loadActionModuleBundle(environment.unitSet, environment.scenarioEffect)
|
||||
);
|
||||
const [city, nation, nationGenerals, cities, nations, generals, environment, traits, moduleBundle, map] =
|
||||
await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
@@ -140,10 +157,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
where: { nationId: general.nationId },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
ctx.db.city.findMany({
|
||||
select: { id: true, name: true, nationId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.city.findMany({ orderBy: { id: 'asc' } }),
|
||||
ctx.db.nation.findMany({
|
||||
select: { id: true, name: true, color: true },
|
||||
orderBy: { id: 'asc' },
|
||||
@@ -153,9 +167,10 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
select: { id: true, name: true, nationId: true, cityId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
buildBattleSimEnvironment(worldState, ctx.profile.id),
|
||||
environmentPromise,
|
||||
loadBattleSimTraitOptions(),
|
||||
loadItemModules([...ITEM_KEYS]),
|
||||
moduleBundlePromise,
|
||||
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
|
||||
]);
|
||||
|
||||
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
||||
@@ -166,7 +181,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
book: [{ value: 'None', label: '판매/해제' }],
|
||||
item: [{ value: 'None', label: '판매/해제' }],
|
||||
};
|
||||
for (const item of itemModules) {
|
||||
for (const item of moduleBundle.itemModules) {
|
||||
if (item.buyable) {
|
||||
items[item.slot].push({ value: item.key, label: item.name });
|
||||
}
|
||||
@@ -201,6 +216,16 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
color,
|
||||
})),
|
||||
items,
|
||||
recruitment: buildRecruitmentCommandInfo({
|
||||
worldState,
|
||||
general,
|
||||
city,
|
||||
nation,
|
||||
cities,
|
||||
map,
|
||||
unitSet: environment.unitSet,
|
||||
generalActionModules: moduleBundle.general,
|
||||
}),
|
||||
};
|
||||
|
||||
return buildTurnCommandTable({
|
||||
|
||||
@@ -17,6 +17,38 @@ export interface TurnCommandOption {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface TurnCommandRecruitmentCrewType {
|
||||
id: number;
|
||||
armType: number;
|
||||
name: string;
|
||||
available: boolean;
|
||||
special: boolean;
|
||||
attack: number;
|
||||
defence: number;
|
||||
speed: number;
|
||||
avoid: number;
|
||||
baseCost: number;
|
||||
baseRice: number;
|
||||
info: string[];
|
||||
}
|
||||
|
||||
export interface TurnCommandRecruitmentGroup {
|
||||
armType: number;
|
||||
armName: string;
|
||||
values: TurnCommandRecruitmentCrewType[];
|
||||
}
|
||||
|
||||
export interface TurnCommandRecruitmentInfo {
|
||||
techLevel: number;
|
||||
leadership: number;
|
||||
fullLeadership: number;
|
||||
currentCrewTypeId: number;
|
||||
currentCrewTypeName: string;
|
||||
crew: number;
|
||||
gold: number;
|
||||
groups: TurnCommandRecruitmentGroup[];
|
||||
}
|
||||
|
||||
export type TurnCommandOptionSource =
|
||||
| 'cities'
|
||||
| 'nations'
|
||||
@@ -50,6 +82,7 @@ export interface TurnCommandInputOptions {
|
||||
nationTypes: TurnCommandOption[];
|
||||
colors: TurnCommandOption[];
|
||||
items: Record<string, TurnCommandOption[]>;
|
||||
recruitment: TurnCommandRecruitmentInfo | null;
|
||||
}
|
||||
|
||||
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
|
||||
|
||||
@@ -7,15 +7,20 @@ import type {
|
||||
GeneralItemSlots,
|
||||
GeneralActionDefinition,
|
||||
GeneralTurnCommandSpec,
|
||||
MapDefinition,
|
||||
Nation,
|
||||
NationTurnCommandSpec,
|
||||
RequirementKey,
|
||||
StateView,
|
||||
TurnCommandEnv,
|
||||
TriggerValue,
|
||||
UnitSetDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
import { evaluateConstraints } from '@sammo-ts/logic';
|
||||
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
|
||||
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
||||
import { getTechAbility, getTechLevel, isCrewTypeAvailable } from '@sammo-ts/logic/world/unitSet.js';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
|
||||
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
|
||||
@@ -24,6 +29,7 @@ import {
|
||||
loadTurnCommandSpecs,
|
||||
type TurnCommandInputField,
|
||||
type TurnCommandInputOptions,
|
||||
type TurnCommandRecruitmentInfo,
|
||||
} from './commandInput.js';
|
||||
|
||||
type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown';
|
||||
@@ -343,6 +349,81 @@ const mapNationRow = (row: NationRow): Nation => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const buildRecruitmentCommandInfo = (options: {
|
||||
worldState: WorldStateRow;
|
||||
general: GeneralRow;
|
||||
city: CityRow | null;
|
||||
nation: NationRow | null;
|
||||
cities: CityRow[];
|
||||
map: MapDefinition;
|
||||
unitSet: UnitSetDefinition;
|
||||
generalActionModules?: ReadonlyArray<GeneralActionModule | null | undefined>;
|
||||
}): TurnCommandRecruitmentInfo => {
|
||||
const general = mapGeneralRow(options.general);
|
||||
const city = options.city ? mapCityRow(options.city) : undefined;
|
||||
const nation = options.nation ? mapNationRow(options.nation) : null;
|
||||
const cities = options.cities.map(mapCityRow);
|
||||
const context = city ? { general, city, nation } : { general, nation };
|
||||
const command = new RecruitmentCommandResolver(options.generalActionModules ?? [], {});
|
||||
const tech = options.nation?.tech ?? 0;
|
||||
const techAbility = getTechAbility(tech);
|
||||
const constraintEnv = buildConstraintEnv(options.worldState);
|
||||
const startYear = typeof constraintEnv.startYear === 'number' ? constraintEnv.startYear : undefined;
|
||||
const availabilityContext = {
|
||||
general,
|
||||
nation,
|
||||
map: options.map,
|
||||
cities,
|
||||
currentYear: options.worldState.currentYear,
|
||||
...(startYear === undefined ? {} : { startYear }),
|
||||
};
|
||||
const crewTypes = options.unitSet.crewTypes ?? [];
|
||||
const armTypes = Object.entries(options.unitSet.armTypes ?? {})
|
||||
.map(([armType, armName]) => ({ armType: Number(armType), armName }))
|
||||
.filter((entry) => Number.isFinite(entry.armType))
|
||||
.sort((left, right) => left.armType - right.armType);
|
||||
|
||||
const groups = armTypes.map(({ armType, armName }) => ({
|
||||
armType,
|
||||
armName,
|
||||
values: crewTypes
|
||||
.filter((crewType) => crewType.armType === armType)
|
||||
.map((crewType) => {
|
||||
const displayCost = command.getDisplayUnitCost(context, crewType);
|
||||
const requiredTech = crewType.requirements.find((requirement) => requirement.type === 'ReqTech');
|
||||
return {
|
||||
id: crewType.id,
|
||||
armType,
|
||||
name: crewType.name,
|
||||
available: isCrewTypeAvailable(options.unitSet, crewType.id, availabilityContext),
|
||||
special:
|
||||
requiredTech?.type === 'ReqTech' &&
|
||||
typeof requiredTech.tech === 'number' &&
|
||||
requiredTech.tech > 0,
|
||||
attack: crewType.attack + techAbility,
|
||||
defence: crewType.defence + techAbility,
|
||||
speed: crewType.speed,
|
||||
avoid: crewType.avoid,
|
||||
baseCost: displayCost.gold,
|
||||
baseRice: displayCost.rice,
|
||||
info: [...crewType.info],
|
||||
};
|
||||
}),
|
||||
}));
|
||||
const currentCrewTypeName = crewTypes.find((crewType) => crewType.id === general.crewTypeId)?.name ?? '-';
|
||||
|
||||
return {
|
||||
techLevel: getTechLevel(tech),
|
||||
leadership: command.resolveLeadership(context),
|
||||
fullLeadership: command.resolveFullLeadership(context),
|
||||
currentCrewTypeId: general.crewTypeId,
|
||||
currentCrewTypeName,
|
||||
crew: general.crew,
|
||||
gold: general.gold,
|
||||
groups,
|
||||
};
|
||||
};
|
||||
|
||||
const buildStateView = (
|
||||
general: General,
|
||||
city: City | null,
|
||||
@@ -528,6 +609,7 @@ export const buildTurnCommandTable = async (options: {
|
||||
nationTypes: [],
|
||||
colors: [],
|
||||
items: {},
|
||||
recruitment: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../src/context.js';
|
||||
import { buildTurnCommandTable } from '../src/turns/commandTable.js';
|
||||
import type { GeneralActionModule, MapDefinition, UnitSetDefinition } from '@sammo-ts/logic';
|
||||
import { buildRecruitmentCommandInfo, buildTurnCommandTable } from '../src/turns/commandTable.js';
|
||||
|
||||
const buildWorldState = (joinMode = 'full'): WorldStateRow =>
|
||||
({
|
||||
@@ -136,4 +137,108 @@ describe('buildTurnCommandTable', () => {
|
||||
reason: '랜덤 임관만 가능합니다',
|
||||
});
|
||||
});
|
||||
|
||||
it('projects Ref recruitment availability, combat values, descriptions, and adjusted costs', () => {
|
||||
const general = buildGeneral();
|
||||
general.injury = 3;
|
||||
general.gold = 12_345;
|
||||
const nation = buildNation();
|
||||
nation.tech = 1000;
|
||||
const unitSet = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
defaultCrewTypeId: 1100,
|
||||
armTypes: { 1: '보병' },
|
||||
crewTypes: [
|
||||
{
|
||||
id: 1100,
|
||||
armType: 1,
|
||||
name: '보병',
|
||||
attack: 100,
|
||||
defence: 150,
|
||||
speed: 7,
|
||||
avoid: 10,
|
||||
magicCoef: 0,
|
||||
cost: 9,
|
||||
rice: 9,
|
||||
requirements: [],
|
||||
attackCoef: {},
|
||||
defenceCoef: {},
|
||||
info: ['표준적인 보병입니다.'],
|
||||
initSkillTrigger: null,
|
||||
phaseSkillTrigger: null,
|
||||
iActionList: null,
|
||||
},
|
||||
{
|
||||
id: 1101,
|
||||
armType: 1,
|
||||
name: '정예병',
|
||||
attack: 150,
|
||||
defence: 200,
|
||||
speed: 8,
|
||||
avoid: 20,
|
||||
magicCoef: 0,
|
||||
cost: 12,
|
||||
rice: 10,
|
||||
requirements: [{ type: 'ReqTech', tech: 2000 }],
|
||||
attackCoef: {},
|
||||
defenceCoef: {},
|
||||
info: ['강력하지만 기술이 필요합니다.'],
|
||||
initSkillTrigger: null,
|
||||
phaseSkillTrigger: null,
|
||||
iActionList: null,
|
||||
},
|
||||
],
|
||||
} satisfies UnitSetDefinition;
|
||||
const map = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [{ id: 1, name: 'TestCity', region: 1 }],
|
||||
} as unknown as MapDefinition;
|
||||
const costDiscount: GeneralActionModule = {
|
||||
onCalcDomestic: (_context, _turnType, varType, value) => (varType === 'cost' ? value * 0.9 : value),
|
||||
};
|
||||
|
||||
const info = buildRecruitmentCommandInfo({
|
||||
worldState: buildWorldState(),
|
||||
general,
|
||||
city: buildCity(),
|
||||
nation,
|
||||
cities: [buildCity()],
|
||||
map,
|
||||
unitSet,
|
||||
generalActionModules: [costDiscount],
|
||||
});
|
||||
|
||||
expect(info).toMatchObject({
|
||||
techLevel: 1,
|
||||
fullLeadership: 70,
|
||||
currentCrewTypeId: 1100,
|
||||
currentCrewTypeName: '보병',
|
||||
crew: 100,
|
||||
gold: 12_345,
|
||||
});
|
||||
expect(info.leadership).toBeLessThan(info.fullLeadership);
|
||||
expect(info.groups).toHaveLength(1);
|
||||
expect(info.groups[0]?.values[0]).toMatchObject({
|
||||
name: '보병',
|
||||
available: true,
|
||||
special: false,
|
||||
attack: 125,
|
||||
defence: 175,
|
||||
speed: 7,
|
||||
avoid: 10,
|
||||
info: ['표준적인 보병입니다.'],
|
||||
});
|
||||
expect(info.groups[0]?.values[0]?.baseCost).toBeCloseTo(9 * 1.15 * 0.9, 10);
|
||||
expect(info.groups[0]?.values[0]?.baseRice).toBeCloseTo(9 * 1.15, 10);
|
||||
expect(info.groups[0]?.values[1]).toMatchObject({
|
||||
name: '정예병',
|
||||
available: false,
|
||||
special: true,
|
||||
attack: 175,
|
||||
defence: 225,
|
||||
info: ['강력하지만 기술이 필요합니다.'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user