feat: restore Ref recruitment command details

This commit is contained in:
2026-08-13 16:20:43 +00:00
parent 94b5b7813c
commit 317a055434
12 changed files with 1375 additions and 58 deletions
+35 -10
View File
@@ -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({
+33
View File
@@ -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;
}
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
+82
View File
@@ -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,
},
};
};
+106 -1
View File
@@ -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: ['강력하지만 기술이 필요합니다.'],
});
});
});