feat: add 발령 (Assignment) and 포상 (Award) actions for generals, and implement 휴식 (Rest) action
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user