feat: add 발령 (Assignment) and 포상 (Award) actions for generals, and implement 휴식 (Rest) action

This commit is contained in:
2025-12-29 12:26:58 +00:00
parent 7f73110621
commit cd43445e4b
5 changed files with 816 additions and 1 deletions
@@ -0,0 +1,205 @@
import type {
City,
General,
GeneralTriggerState,
TriggerValue,
} from '../../../domain/entities.js';
import type {
Constraint,
ConstraintContext,
} from '../../../constraints/types.js';
import {
alwaysFail,
beChief,
existsDestGeneral,
friendlyDestGeneral,
notBeNeutral,
occupiedCity,
occupiedDestCity,
suppliedCity,
suppliedDestCity,
} from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import { createGeneralPatchEffect, createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface AssignmentArgs {
destGeneralId: number;
destCityId: number;
}
export interface AssignmentResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionResolveContext<TriggerState> {
destGeneral: General<TriggerState>;
destCity: City;
currentYear: number;
currentMonth: number;
turnTermMinutes?: number;
generalTurnTime?: Date;
destGeneralTurnTime?: Date;
}
export interface AssignmentEnvironment {
formatCityName?: (city: City) => string;
}
const ACTION_NAME = '발령';
const joinYearMonth = (year: number, month: number): number =>
year * 12 + month - 1;
const cutTurn = (time: Date, turnTermMinutes: number): number => {
const turnMs = turnTermMinutes * 60 * 1000;
return Math.floor(time.getTime() / turnMs);
};
const resolveLastAssignment = (
context: AssignmentResolveContext
): number => {
let yearMonth = joinYearMonth(context.currentYear, context.currentMonth);
const term = context.turnTermMinutes;
const srcTime = context.generalTurnTime;
const destTime = context.destGeneralTurnTime;
if (term && srcTime && destTime) {
if (cutTurn(srcTime, term) !== cutTurn(destTime, term)) {
yearMonth += 1;
}
}
return yearMonth;
};
const addMetaValue = (
meta: Record<string, TriggerValue>,
key: string,
value: TriggerValue
): Record<string, TriggerValue> => ({
...meta,
[key]: value,
});
// 발령 결과를 계산한다.
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
private readonly env: AssignmentEnvironment;
constructor(env: AssignmentEnvironment) {
this.env = env;
}
resolve(
context: AssignmentResolveContext<TriggerState>,
_args: AssignmentArgs
): GeneralActionOutcome<TriggerState> {
void _args;
const destGeneral = context.destGeneral;
const destCity = context.destCity;
const cityName = this.env.formatCityName
? this.env.formatCityName(destCity)
: destCity.name;
const yearMonth = resolveLastAssignment(context);
const effects: Array<GeneralActionEffect<TriggerState>> = [
createGeneralPatchEffect(
{
cityId: destCity.id,
meta: addMetaValue(destGeneral.meta, 'last발령', yearMonth),
},
destGeneral.id
),
];
effects.push(
createLogEffect(
`<Y>${context.general.name}</>에 의해 <G><b>${cityName}</b></>로 발령됐습니다.`,
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: destGeneral.id,
format: LogFormat.MONTH,
}
)
);
effects.push(
createLogEffect(
`<Y>${destGeneral.name}</>을 <G><b>${cityName}</b></>로 발령했습니다.`,
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}
)
);
return { effects };
}
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<
TriggerState,
AssignmentArgs,
AssignmentResolveContext<TriggerState>
> {
public readonly key = 'che_발령';
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(env: AssignmentEnvironment) {
this.resolver = new ActionResolver(env);
}
parseArgs(raw: unknown): AssignmentArgs | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const data = raw as {
destGeneralId?: unknown;
destCityId?: unknown;
};
if (typeof data.destGeneralId !== 'number') {
return null;
}
if (typeof data.destCityId !== 'number') {
return null;
}
return {
destGeneralId: data.destGeneralId,
destCityId: data.destCityId,
};
}
buildConstraints(
ctx: ConstraintContext,
_args: AssignmentArgs
): Constraint[] {
void _args;
if (ctx.destGeneralId === ctx.actorId) {
return [alwaysFail('본인입니다')];
}
return [
beChief(),
notBeNeutral(),
occupiedCity(),
suppliedCity(),
existsDestGeneral(),
friendlyDestGeneral(),
occupiedDestCity(),
suppliedDestCity(),
];
}
resolve(
context: AssignmentResolveContext<TriggerState>,
args: AssignmentArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
@@ -0,0 +1,266 @@
import type {
General,
GeneralTriggerState,
Nation,
} from '../../../domain/entities.js';
import type {
Constraint,
ConstraintContext,
RequirementKey,
StateView,
} from '../../../constraints/types.js';
import {
alwaysFail,
beChief,
existsDestGeneral,
friendlyDestGeneral,
notBeNeutral,
occupiedCity,
reqNationGold,
reqNationRice,
suppliedCity,
} from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import {
createGeneralPatchEffect,
createLogEffect,
createNationPatchEffect,
} from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface AwardArgs {
isGold: boolean;
amount: number;
destGeneralId: number;
}
export interface AwardResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionResolveContext<TriggerState> {
destGeneral: General<TriggerState>;
}
export interface AwardEnvironment {
baseGold: number;
baseRice: number;
minAmount?: number;
maxAmount: number;
amountUnit?: number;
}
const ACTION_NAME = '포상';
const DEFAULT_MIN_AMOUNT = 100;
const DEFAULT_AMOUNT_UNIT = 100;
const roundToUnit = (value: number, unit: number): number =>
Math.round(value / unit) * unit;
const formatNumber = (value: number): string =>
value.toLocaleString('en-US');
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
const normalizeAmount = (
amount: number,
env: AwardEnvironment
): number => {
const unit = env.amountUnit ?? DEFAULT_AMOUNT_UNIT;
const min = env.minAmount ?? DEFAULT_MIN_AMOUNT;
const max = env.maxAmount;
return clamp(roundToUnit(amount, unit), min, max);
};
const resolveNationResource = (
nation: Nation,
isGold: boolean
): { current: number; base: number; key: 'gold' | 'rice'; label: string } => ({
current: isGold ? nation.gold : nation.rice,
base: isGold ? 0 : 0,
key: isGold ? 'gold' : 'rice',
label: isGold ? '금' : '쌀',
});
// 포상 비용 및 유효 범위를 계산한다.
export class CommandResolver {
private readonly env: AwardEnvironment;
constructor(env: AwardEnvironment) {
this.env = env;
}
getRequiredResource(isGold: boolean): number {
return 1 + (isGold ? this.env.baseGold : this.env.baseRice);
}
normalizeAmount(amount: number): number {
return normalizeAmount(amount, this.env);
}
}
// 포상 결과를 계산한다.
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
private readonly env: AwardEnvironment;
private readonly command: CommandResolver;
constructor(env: AwardEnvironment) {
this.env = env;
this.command = new CommandResolver(env);
}
resolve(
context: AwardResolveContext<TriggerState>,
args: AwardArgs
): GeneralActionOutcome<TriggerState> {
const nation = context.nation;
if (!nation) {
return { effects: [] };
}
const { key, label } = resolveNationResource(nation, args.isGold);
const base = args.isGold ? this.env.baseGold : this.env.baseRice;
const available = Math.max(nation[key] - base, 0);
const amount = clamp(
this.command.normalizeAmount(args.amount),
0,
available
);
if (amount <= 0) {
return { effects: [] };
}
const amountText = formatNumber(amount);
const effects: Array<GeneralActionEffect<TriggerState>> = [
createGeneralPatchEffect(
{ [key]: context.destGeneral[key] + amount } as Partial<
General<TriggerState>
>,
context.destGeneral.id
),
createNationPatchEffect({
[key]: nation[key] - amount,
} as Partial<Nation>, nation.id),
];
effects.push(
createLogEffect(
`${label} ${amountText} 포상으로 받았습니다.`,
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: context.destGeneral.id,
format: LogFormat.PLAIN,
}
)
);
effects.push(
createLogEffect(
`<Y>${context.destGeneral.name}</>에게 ${label} ${amountText} 수여했습니다.`,
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}
)
);
return { effects };
}
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<
TriggerState,
AwardArgs,
AwardResolveContext<TriggerState>
> {
public readonly key = 'che_포상';
public readonly name = ACTION_NAME;
private readonly command: CommandResolver;
private readonly resolver: ActionResolver<TriggerState>;
constructor(env: AwardEnvironment) {
this.command = new CommandResolver(env);
this.resolver = new ActionResolver(env);
}
parseArgs(raw: unknown): AwardArgs | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const data = raw as {
isGold?: unknown;
amount?: unknown;
destGeneralId?: unknown;
};
if (typeof data.isGold !== 'boolean') {
return null;
}
if (typeof data.amount !== 'number' || Number.isNaN(data.amount)) {
return null;
}
if (typeof data.destGeneralId !== 'number') {
return null;
}
const amount = this.command.normalizeAmount(data.amount);
if (amount <= 0) {
return null;
}
return {
isGold: data.isGold,
amount,
destGeneralId: data.destGeneralId,
};
}
buildConstraints(
ctx: ConstraintContext,
args: AwardArgs
): Constraint[] {
const requirements: RequirementKey[] = [];
if (ctx.cityId !== undefined) {
requirements.push({ kind: 'city', id: ctx.cityId });
}
if (ctx.nationId !== undefined) {
requirements.push({ kind: 'nation', id: ctx.nationId });
}
if (ctx.destGeneralId !== undefined) {
requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId });
}
if (ctx.destGeneralId === ctx.actorId) {
return [alwaysFail('본인입니다')];
}
const getRequired = (_ctx: ConstraintContext, _view: StateView): number =>
this.command.getRequiredResource(args.isGold);
const resourceConstraint = args.isGold
? reqNationGold(getRequired, requirements)
: reqNationRice(getRequired, requirements);
return [
notBeNeutral(),
occupiedCity(),
beChief(),
suppliedCity(),
existsDestGeneral(),
friendlyDestGeneral(),
resourceConstraint,
];
}
resolve(
context: AwardResolveContext<TriggerState>,
args: AwardArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
@@ -1 +1,50 @@
export {};
export type NationTurnCommandKey = '휴식' | 'che_포상' | 'che_발령';
export type NationTurnCommandModule =
| typeof import('./휴식.js')
| typeof import('./che_포상.js')
| typeof import('./che_발령.js');
export type NationTurnCommandImporter = () => Promise<NationTurnCommandModule>;
const defaultImporters: Record<
NationTurnCommandKey,
NationTurnCommandImporter
> = {
휴식: async () => import('./휴식.js'),
che_포상: async () => import('./che_포상.js'),
che_발령: async () => import('./che_발령.js'),
};
export class NationTurnCommandLoader {
constructor(
private readonly importers: Record<
NationTurnCommandKey,
NationTurnCommandImporter
> = defaultImporters
) {}
async load(
key: NationTurnCommandKey
): Promise<NationTurnCommandModule> {
const importer = this.importers[key];
if (!importer) {
throw new Error(`Unknown nation turn command key: ${key}`);
}
return importer();
}
}
export {
ActionDefinition as NationRestActionDefinition,
ActionResolver as NationRestActionResolver,
} from './휴식.js';
export {
ActionDefinition as AwardActionDefinition,
ActionResolver as AwardActionResolver,
CommandResolver as AwardCommandResolver,
} from './che_포상.js';
export {
ActionDefinition as AssignmentActionDefinition,
ActionResolver as AssignmentActionResolver,
} from './che_발령.js';
@@ -0,0 +1,58 @@
import type {
GeneralTriggerState,
} from '../../../domain/entities.js';
import type {
Constraint,
ConstraintContext,
} from '../../../constraints/types.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
export interface NationRestArgs {}
const ACTION_NAME = '휴식';
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
resolve(
_context: GeneralActionResolveContext<TriggerState>,
_args: NationRestArgs
): GeneralActionOutcome<TriggerState> {
void _context;
void _args;
return { effects: [] };
}
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, NationRestArgs> {
public readonly key = '휴식';
public readonly name = ACTION_NAME;
private readonly resolver = new ActionResolver<TriggerState>();
parseArgs(_raw: unknown): NationRestArgs | null {
void _raw;
return {};
}
buildConstraints(
_ctx: ConstraintContext,
_args: NationRestArgs
): Constraint[] {
void _ctx;
void _args;
return [];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
args: NationRestArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
+237
View File
@@ -40,6 +40,21 @@ const readCity = (view: StateView, id?: number): City | null => {
return view.get(req) as City | null;
};
const readDestGeneral = (
ctx: ConstraintContext,
view: StateView
): General | null => {
const destGeneralId = resolveDestGeneralId(ctx);
if (destGeneralId === undefined) {
return null;
}
const req: RequirementKey = { kind: 'destGeneral', id: destGeneralId };
if (!view.has(req)) {
return null;
}
return view.get(req) as General | null;
};
const resolveDestCityId = (ctx: ConstraintContext): number | undefined => {
if (ctx.destCityId !== undefined) {
return ctx.destCityId;
@@ -48,6 +63,14 @@ const resolveDestCityId = (ctx: ConstraintContext): number | undefined => {
return typeof raw === 'number' ? raw : undefined;
};
const resolveDestGeneralId = (ctx: ConstraintContext): number | undefined => {
if (ctx.destGeneralId !== undefined) {
return ctx.destGeneralId;
}
const raw = ctx.args?.destGeneralId;
return typeof raw === 'number' ? raw : undefined;
};
const resolveDestNationId = (ctx: ConstraintContext): number | undefined => {
if (ctx.destNationId !== undefined) {
return ctx.destNationId;
@@ -135,6 +158,12 @@ export const notBeNeutral = (): Constraint => ({
},
});
export const alwaysFail = (reason: string): Constraint => ({
name: 'AlwaysFail',
requires: () => [],
test: () => ({ kind: 'deny', reason }),
});
export const beChief = (): Constraint => ({
name: 'BeChief',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
@@ -219,6 +248,78 @@ export const availableStrategicCommand = (
},
});
export const reqNationGold = (
getRequiredGold: (ctx: ConstraintContext, view: StateView) => number,
requirements: RequirementKey[] = []
): Constraint => ({
name: 'ReqNationGold',
requires: (ctx) => {
const reqs: RequirementKey[] = [...requirements];
if (ctx.nationId !== undefined) {
reqs.push({ kind: 'nation', id: ctx.nationId });
}
return reqs;
},
test: (ctx, view) => {
const nationId = ctx.nationId;
if (nationId === undefined) {
return unknownOrDeny(ctx, [], '국가 정보가 없습니다.');
}
const nationReq: RequirementKey = { kind: 'nation', id: nationId };
const missing = [nationReq, ...requirements].filter(
(req) => !view.has(req)
);
if (missing.length > 0) {
return unknownOrDeny(ctx, missing, '국가 정보가 없습니다.');
}
const nation = view.get(nationReq) as Nation | null;
if (!nation) {
return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.');
}
const required = getRequiredGold(ctx, view);
if (nation.gold >= required) {
return allow();
}
return { kind: 'deny', reason: '자금이 모자랍니다.' };
},
});
export const reqNationRice = (
getRequiredRice: (ctx: ConstraintContext, view: StateView) => number,
requirements: RequirementKey[] = []
): Constraint => ({
name: 'ReqNationRice',
requires: (ctx) => {
const reqs: RequirementKey[] = [...requirements];
if (ctx.nationId !== undefined) {
reqs.push({ kind: 'nation', id: ctx.nationId });
}
return reqs;
},
test: (ctx, view) => {
const nationId = ctx.nationId;
if (nationId === undefined) {
return unknownOrDeny(ctx, [], '국가 정보가 없습니다.');
}
const nationReq: RequirementKey = { kind: 'nation', id: nationId };
const missing = [nationReq, ...requirements].filter(
(req) => !view.has(req)
);
if (missing.length > 0) {
return unknownOrDeny(ctx, missing, '국가 정보가 없습니다.');
}
const nation = view.get(nationReq) as Nation | null;
if (!nation) {
return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.');
}
const required = getRequiredRice(ctx, view);
if (nation.rice >= required) {
return allow();
}
return { kind: 'deny', reason: '군량이 모자랍니다.' };
},
});
export const notOpeningPart = (
relYear: number,
openingPartYear: number
@@ -272,6 +373,45 @@ export const occupiedCity = (
},
});
export const occupiedDestCity = (): Constraint => ({
name: 'OccupiedDestCity',
requires: (ctx) => {
const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }];
const destCityId = resolveDestCityId(ctx);
if (destCityId !== undefined) {
reqs.push({ kind: 'destCity', id: destCityId });
}
return reqs;
},
test: (ctx, view) => {
const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId };
if (!view.has(generalReq)) {
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
}
const general = view.get(generalReq) as General | null;
if (!general) {
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
}
const destCity = readDestCity(ctx, view);
if (!destCity) {
const destCityId = resolveDestCityId(ctx);
if (destCityId === undefined) {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
}
const req: RequirementKey = {
kind: 'destCity',
id: destCityId,
};
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
}
const baseNationId = ctx.nationId ?? general.nationId;
if (destCity.nationId === baseNationId) {
return allow();
}
return { kind: 'deny', reason: '아국이 아닙니다.' };
},
});
export const suppliedCity = (): Constraint => ({
name: 'SuppliedCity',
requires: (ctx) =>
@@ -292,6 +432,37 @@ export const suppliedCity = (): Constraint => ({
},
});
export const suppliedDestCity = (): Constraint => ({
name: 'SuppliedDestCity',
requires: (ctx) =>
resolveDestCityId(ctx) !== undefined
? [
{
kind: 'destCity',
id: resolveDestCityId(ctx) ?? 0,
},
]
: [],
test: (ctx, view) => {
const destCity = readDestCity(ctx, view);
if (!destCity) {
const destCityId = resolveDestCityId(ctx);
if (destCityId === undefined) {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
}
const req: RequirementKey = {
kind: 'destCity',
id: destCityId,
};
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
}
if (destCity.supplyState) {
return allow();
}
return { kind: 'deny', reason: '고립된 도시입니다.' };
},
});
export const reqGeneralGold = (
getRequiredGold: (ctx: ConstraintContext, view: StateView) => number,
requirements: RequirementKey[] = []
@@ -402,6 +573,72 @@ export const existsDestCity = (): Constraint => ({
},
});
export const existsDestGeneral = (): Constraint => ({
name: 'ExistsDestGeneral',
requires: (ctx) =>
resolveDestGeneralId(ctx) !== undefined
? [
{
kind: 'destGeneral',
id: resolveDestGeneralId(ctx) ?? 0,
},
]
: [],
test: (ctx, view) => {
const destGeneralId = resolveDestGeneralId(ctx);
if (destGeneralId === undefined) {
return unknownOrDeny(ctx, [], '장수 정보가 없습니다.');
}
const req: RequirementKey = { kind: 'destGeneral', id: destGeneralId };
if (!view.has(req)) {
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
}
const general = view.get(req) as General | null;
if (!general) {
return { kind: 'deny', reason: '장수 정보가 없습니다.' };
}
return allow();
},
});
export const friendlyDestGeneral = (): Constraint => ({
name: 'FriendlyDestGeneral',
requires: (ctx) => {
const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }];
const destGeneralId = resolveDestGeneralId(ctx);
if (destGeneralId !== undefined) {
reqs.push({ kind: 'destGeneral', id: destGeneralId });
}
return reqs;
},
test: (ctx, view) => {
const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId };
if (!view.has(generalReq)) {
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
}
const general = view.get(generalReq) as General | null;
if (!general) {
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
}
const destGeneral = readDestGeneral(ctx, view);
if (!destGeneral) {
const destGeneralId = resolveDestGeneralId(ctx);
if (destGeneralId === undefined) {
return unknownOrDeny(ctx, [], '장수 정보가 없습니다.');
}
const req: RequirementKey = {
kind: 'destGeneral',
id: destGeneralId,
};
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
}
if (destGeneral.nationId === general.nationId) {
return allow();
}
return { kind: 'deny', reason: '아군이 아닙니다.' };
},
});
export const notOccupiedDestCity = (): Constraint => ({
name: 'NotOccupiedDestCity',
requires: (ctx) => {