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);
}
}