feat: add command table functionality and integrate logic for turn commands
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
"@sammo-ts/common": "workspace:*",
|
"@sammo-ts/common": "workspace:*",
|
||||||
|
"@sammo-ts/logic": "workspace:*",
|
||||||
"@sammo-ts/infra": "workspace:*",
|
"@sammo-ts/infra": "workspace:*",
|
||||||
"@trpc/server": "^11.4.4",
|
"@trpc/server": "^11.4.4",
|
||||||
"fastify": "^5.3.3",
|
"fastify": "^5.3.3",
|
||||||
|
|||||||
@@ -18,10 +18,83 @@ export interface WorldStateRow {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GeneralRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nationId: number;
|
||||||
|
cityId: number;
|
||||||
|
troopId: number;
|
||||||
|
leadership: number;
|
||||||
|
strength: number;
|
||||||
|
intel: number;
|
||||||
|
experience: number;
|
||||||
|
dedication: number;
|
||||||
|
officerLevel: number;
|
||||||
|
personalCode: string;
|
||||||
|
specialCode: string;
|
||||||
|
special2Code: string;
|
||||||
|
horseCode: string;
|
||||||
|
weaponCode: string;
|
||||||
|
bookCode: string;
|
||||||
|
itemCode: string;
|
||||||
|
injury: number;
|
||||||
|
gold: number;
|
||||||
|
rice: number;
|
||||||
|
crew: number;
|
||||||
|
crewTypeId: number;
|
||||||
|
train: number;
|
||||||
|
age: number;
|
||||||
|
npcState: number;
|
||||||
|
meta: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CityRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nationId: number;
|
||||||
|
level: number;
|
||||||
|
population: number;
|
||||||
|
populationMax: number;
|
||||||
|
agriculture: number;
|
||||||
|
agricultureMax: number;
|
||||||
|
commerce: number;
|
||||||
|
commerceMax: number;
|
||||||
|
security: number;
|
||||||
|
securityMax: number;
|
||||||
|
supplyState: number;
|
||||||
|
frontState: number;
|
||||||
|
defence: number;
|
||||||
|
defenceMax: number;
|
||||||
|
wall: number;
|
||||||
|
wallMax: number;
|
||||||
|
meta: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NationRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
capitalCityId: number | null;
|
||||||
|
gold: number;
|
||||||
|
rice: number;
|
||||||
|
level: number;
|
||||||
|
typeCode: string;
|
||||||
|
meta: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DatabaseClient {
|
export interface DatabaseClient {
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst(args?: unknown): Promise<WorldStateRow | null>;
|
findFirst(args?: unknown): Promise<WorldStateRow | null>;
|
||||||
};
|
};
|
||||||
|
general: {
|
||||||
|
findUnique(args: { where: { id: number } }): Promise<GeneralRow | null>;
|
||||||
|
};
|
||||||
|
city: {
|
||||||
|
findUnique(args: { where: { id: number } }): Promise<CityRow | null>;
|
||||||
|
};
|
||||||
|
nation: {
|
||||||
|
findUnique(args: { where: { id: number } }): Promise<NationRow | null>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameApiContext {
|
export interface GameApiContext {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { WorldStateRow } from './context.js';
|
import type { WorldStateRow } from './context.js';
|
||||||
import { procedure, router } from './trpc.js';
|
import { authedProcedure, procedure, router } from './trpc.js';
|
||||||
|
import { buildTurnCommandTable } from './turns/commandTable.js';
|
||||||
|
|
||||||
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
|
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
|
||||||
|
|
||||||
@@ -35,6 +37,54 @@ export const appRouter = router({
|
|||||||
return state ? toWorldStateSnapshot(state) : null;
|
return state ? toWorldStateSnapshot(state) : null;
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
turns: router({
|
||||||
|
getCommandTable: authedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
generalId: z.number().int().positive(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const [worldState, general] = await Promise.all([
|
||||||
|
ctx.db.worldState.findFirst(),
|
||||||
|
ctx.db.general.findUnique({ where: { id: input.generalId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!worldState) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'World state is not initialized.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!general) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'General not found.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [city, nation] = await Promise.all([
|
||||||
|
general.cityId > 0
|
||||||
|
? ctx.db.city.findUnique({
|
||||||
|
where: { id: general.cityId },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
general.nationId > 0
|
||||||
|
? ctx.db.nation.findUnique({
|
||||||
|
where: { id: general.nationId },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return buildTurnCommandTable({
|
||||||
|
worldState,
|
||||||
|
general,
|
||||||
|
city,
|
||||||
|
nation,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
}),
|
||||||
turnDaemon: router({
|
turnDaemon: router({
|
||||||
run: procedure
|
run: procedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -0,0 +1,626 @@
|
|||||||
|
import type {
|
||||||
|
City,
|
||||||
|
Constraint,
|
||||||
|
ConstraintContext,
|
||||||
|
General,
|
||||||
|
GeneralActionDefinition,
|
||||||
|
Nation,
|
||||||
|
RequirementKey,
|
||||||
|
StateView,
|
||||||
|
TriggerValue,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
import {
|
||||||
|
AssignmentActionDefinition,
|
||||||
|
AwardActionDefinition,
|
||||||
|
CommerceInvestmentActionDefinition,
|
||||||
|
evaluateConstraints,
|
||||||
|
FireAttackActionDefinition,
|
||||||
|
NationRestActionDefinition,
|
||||||
|
RestActionDefinition,
|
||||||
|
TalentScoutActionDefinition,
|
||||||
|
VolunteerRecruitActionDefinition,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CityRow,
|
||||||
|
GeneralRow,
|
||||||
|
NationRow,
|
||||||
|
WorldStateRow,
|
||||||
|
} from '../context.js';
|
||||||
|
|
||||||
|
type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown';
|
||||||
|
|
||||||
|
export interface TurnCommandAvailability {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
reqArg: boolean;
|
||||||
|
possible: boolean;
|
||||||
|
status: AvailabilityStatus;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnCommandGroup {
|
||||||
|
category: string;
|
||||||
|
values: TurnCommandAvailability[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnCommandTable {
|
||||||
|
general: TurnCommandGroup[];
|
||||||
|
nation: TurnCommandGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandEnv {
|
||||||
|
develCost: number;
|
||||||
|
sabotageDefaultProb: number;
|
||||||
|
sabotageProbCoefByStat: number;
|
||||||
|
sabotageDefenceCoefByGeneralCount: number;
|
||||||
|
sabotageDamageMin: number;
|
||||||
|
sabotageDamageMax: number;
|
||||||
|
openingPartYear: number;
|
||||||
|
maxGeneral: number;
|
||||||
|
defaultNpcGold: number;
|
||||||
|
defaultNpcRice: number;
|
||||||
|
defaultCrewTypeId: number;
|
||||||
|
defaultSpecialDomestic: string | null;
|
||||||
|
defaultSpecialWar: string | null;
|
||||||
|
initialNationGenLimit: number;
|
||||||
|
baseGold: number;
|
||||||
|
baseRice: number;
|
||||||
|
maxResourceActionAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvailabilityCore {
|
||||||
|
possible: boolean;
|
||||||
|
status: AvailabilityStatus;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandEntry {
|
||||||
|
category: string;
|
||||||
|
definition: GeneralActionDefinition;
|
||||||
|
reqArg: boolean;
|
||||||
|
args: unknown;
|
||||||
|
evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INPUT_REQUIREMENT_KINDS = new Set<RequirementKey['kind']>([
|
||||||
|
'destGeneral',
|
||||||
|
'destCity',
|
||||||
|
'destNation',
|
||||||
|
'diplomacy',
|
||||||
|
'arg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const AVAILABILITY_PRIORITY: Record<AvailabilityStatus, number> = {
|
||||||
|
available: 3,
|
||||||
|
needsInput: 2,
|
||||||
|
unknown: 1,
|
||||||
|
blocked: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_GENERAL_GOLD = 1000;
|
||||||
|
const DEFAULT_GENERAL_RICE = 1000;
|
||||||
|
const DEFAULT_CREW_TYPE_ID = 1100;
|
||||||
|
|
||||||
|
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 asTriggerRecord = (value: unknown): Record<string, TriggerValue> =>
|
||||||
|
isRecord(value) ? (value as Record<string, TriggerValue>) : {};
|
||||||
|
|
||||||
|
const normalizeCode = (value: string | null | undefined): string | null => {
|
||||||
|
if (!value || value === 'None') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MemoryStateView implements StateView {
|
||||||
|
private readonly store = new Map<string, unknown>();
|
||||||
|
|
||||||
|
has(req: RequirementKey): boolean {
|
||||||
|
return this.store.has(this.getKey(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
get(req: RequirementKey): unknown | null {
|
||||||
|
return this.store.get(this.getKey(req)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(req: RequirementKey, value: unknown): void {
|
||||||
|
this.store.set(this.getKey(req), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKey(req: RequirementKey): string {
|
||||||
|
switch (req.kind) {
|
||||||
|
case 'general':
|
||||||
|
return `general:${req.id}`;
|
||||||
|
case 'city':
|
||||||
|
return `city:${req.id}`;
|
||||||
|
case 'nation':
|
||||||
|
return `nation:${req.id}`;
|
||||||
|
case 'destGeneral':
|
||||||
|
return `destGeneral:${req.id}`;
|
||||||
|
case 'destCity':
|
||||||
|
return `destCity:${req.id}`;
|
||||||
|
case 'destNation':
|
||||||
|
return `destNation:${req.id}`;
|
||||||
|
case 'diplomacy':
|
||||||
|
return `diplomacy:${req.srcNationId}:${req.destNationId}`;
|
||||||
|
case 'arg':
|
||||||
|
return `arg:${req.key}`;
|
||||||
|
case 'env':
|
||||||
|
return `env:${req.key}`;
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveNumber = (
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
keys: string[],
|
||||||
|
fallback: number
|
||||||
|
): number => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveOptionalString = (
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
keys: string[]
|
||||||
|
): string | null => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key];
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return normalizeCode(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
||||||
|
const config = asRecord(worldState.config);
|
||||||
|
const constValues = asRecord(config.const);
|
||||||
|
|
||||||
|
return {
|
||||||
|
develCost: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['develCost', 'develcost', 'develrate'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
sabotageDefaultProb: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['sabotageDefaultProb'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
sabotageProbCoefByStat: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['sabotageProbCoefByStat'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
sabotageDefenceCoefByGeneralCount: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['sabotageDefenceCoefByGeneralCount'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
sabotageDamageMin: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['sabotageDamageMin'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
sabotageDamageMax: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['sabotageDamageMax'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
openingPartYear: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['openingPartYear'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
maxGeneral: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['defaultMaxGeneral', 'maxGeneral'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
defaultNpcGold: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['defaultNpcGold', 'defaultGold'],
|
||||||
|
DEFAULT_GENERAL_GOLD
|
||||||
|
),
|
||||||
|
defaultNpcRice: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['defaultNpcRice', 'defaultRice'],
|
||||||
|
DEFAULT_GENERAL_RICE
|
||||||
|
),
|
||||||
|
defaultCrewTypeId: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['defaultCrewTypeId'],
|
||||||
|
DEFAULT_CREW_TYPE_ID
|
||||||
|
),
|
||||||
|
defaultSpecialDomestic: resolveOptionalString(
|
||||||
|
constValues,
|
||||||
|
['defaultSpecialDomestic']
|
||||||
|
),
|
||||||
|
defaultSpecialWar: resolveOptionalString(
|
||||||
|
constValues,
|
||||||
|
['defaultSpecialWar']
|
||||||
|
),
|
||||||
|
initialNationGenLimit: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['initialNationGenLimit'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
||||||
|
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
||||||
|
maxResourceActionAmount: resolveNumber(
|
||||||
|
constValues,
|
||||||
|
['maxResourceActionAmount'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildConstraintEnv = (
|
||||||
|
worldState: WorldStateRow
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const meta = asRecord(worldState.meta);
|
||||||
|
const scenarioMeta = asRecord(meta.scenarioMeta);
|
||||||
|
const startYear =
|
||||||
|
typeof scenarioMeta.startYear === 'number'
|
||||||
|
? scenarioMeta.startYear
|
||||||
|
: undefined;
|
||||||
|
const relYear =
|
||||||
|
typeof startYear === 'number'
|
||||||
|
? worldState.currentYear - startYear
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentYear: worldState.currentYear,
|
||||||
|
currentMonth: worldState.currentMonth,
|
||||||
|
year: worldState.currentYear,
|
||||||
|
month: worldState.currentMonth,
|
||||||
|
startYear,
|
||||||
|
relYear,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapGeneralRow = (row: GeneralRow): General => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
nationId: row.nationId,
|
||||||
|
cityId: row.cityId,
|
||||||
|
troopId: row.troopId,
|
||||||
|
stats: {
|
||||||
|
leadership: row.leadership,
|
||||||
|
strength: row.strength,
|
||||||
|
intelligence: row.intel,
|
||||||
|
},
|
||||||
|
experience: row.experience,
|
||||||
|
dedication: row.dedication,
|
||||||
|
officerLevel: row.officerLevel,
|
||||||
|
role: {
|
||||||
|
personality: normalizeCode(row.personalCode),
|
||||||
|
specialDomestic: normalizeCode(row.specialCode),
|
||||||
|
specialWar: normalizeCode(row.special2Code),
|
||||||
|
items: {
|
||||||
|
horse: normalizeCode(row.horseCode),
|
||||||
|
weapon: normalizeCode(row.weaponCode),
|
||||||
|
book: normalizeCode(row.bookCode),
|
||||||
|
item: normalizeCode(row.itemCode),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
injury: row.injury,
|
||||||
|
gold: row.gold,
|
||||||
|
rice: row.rice,
|
||||||
|
crew: row.crew,
|
||||||
|
crewTypeId: row.crewTypeId,
|
||||||
|
train: row.train,
|
||||||
|
age: row.age,
|
||||||
|
npcState: row.npcState,
|
||||||
|
triggerState: {
|
||||||
|
flags: {},
|
||||||
|
counters: {},
|
||||||
|
modifiers: {},
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
meta: asTriggerRecord(row.meta),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapCityRow = (row: CityRow): City => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
nationId: row.nationId,
|
||||||
|
level: row.level,
|
||||||
|
population: row.population,
|
||||||
|
populationMax: row.populationMax,
|
||||||
|
agriculture: row.agriculture,
|
||||||
|
agricultureMax: row.agricultureMax,
|
||||||
|
commerce: row.commerce,
|
||||||
|
commerceMax: row.commerceMax,
|
||||||
|
security: row.security,
|
||||||
|
securityMax: row.securityMax,
|
||||||
|
supplyState: row.supplyState,
|
||||||
|
frontState: row.frontState,
|
||||||
|
defence: row.defence,
|
||||||
|
defenceMax: row.defenceMax,
|
||||||
|
wall: row.wall,
|
||||||
|
wallMax: row.wallMax,
|
||||||
|
meta: asTriggerRecord(row.meta),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapNationRow = (row: NationRow): Nation => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
color: row.color,
|
||||||
|
capitalCityId: row.capitalCityId,
|
||||||
|
chiefGeneralId: null,
|
||||||
|
gold: row.gold,
|
||||||
|
rice: row.rice,
|
||||||
|
power: 0,
|
||||||
|
level: row.level,
|
||||||
|
typeCode: row.typeCode,
|
||||||
|
meta: asTriggerRecord(row.meta),
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildStateView = (
|
||||||
|
general: General,
|
||||||
|
city: City | null,
|
||||||
|
nation: Nation | null
|
||||||
|
): StateView => {
|
||||||
|
const view = new MemoryStateView();
|
||||||
|
view.set({ kind: 'general', id: general.id }, general);
|
||||||
|
if (city) {
|
||||||
|
view.set({ kind: 'city', id: city.id }, city);
|
||||||
|
}
|
||||||
|
if (nation) {
|
||||||
|
view.set({ kind: 'nation', id: nation.id }, nation);
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateAvailability = (
|
||||||
|
constraints: Constraint[],
|
||||||
|
ctx: ConstraintContext,
|
||||||
|
view: StateView,
|
||||||
|
reqArg: boolean
|
||||||
|
): AvailabilityCore => {
|
||||||
|
const result = evaluateConstraints(constraints, ctx, view);
|
||||||
|
if (result.kind === 'allow') {
|
||||||
|
return { possible: true, status: 'available' };
|
||||||
|
}
|
||||||
|
if (result.kind === 'deny') {
|
||||||
|
return {
|
||||||
|
possible: false,
|
||||||
|
status: 'blocked',
|
||||||
|
reason: result.reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const missingKinds = new Set(result.missing.map((req) => req.kind));
|
||||||
|
const inputOnlyMissing =
|
||||||
|
missingKinds.size === 0
|
||||||
|
? reqArg
|
||||||
|
: Array.from(missingKinds).every((kind) =>
|
||||||
|
INPUT_REQUIREMENT_KINDS.has(kind)
|
||||||
|
);
|
||||||
|
if (inputOnlyMissing) {
|
||||||
|
return {
|
||||||
|
possible: true,
|
||||||
|
status: 'needsInput',
|
||||||
|
reason: '대상 선택 필요',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
possible: false,
|
||||||
|
status: 'unknown',
|
||||||
|
reason: '정보 부족',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateDefinition = (
|
||||||
|
definition: GeneralActionDefinition,
|
||||||
|
ctx: ConstraintContext,
|
||||||
|
view: StateView,
|
||||||
|
reqArg: boolean,
|
||||||
|
args: unknown
|
||||||
|
): AvailabilityCore => {
|
||||||
|
const constraints = definition.buildConstraints(ctx, args);
|
||||||
|
return evaluateAvailability(constraints, ctx, view, reqArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickAvailability = (
|
||||||
|
lhs: AvailabilityCore,
|
||||||
|
rhs: AvailabilityCore
|
||||||
|
): AvailabilityCore =>
|
||||||
|
AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status]
|
||||||
|
? lhs
|
||||||
|
: rhs;
|
||||||
|
|
||||||
|
const buildEntries = (env: CommandEnv): {
|
||||||
|
general: CommandEntry[];
|
||||||
|
nation: CommandEntry[];
|
||||||
|
} => {
|
||||||
|
const emptyModules: [] = [];
|
||||||
|
|
||||||
|
const generalEntries: CommandEntry[] = [
|
||||||
|
{
|
||||||
|
category: '개인',
|
||||||
|
definition: new RestActionDefinition(),
|
||||||
|
reqArg: false,
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '내정',
|
||||||
|
definition: new CommerceInvestmentActionDefinition(emptyModules, {
|
||||||
|
develCost: env.develCost,
|
||||||
|
}),
|
||||||
|
reqArg: false,
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '계략',
|
||||||
|
definition: new FireAttackActionDefinition(emptyModules, {
|
||||||
|
develCost: env.develCost,
|
||||||
|
sabotageDefaultProb: env.sabotageDefaultProb,
|
||||||
|
sabotageProbCoefByStat: env.sabotageProbCoefByStat,
|
||||||
|
sabotageDefenceCoefByGeneralCount:
|
||||||
|
env.sabotageDefenceCoefByGeneralCount,
|
||||||
|
sabotageDamageMin: env.sabotageDamageMin,
|
||||||
|
sabotageDamageMax: env.sabotageDamageMax,
|
||||||
|
}),
|
||||||
|
reqArg: true,
|
||||||
|
args: { destCityId: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '인사',
|
||||||
|
definition: new TalentScoutActionDefinition(emptyModules, {
|
||||||
|
develCost: env.develCost,
|
||||||
|
maxGeneral: env.maxGeneral,
|
||||||
|
defaultNpcGold: env.defaultNpcGold,
|
||||||
|
defaultNpcRice: env.defaultNpcRice,
|
||||||
|
defaultCrewTypeId: env.defaultCrewTypeId,
|
||||||
|
defaultSpecialDomestic: env.defaultSpecialDomestic,
|
||||||
|
defaultSpecialWar: env.defaultSpecialWar,
|
||||||
|
}),
|
||||||
|
reqArg: false,
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '전략',
|
||||||
|
definition: new VolunteerRecruitActionDefinition(emptyModules, {
|
||||||
|
openingPartYear: env.openingPartYear,
|
||||||
|
initialNationGenLimit: env.initialNationGenLimit,
|
||||||
|
defaultNpcGold: env.defaultNpcGold,
|
||||||
|
defaultNpcRice: env.defaultNpcRice,
|
||||||
|
defaultCrewTypeId: env.defaultCrewTypeId,
|
||||||
|
defaultSpecialDomestic: env.defaultSpecialDomestic,
|
||||||
|
defaultSpecialWar: env.defaultSpecialWar,
|
||||||
|
}),
|
||||||
|
reqArg: false,
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const awardDefinition = new AwardActionDefinition({
|
||||||
|
baseGold: env.baseGold,
|
||||||
|
baseRice: env.baseRice,
|
||||||
|
maxAmount: env.maxResourceActionAmount,
|
||||||
|
});
|
||||||
|
const assignmentDefinition = new AssignmentActionDefinition({});
|
||||||
|
|
||||||
|
const nationEntries: CommandEntry[] = [
|
||||||
|
{
|
||||||
|
category: '휴식',
|
||||||
|
definition: new NationRestActionDefinition(),
|
||||||
|
reqArg: false,
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '인사',
|
||||||
|
definition: assignmentDefinition,
|
||||||
|
reqArg: true,
|
||||||
|
args: { destGeneralId: 0, destCityId: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '인사',
|
||||||
|
definition: awardDefinition,
|
||||||
|
reqArg: true,
|
||||||
|
args: { isGold: true, amount: 1, destGeneralId: 0 },
|
||||||
|
evaluate: (ctx, view) => {
|
||||||
|
const gold = evaluateDefinition(
|
||||||
|
awardDefinition,
|
||||||
|
ctx,
|
||||||
|
view,
|
||||||
|
true,
|
||||||
|
{ isGold: true, amount: 1, destGeneralId: 0 }
|
||||||
|
);
|
||||||
|
const rice = evaluateDefinition(
|
||||||
|
awardDefinition,
|
||||||
|
ctx,
|
||||||
|
view,
|
||||||
|
true,
|
||||||
|
{ isGold: false, amount: 1, destGeneralId: 0 }
|
||||||
|
);
|
||||||
|
return pickAvailability(gold, rice);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return { general: generalEntries, nation: nationEntries };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildGroups = (
|
||||||
|
entries: CommandEntry[],
|
||||||
|
ctx: ConstraintContext,
|
||||||
|
view: StateView
|
||||||
|
): TurnCommandGroup[] => {
|
||||||
|
const groups = new Map<string, TurnCommandAvailability[]>();
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const availability = entry.evaluate
|
||||||
|
? entry.evaluate(ctx, view)
|
||||||
|
: evaluateDefinition(
|
||||||
|
entry.definition,
|
||||||
|
ctx,
|
||||||
|
view,
|
||||||
|
entry.reqArg,
|
||||||
|
entry.args
|
||||||
|
);
|
||||||
|
const value: TurnCommandAvailability = {
|
||||||
|
key: entry.definition.key,
|
||||||
|
name: entry.definition.name,
|
||||||
|
reqArg: entry.reqArg,
|
||||||
|
...availability,
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = groups.get(entry.category);
|
||||||
|
if (list) {
|
||||||
|
list.push(value);
|
||||||
|
} else {
|
||||||
|
groups.set(entry.category, [value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.entries()).map(([category, values]) => ({
|
||||||
|
category,
|
||||||
|
values,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildTurnCommandTable = (options: {
|
||||||
|
worldState: WorldStateRow;
|
||||||
|
general: GeneralRow;
|
||||||
|
city: CityRow | null;
|
||||||
|
nation: NationRow | null;
|
||||||
|
}): TurnCommandTable => {
|
||||||
|
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
|
||||||
|
const general = mapGeneralRow(options.general);
|
||||||
|
const city = options.city ? mapCityRow(options.city) : null;
|
||||||
|
const nation = options.nation ? mapNationRow(options.nation) : null;
|
||||||
|
const view = buildStateView(general, city, nation);
|
||||||
|
|
||||||
|
const ctx: ConstraintContext = {
|
||||||
|
actorId: general.id,
|
||||||
|
cityId: city?.id,
|
||||||
|
nationId: nation?.id,
|
||||||
|
args: {},
|
||||||
|
env: buildConstraintEnv(options.worldState),
|
||||||
|
mode: 'precheck',
|
||||||
|
};
|
||||||
|
|
||||||
|
const env = buildCommandEnv(options.worldState);
|
||||||
|
const entries = buildEntries(env);
|
||||||
|
|
||||||
|
return {
|
||||||
|
general: buildGroups(entries.general, ctx, view),
|
||||||
|
nation: buildGroups(entries.nation, ctx, view),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -19,6 +19,15 @@ const buildContext = (options?: {
|
|||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => options?.state ?? null,
|
findFirst: async () => options?.state ?? null,
|
||||||
},
|
},
|
||||||
|
general: {
|
||||||
|
findUnique: async () => null,
|
||||||
|
},
|
||||||
|
city: {
|
||||||
|
findUnique: async () => null,
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findUnique: async () => null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../packages/common" },
|
{ "path": "../../packages/common" },
|
||||||
{ "path": "../../packages/infra" }
|
{ "path": "../../packages/infra" },
|
||||||
|
{ "path": "../../packages/logic" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user