feat: Add new general actions and constraints for game mechanics

- Introduced new general turn commands: 'che_거병', 'che_임관', 'che_건국', 'che_훈련', 'che_사기진작', 'che_요양', 'che_출병', 'che_주민선정', 'che_농지개간', 'che_기술연구', 'che_치안강화', 'che_수비강화', 'che_성벽보수'.
- Implemented action definitions for each new command, including constraints and resolution logic.
- Added new constraints: 'beNeutral', 'remainCityCapacityByMax', 'existsDestNation'.
- Enhanced city development actions for '농지개간', '성벽보수', and '수비강화'.
- Created a new action for declaring war: 'che_선전포고' with appropriate constraints.
- Updated existing action definitions to accommodate new game mechanics and ensure proper functionality.
This commit is contained in:
2026-01-01 11:49:15 +00:00
parent 8c9f61700e
commit cce57d0fea
20 changed files with 1344 additions and 2 deletions
+130
View File
@@ -10,15 +10,28 @@ import type {
TriggerValue,
} from '@sammo-ts/logic';
import {
AppointmentActionDefinition,
AssignmentActionDefinition,
BoostMoraleActionDefinition,
AwardActionDefinition,
CommerceInvestmentActionDefinition,
DeclarationActionDefinition,
DefenceUpgradeActionDefinition,
DispatchActionDefinition,
evaluateConstraints,
FireAttackActionDefinition,
FarmingActionDefinition,
FoundingActionDefinition,
NationRestActionDefinition,
RecoveryActionDefinition,
ResidentsSelectionActionDefinition,
RecruitActionDefinition,
RestActionDefinition,
SecurityUpgradeActionDefinition,
TechResearchActionDefinition,
TalentScoutActionDefinition,
TrainingActionDefinition,
UprisingActionDefinition,
VolunteerRecruitActionDefinition,
} from '@sammo-ts/logic';
@@ -52,6 +65,10 @@ export interface TurnCommandTable {
interface CommandEnv {
develCost: number;
trainDelta: number;
atmosDelta: number;
maxTrainByCommand: number;
maxAtmosByCommand: number;
sabotageDefaultProb: number;
sabotageProbCoefByStat: number;
sabotageDefenceCoefByGeneralCount: number;
@@ -65,6 +82,7 @@ interface CommandEnv {
defaultSpecialDomestic: string | null;
defaultSpecialWar: string | null;
initialNationGenLimit: number;
maxTechLevel: number;
baseGold: number;
baseRice: number;
maxResourceActionAmount: number;
@@ -197,6 +215,14 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
['develCost', 'develcost', 'develrate'],
0
),
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0),
maxAtmosByCommand: resolveNumber(
constValues,
['maxAtmosByCommand'],
0
),
sabotageDefaultProb: resolveNumber(
constValues,
['sabotageDefaultProb'],
@@ -260,6 +286,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
['initialNationGenLimit'],
0
),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
maxResourceActionAmount: resolveNumber(
@@ -467,6 +494,70 @@ const buildEntries = (env: CommandEnv): {
reqArg: false,
args: {},
},
{
category: '개인',
definition: new RecoveryActionDefinition(),
reqArg: false,
args: {},
},
{
category: '전략',
definition: new UprisingActionDefinition(),
reqArg: false,
args: {},
},
{
category: '전략',
definition: new AppointmentActionDefinition(),
reqArg: true,
args: { destNationId: 0 },
},
{
category: '전략',
definition: new FoundingActionDefinition(),
reqArg: false,
args: {},
},
{
category: '군사',
definition: new TrainingActionDefinition({
trainDelta: env.trainDelta,
maxTrainByCommand: env.maxTrainByCommand,
}),
reqArg: false,
args: {},
},
{
category: '군사',
definition: new BoostMoraleActionDefinition({
atmosDelta: env.atmosDelta,
maxAtmosByCommand: env.maxAtmosByCommand,
}),
reqArg: false,
args: {},
},
{
category: '군사',
definition: new DispatchActionDefinition(),
reqArg: true,
args: { destCityId: 0 },
},
{
category: '내정',
definition: new ResidentsSelectionActionDefinition({
develCost: env.develCost,
}),
reqArg: false,
args: {},
},
{
category: '내정',
definition: new FarmingActionDefinition({
develCost: env.develCost,
}),
reqArg: false,
args: {},
},
{
category: '내정',
definition: new CommerceInvestmentActionDefinition(emptyModules, {
@@ -475,6 +566,39 @@ const buildEntries = (env: CommandEnv): {
reqArg: false,
args: {},
},
{
category: '내정',
definition: new TechResearchActionDefinition({
costGold: env.develCost,
maxTechLevel: env.maxTechLevel,
}),
reqArg: false,
args: {},
},
{
category: '내정',
definition: new SecurityUpgradeActionDefinition({
develCost: env.develCost,
}),
reqArg: false,
args: {},
},
{
category: '내정',
definition: new DefenceUpgradeActionDefinition({
develCost: env.develCost,
}),
reqArg: false,
args: {},
},
{
category: '내정',
definition: new WallRepairActionDefinition({
develCost: env.develCost,
}),
reqArg: false,
args: {},
},
{
category: '내정',
definition: new RecruitActionDefinition(emptyModules, {}),
@@ -539,6 +663,12 @@ const buildEntries = (env: CommandEnv): {
reqArg: false,
args: {},
},
{
category: '외교',
definition: new DeclarationActionDefinition(),
reqArg: true,
args: { destNationId: 0 },
},
{
category: '인사',
definition: assignmentDefinition,
@@ -13,16 +13,29 @@ import type {
UnitSetDefinition,
} from '@sammo-ts/logic';
import {
AppointmentActionDefinition,
AssignmentActionDefinition,
BoostMoraleActionDefinition,
AwardActionDefinition,
CommerceInvestmentActionDefinition,
DeclarationActionDefinition,
DefenceUpgradeActionDefinition,
DispatchActionDefinition,
evaluateConstraints,
FireAttackActionDefinition,
FarmingActionDefinition,
FoundingActionDefinition,
NationRestActionDefinition,
RecoveryActionDefinition,
ResidentsSelectionActionDefinition,
RecruitActionDefinition,
resolveGeneralAction,
RestActionDefinition,
SecurityUpgradeActionDefinition,
TechResearchActionDefinition,
TalentScoutActionDefinition,
TrainingActionDefinition,
UprisingActionDefinition,
VolunteerRecruitActionDefinition,
} from '@sammo-ts/logic';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
@@ -38,6 +51,10 @@ import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
interface CommandEnv {
develCost: number;
trainDelta: number;
atmosDelta: number;
maxTrainByCommand: number;
maxAtmosByCommand: number;
sabotageDefaultProb: number;
sabotageProbCoefByStat: number;
sabotageDefenceCoefByGeneralCount: number;
@@ -51,6 +68,7 @@ interface CommandEnv {
defaultSpecialDomestic: string | null;
defaultSpecialWar: string | null;
initialNationGenLimit: number;
maxTechLevel: number;
baseGold: number;
baseRice: number;
maxResourceActionAmount: number;
@@ -125,6 +143,14 @@ const buildCommandEnv = (
['develCost', 'develcost', 'develrate'],
0
),
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0),
maxAtmosByCommand: resolveNumber(
constValues,
['maxAtmosByCommand'],
0
),
sabotageDefaultProb: resolveNumber(
constValues,
['sabotageDefaultProb'],
@@ -188,6 +214,7 @@ const buildCommandEnv = (
['initialNationGenLimit'],
0
),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
maxResourceActionAmount: resolveNumber(
@@ -421,10 +448,56 @@ const buildGeneralDefinitions = (
env: CommandEnv
): Map<string, GeneralActionDefinition> => {
const definitions = new Map<string, GeneralActionDefinition>();
definitions.set('che_거병', new UprisingActionDefinition());
definitions.set('che_임관', new AppointmentActionDefinition());
definitions.set('che_건국', new FoundingActionDefinition());
definitions.set(
'che_훈련',
new TrainingActionDefinition({
trainDelta: env.trainDelta,
maxTrainByCommand: env.maxTrainByCommand,
})
);
definitions.set(
'che_사기진작',
new BoostMoraleActionDefinition({
atmosDelta: env.atmosDelta,
maxAtmosByCommand: env.maxAtmosByCommand,
})
);
definitions.set('che_요양', new RecoveryActionDefinition());
definitions.set('che_출병', new DispatchActionDefinition());
definitions.set(
'che_주민선정',
new ResidentsSelectionActionDefinition({ develCost: env.develCost })
);
definitions.set(
'che_농지개간',
new FarmingActionDefinition({ develCost: env.develCost })
);
definitions.set(
'che_상업투자',
new CommerceInvestmentActionDefinition([], env)
);
definitions.set(
'che_기술연구',
new TechResearchActionDefinition({
costGold: env.develCost,
maxTechLevel: env.maxTechLevel,
})
);
definitions.set(
'che_치안강화',
new SecurityUpgradeActionDefinition({ develCost: env.develCost })
);
definitions.set(
'che_수비강화',
new DefenceUpgradeActionDefinition({ develCost: env.develCost })
);
definitions.set(
'che_성벽보수',
new WallRepairActionDefinition({ develCost: env.develCost })
);
definitions.set('che_화계', new FireAttackActionDefinition([], env));
definitions.set('che_인재탐색', new TalentScoutActionDefinition([], env));
definitions.set('che_의병모집', new VolunteerRecruitActionDefinition([], env));
@@ -442,6 +515,7 @@ const buildNationDefinitions = (
? env.maxResourceActionAmount
: Math.max(env.baseGold, env.baseRice, 1000);
definitions.set('휴식', new NationRestActionDefinition());
definitions.set('che_선전포고', new DeclarationActionDefinition());
definitions.set(
'che_포상',
new AwardActionDefinition({
@@ -0,0 +1,55 @@
import type { GeneralTriggerState, TriggerValue } from '../../../domain/entities.js';
import type { Constraint, ConstraintContext } from '../../../constraints/types.js';
import { beNeutral } from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import { createGeneralPatchEffect, createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface UprisingArgs {}
const ACTION_NAME = '거병';
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, UprisingArgs> {
public readonly key = 'che_거병';
public readonly name = ACTION_NAME;
parseArgs(_raw: unknown): UprisingArgs | null {
void _raw;
return {};
}
buildConstraints(_ctx: ConstraintContext, _args: UprisingArgs): Constraint[] {
return [beNeutral()];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: UprisingArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const meta = {
...general.meta,
uprising: true as TriggerValue,
};
return {
effects: [
createGeneralPatchEffect<TriggerState>(
{ meta } as Partial<typeof general>,
general.id
),
createLogEffect(`${ACTION_NAME}을 준비했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
}
@@ -0,0 +1,55 @@
import type { GeneralTriggerState, TriggerValue } from '../../../domain/entities.js';
import type { Constraint, ConstraintContext } from '../../../constraints/types.js';
import { beNeutral } from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import { createGeneralPatchEffect, createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface FoundingArgs {}
const ACTION_NAME = '건국';
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, FoundingArgs> {
public readonly key = 'che_건국';
public readonly name = ACTION_NAME;
parseArgs(_raw: unknown): FoundingArgs | null {
void _raw;
return {};
}
buildConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] {
return [beNeutral()];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: FoundingArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const meta = {
...general.meta,
founding: true as TriggerValue,
};
return {
effects: [
createGeneralPatchEffect<TriggerState>(
{ meta } as Partial<typeof general>,
general.id
),
createLogEffect(`${ACTION_NAME}을 준비했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
}
@@ -0,0 +1,129 @@
import type {
General,
GeneralTriggerState,
Nation,
} from '../../../domain/entities.js';
import type {
Constraint,
ConstraintContext,
StateView,
} from '../../../constraints/types.js';
import {
notBeNeutral,
notWanderingNation,
occupiedCity,
reqGeneralGold,
suppliedCity,
} from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import {
createGeneralPatchEffect,
createLogEffect,
createNationPatchEffect,
} from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface TechResearchArgs {}
export interface TechResearchEnvironment {
costGold?: number;
techDelta?: number;
maxTechLevel?: number;
}
const ACTION_NAME = '기술 연구';
const DEFAULT_TECH_DELTA = 1;
const readTech = (nation: Nation | null | undefined): number => {
if (!nation) {
return 0;
}
const tech = nation.meta.tech;
return typeof tech === 'number' ? tech : 0;
};
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, TechResearchArgs> {
public readonly key = 'che_기술연구';
public readonly name = ACTION_NAME;
private readonly env: TechResearchEnvironment;
constructor(env: TechResearchEnvironment = {}) {
this.env = env;
}
parseArgs(_raw: unknown): TechResearchArgs | null {
void _raw;
return {};
}
buildConstraints(
_ctx: ConstraintContext,
_args: TechResearchArgs
): Constraint[] {
const getRequiredGold = (_context: ConstraintContext, _view: StateView): number =>
this.env.costGold ?? 0;
return [
notBeNeutral(),
notWanderingNation(),
occupiedCity(),
suppliedCity(),
reqGeneralGold(getRequiredGold),
];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: TechResearchArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation ?? null;
if (!nation) {
return {
effects: [
createLogEffect('국가 정보를 찾지 못했습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
const delta = this.env.techDelta ?? DEFAULT_TECH_DELTA;
const currentTech = readTech(nation);
const maxTech =
typeof this.env.maxTechLevel === 'number' &&
this.env.maxTechLevel > 0
? this.env.maxTechLevel
: currentTech + delta;
const nextTech = Math.min(currentTech + delta, maxTech);
const applied = nextTech - currentTech;
const costGold = this.env.costGold ?? 0;
const generalPatch: Partial<General<TriggerState>> = {
gold: Math.max(0, general.gold - costGold),
};
return {
effects: [
createNationPatchEffect(
{
meta: { ...nation.meta, tech: nextTech },
} as Partial<Nation>,
nation.id
),
createGeneralPatchEffect<TriggerState>(generalPatch, general.id),
createLogEffect(`${ACTION_NAME}로 기술이 ${applied} 상승했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
}
@@ -0,0 +1,20 @@
import type { GeneralTriggerState } from '../../../domain/entities.js';
import { CityDevelopmentActionDefinition } from './cityDevelopment.js';
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends CityDevelopmentActionDefinition<TriggerState> {
constructor(env: { develCost?: number; amount?: number } = {}) {
super(
{
key: 'che_농지개간',
name: '농지 개간',
statKey: 'agriculture',
maxKey: 'agricultureMax',
label: '농업',
baseAmount: 100,
},
env
);
}
}
@@ -0,0 +1,91 @@
import type {
General,
GeneralTriggerState,
} from '../../../domain/entities.js';
import type {
Constraint,
ConstraintContext,
StateView,
} from '../../../constraints/types.js';
import { notBeNeutral, reqGeneralGold } from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import { createGeneralPatchEffect, createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface BoostMoraleArgs {}
export interface BoostMoraleEnvironment {
atmosDelta?: number;
maxAtmosByCommand?: number;
costGold?: number;
}
const ACTION_NAME = '사기 진작';
const DEFAULT_ATMOS_DELTA = 5;
const DEFAULT_MAX_ATMOS = 100;
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, BoostMoraleArgs> {
public readonly key = 'che_사기진작';
public readonly name = ACTION_NAME;
private readonly env: BoostMoraleEnvironment;
constructor(env: BoostMoraleEnvironment = {}) {
this.env = env;
}
parseArgs(_raw: unknown): BoostMoraleArgs | null {
void _raw;
return {};
}
buildConstraints(
_ctx: ConstraintContext,
_args: BoostMoraleArgs
): Constraint[] {
const getRequiredGold = (_context: ConstraintContext, _view: StateView): number =>
this.env.costGold ?? 0;
return [notBeNeutral(), reqGeneralGold(getRequiredGold)];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: BoostMoraleArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const maxAtmos =
this.env.maxAtmosByCommand && this.env.maxAtmosByCommand > 0
? this.env.maxAtmosByCommand
: DEFAULT_MAX_ATMOS;
const delta =
this.env.atmosDelta && this.env.atmosDelta > 0
? this.env.atmosDelta
: DEFAULT_ATMOS_DELTA;
const nextAtmos = clamp(general.atmos + delta, 0, maxAtmos);
const applied = nextAtmos - general.atmos;
const costGold = this.env.costGold ?? 0;
const patch: Partial<General<TriggerState>> = {
atmos: nextAtmos,
gold: Math.max(0, general.gold - costGold),
};
return {
effects: [
createGeneralPatchEffect<TriggerState>(patch, general.id),
createLogEffect(`${ACTION_NAME}로 사기가 ${applied} 증가했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
}
@@ -0,0 +1,20 @@
import type { GeneralTriggerState } from '../../../domain/entities.js';
import { CityDevelopmentActionDefinition } from './cityDevelopment.js';
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends CityDevelopmentActionDefinition<TriggerState> {
constructor(env: { develCost?: number; amount?: number } = {}) {
super(
{
key: 'che_성벽보수',
name: '성벽 보수',
statKey: 'wall',
maxKey: 'wallMax',
label: '성벽',
baseAmount: 50,
},
env
);
}
}
@@ -0,0 +1,20 @@
import type { GeneralTriggerState } from '../../../domain/entities.js';
import { CityDevelopmentActionDefinition } from './cityDevelopment.js';
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends CityDevelopmentActionDefinition<TriggerState> {
constructor(env: { develCost?: number; amount?: number } = {}) {
super(
{
key: 'che_수비강화',
name: '수비 강화',
statKey: 'defence',
maxKey: 'defenceMax',
label: '수비',
baseAmount: 50,
},
env
);
}
}
@@ -0,0 +1,79 @@
import type {
General,
GeneralTriggerState,
} from '../../../domain/entities.js';
import type {
Constraint,
ConstraintContext,
StateView,
} from '../../../constraints/types.js';
import { notBeNeutral, reqGeneralGold } from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import { createGeneralPatchEffect, createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface RecoveryArgs {}
export interface RecoveryEnvironment {
injuryDelta?: number;
costGold?: number;
}
const ACTION_NAME = '요양';
const DEFAULT_INJURY_DELTA = 10;
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, RecoveryArgs> {
public readonly key = 'che_요양';
public readonly name = ACTION_NAME;
private readonly env: RecoveryEnvironment;
constructor(env: RecoveryEnvironment = {}) {
this.env = env;
}
parseArgs(_raw: unknown): RecoveryArgs | null {
void _raw;
return {};
}
buildConstraints(
_ctx: ConstraintContext,
_args: RecoveryArgs
): Constraint[] {
const getRequiredGold = (_context: ConstraintContext, _view: StateView): number =>
this.env.costGold ?? 0;
return [notBeNeutral(), reqGeneralGold(getRequiredGold)];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: RecoveryArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const delta = this.env.injuryDelta ?? DEFAULT_INJURY_DELTA;
const nextInjury = Math.max(0, general.injury - delta);
const applied = general.injury - nextInjury;
const costGold = this.env.costGold ?? 0;
const patch: Partial<General<TriggerState>> = {
injury: nextInjury,
gold: Math.max(0, general.gold - costGold),
};
return {
effects: [
createGeneralPatchEffect<TriggerState>(patch, general.id),
createLogEffect(`${ACTION_NAME}으로 부상이 ${applied} 회복되었습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
}
@@ -0,0 +1,65 @@
import type { GeneralTriggerState } from '../../../domain/entities.js';
import type {
Constraint,
ConstraintContext,
} from '../../../constraints/types.js';
import { beNeutral, existsDestNation } from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import { createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface AppointmentArgs {
destNationId: number;
}
const ACTION_NAME = '임관';
const parseNationId = (raw: unknown): number | null => {
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
}
return raw > 0 ? Math.floor(raw) : null;
};
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, AppointmentArgs> {
public readonly key = 'che_임관';
public readonly name = ACTION_NAME;
parseArgs(raw: unknown): AppointmentArgs | null {
const data = raw as { destNationId?: unknown };
const destNationId = parseNationId(data?.destNationId);
if (destNationId === null) {
return null;
}
return { destNationId };
}
buildConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] {
return [beNeutral(), existsDestNation()];
}
resolve(
_context: GeneralActionResolveContext<TriggerState>,
args: AppointmentArgs
): GeneralActionOutcome<TriggerState> {
void _context;
return {
effects: [
createLogEffect(
`${ACTION_NAME}을 신청했습니다. (국가 ${args.destNationId})`,
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}
),
],
};
}
}
@@ -0,0 +1,20 @@
import type { GeneralTriggerState } from '../../../domain/entities.js';
import { CityDevelopmentActionDefinition } from './cityDevelopment.js';
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends CityDevelopmentActionDefinition<TriggerState> {
constructor(env: { develCost?: number; amount?: number } = {}) {
super(
{
key: 'che_주민선정',
name: '주민 선정',
statKey: 'population',
maxKey: 'populationMax',
label: '인구',
baseAmount: 1000,
},
env
);
}
}
@@ -0,0 +1,71 @@
import type { GeneralTriggerState } from '../../../domain/entities.js';
import type { Constraint, ConstraintContext } from '../../../constraints/types.js';
import {
existsDestCity,
notBeNeutral,
notOccupiedDestCity,
occupiedCity,
suppliedCity,
} from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import { createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface DispatchArgs {
destCityId: number;
}
const ACTION_NAME = '출병';
const parseCityId = (raw: unknown): number | null => {
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
}
return raw > 0 ? Math.floor(raw) : null;
};
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, DispatchArgs> {
public readonly key = 'che_출병';
public readonly name = ACTION_NAME;
parseArgs(raw: unknown): DispatchArgs | null {
const data = raw as { destCityId?: unknown };
const destCityId = parseCityId(data?.destCityId);
if (destCityId === null) {
return null;
}
return { destCityId };
}
buildConstraints(_ctx: ConstraintContext, _args: DispatchArgs): Constraint[] {
return [
notBeNeutral(),
occupiedCity(),
suppliedCity(),
existsDestCity(),
notOccupiedDestCity(),
];
}
resolve(
_context: GeneralActionResolveContext<TriggerState>,
args: DispatchArgs
): GeneralActionOutcome<TriggerState> {
void _context;
return {
effects: [
createLogEffect(`${ACTION_NAME}을 준비했습니다. (목표 도시 ${args.destCityId})`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
}
@@ -0,0 +1,20 @@
import type { GeneralTriggerState } from '../../../domain/entities.js';
import { CityDevelopmentActionDefinition } from './cityDevelopment.js';
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends CityDevelopmentActionDefinition<TriggerState> {
constructor(env: { develCost?: number; amount?: number } = {}) {
super(
{
key: 'che_치안강화',
name: '치안 강화',
statKey: 'security',
maxKey: 'securityMax',
label: '치안',
baseAmount: 50,
},
env
);
}
}
@@ -0,0 +1,91 @@
import type {
General,
GeneralTriggerState,
} from '../../../domain/entities.js';
import type {
Constraint,
ConstraintContext,
StateView,
} from '../../../constraints/types.js';
import { notBeNeutral, reqGeneralGold } from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import { createGeneralPatchEffect, createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface TrainingArgs {}
export interface TrainingEnvironment {
trainDelta?: number;
maxTrainByCommand?: number;
costGold?: number;
}
const ACTION_NAME = '훈련';
const DEFAULT_TRAIN_DELTA = 5;
const DEFAULT_MAX_TRAIN = 100;
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, TrainingArgs> {
public readonly key = 'che_훈련';
public readonly name = ACTION_NAME;
private readonly env: TrainingEnvironment;
constructor(env: TrainingEnvironment = {}) {
this.env = env;
}
parseArgs(_raw: unknown): TrainingArgs | null {
void _raw;
return {};
}
buildConstraints(
_ctx: ConstraintContext,
_args: TrainingArgs
): Constraint[] {
const getRequiredGold = (_context: ConstraintContext, _view: StateView): number =>
this.env.costGold ?? 0;
return [notBeNeutral(), reqGeneralGold(getRequiredGold)];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: TrainingArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const maxTrain =
this.env.maxTrainByCommand && this.env.maxTrainByCommand > 0
? this.env.maxTrainByCommand
: DEFAULT_MAX_TRAIN;
const delta =
this.env.trainDelta && this.env.trainDelta > 0
? this.env.trainDelta
: DEFAULT_TRAIN_DELTA;
const nextTrain = clamp(general.train + delta, 0, maxTrain);
const applied = nextTrain - general.train;
const costGold = this.env.costGold ?? 0;
const patch: Partial<General<TriggerState>> = {
train: nextTrain,
gold: Math.max(0, general.gold - costGold),
};
return {
effects: [
createGeneralPatchEffect<TriggerState>(patch, general.id),
createLogEffect(`${ACTION_NAME}을 통해 훈련도가 ${applied} 증가했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
}
@@ -0,0 +1,150 @@
import type {
City,
General,
GeneralTriggerState,
} from '../../../domain/entities.js';
import type {
Constraint,
ConstraintContext,
StateView,
} from '../../../constraints/types.js';
import {
notBeNeutral,
notWanderingNation,
occupiedCity,
remainCityCapacityByMax,
reqGeneralGold,
suppliedCity,
} from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import {
createCityPatchEffect,
createGeneralPatchEffect,
createLogEffect,
} from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface CityDevelopmentArgs {}
export interface CityDevelopmentEnvironment {
develCost?: number;
amount?: number;
}
export interface CityDevelopmentConfig {
key: string;
name: string;
statKey: keyof City;
maxKey: keyof City;
label: string;
baseAmount: number;
}
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
const readNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
export class CityDevelopmentActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, CityDevelopmentArgs> {
public readonly key: string;
public readonly name: string;
private readonly config: CityDevelopmentConfig;
private readonly env: CityDevelopmentEnvironment;
constructor(config: CityDevelopmentConfig, env: CityDevelopmentEnvironment) {
this.key = config.key;
this.name = config.name;
this.config = config;
this.env = env;
}
parseArgs(_raw: unknown): CityDevelopmentArgs | null {
void _raw;
return {};
}
buildConstraints(
_ctx: ConstraintContext,
_args: CityDevelopmentArgs
): Constraint[] {
const getRequiredGold = (_context: ConstraintContext, _view: StateView): number =>
this.env.develCost ?? 0;
return [
notBeNeutral(),
notWanderingNation(),
occupiedCity(),
suppliedCity(),
remainCityCapacityByMax(
String(this.config.statKey),
String(this.config.maxKey),
this.config.label
),
reqGeneralGold(getRequiredGold),
];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: CityDevelopmentArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const city = context.city;
if (!city) {
return {
effects: [
createLogEffect('도시 정보를 찾지 못했습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
const baseAmount = this.env.amount ?? this.config.baseAmount;
const current = readNumber(city[this.config.statKey]);
const max = readNumber(city[this.config.maxKey]);
if (current === null || max === null) {
return {
effects: [
createLogEffect('도시 정보를 찾지 못했습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
const nextValue = clamp(current + baseAmount, 0, max);
const costGold = this.env.develCost ?? 0;
const generalPatch: Partial<General<TriggerState>> = {
gold: Math.max(0, general.gold - costGold),
};
const logMessage = `${this.config.label}${nextValue - current} 증가했습니다.`;
return {
effects: [
createCityPatchEffect(
{ [this.config.statKey]: nextValue } as Partial<City>,
city.id
),
createGeneralPatchEffect<TriggerState>(generalPatch, general.id),
createLogEffect(logMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
}
@@ -1,12 +1,38 @@
export type GeneralTurnCommandKey =
| 'che_거병'
| 'che_임관'
| 'che_건국'
| 'che_훈련'
| 'che_사기진작'
| 'che_요양'
| 'che_출병'
| 'che_주민선정'
| 'che_농지개간'
| 'che_상업투자'
| 'che_기술연구'
| 'che_치안강화'
| 'che_수비강화'
| 'che_성벽보수'
| 'che_화계'
| 'che_인재탐색'
| 'che_의병모집'
| 'che_징병'
| '휴식';
import type * as UprisingModule from './che_거병.js';
import type * as AppointmentModule from './che_임관.js';
import type * as FoundingModule from './che_건국.js';
import type * as TrainingModule from './che_훈련.js';
import type * as BoostMoraleModule from './che_사기진작.js';
import type * as RecoveryModule from './che_요양.js';
import type * as DispatchModule from './che_출병.js';
import type * as ResidentsSelectionModule from './che_주민선정.js';
import type * as FarmingModule from './che_농지개간.js';
import type * as CommerceInvestmentModule from './che_상업투자.js';
import type * as TechResearchModule from './che_기술연구.js';
import type * as SecurityUpgradeModule from './che_치안강화.js';
import type * as DefenceUpgradeModule from './che_수비강화.js';
import type * as WallRepairModule from './che_성벽보수.js';
import type * as FireAttackModule from './che_화계.js';
import type * as TalentScoutModule from './che_인재탐색.js';
import type * as VolunteerRecruitModule from './che_의병모집.js';
@@ -14,7 +40,20 @@ import type * as RecruitModule from './che_징병.js';
import type * as RestModule from './휴식.js';
export type GeneralTurnCommandModule =
| typeof UprisingModule
| typeof AppointmentModule
| typeof FoundingModule
| typeof TrainingModule
| typeof BoostMoraleModule
| typeof RecoveryModule
| typeof DispatchModule
| typeof ResidentsSelectionModule
| typeof FarmingModule
| typeof CommerceInvestmentModule
| typeof TechResearchModule
| typeof SecurityUpgradeModule
| typeof DefenceUpgradeModule
| typeof WallRepairModule
| typeof FireAttackModule
| typeof TalentScoutModule
| typeof VolunteerRecruitModule
@@ -27,7 +66,20 @@ const defaultImporters: Record<
GeneralTurnCommandKey,
GeneralTurnCommandImporter
> = {
che_거병: async () => import('./che_거병.js'),
che_임관: async () => import('./che_임관.js'),
che_건국: async () => import('./che_건국.js'),
che_훈련: async () => import('./che_훈련.js'),
che_사기진작: async () => import('./che_사기진작.js'),
che_요양: async () => import('./che_요양.js'),
che_출병: async () => import('./che_출병.js'),
che_주민선정: async () => import('./che_주민선정.js'),
che_농지개간: async () => import('./che_농지개간.js'),
che_상업투자: async () => import('./che_상업투자.js'),
che_기술연구: async () => import('./che_기술연구.js'),
che_치안강화: async () => import('./che_치안강화.js'),
che_수비강화: async () => import('./che_수비강화.js'),
che_성벽보수: async () => import('./che_성벽보수.js'),
che_화계: async () => import('./che_화계.js'),
che_인재탐색: async () => import('./che_인재탐색.js'),
che_의병모집: async () => import('./che_의병모집.js'),
@@ -54,11 +106,50 @@ export class GeneralTurnCommandLoader {
}
}
export {
ActionDefinition as UprisingActionDefinition,
} from './che_거병.js';
export {
ActionDefinition as AppointmentActionDefinition,
} from './che_임관.js';
export {
ActionDefinition as FoundingActionDefinition,
} from './che_건국.js';
export {
ActionDefinition as TrainingActionDefinition,
} from './che_훈련.js';
export {
ActionDefinition as BoostMoraleActionDefinition,
} from './che_사기진작.js';
export {
ActionDefinition as RecoveryActionDefinition,
} from './che_요양.js';
export {
ActionDefinition as DispatchActionDefinition,
} from './che_출병.js';
export {
ActionDefinition as ResidentsSelectionActionDefinition,
} from './che_주민선정.js';
export {
ActionDefinition as FarmingActionDefinition,
} from './che_농지개간.js';
export {
ActionDefinition as CommerceInvestmentActionDefinition,
ActionResolver as CommerceInvestmentActionResolver,
CommandResolver as CommerceInvestmentCommandResolver,
} from './che_상업투자.js';
export {
ActionDefinition as TechResearchActionDefinition,
} from './che_기술연구.js';
export {
ActionDefinition as SecurityUpgradeActionDefinition,
} from './che_치안강화.js';
export {
ActionDefinition as DefenceUpgradeActionDefinition,
} from './che_수비강화.js';
export {
ActionDefinition as WallRepairActionDefinition,
} from './che_성벽보수.js';
export {
ActionDefinition as FireAttackActionDefinition,
ActionResolver as FireAttackActionResolver,
@@ -0,0 +1,74 @@
import type { GeneralTriggerState } from '../../../domain/entities.js';
import type { Constraint, ConstraintContext } from '../../../constraints/types.js';
import {
beChief,
existsDestNation,
notBeNeutral,
occupiedCity,
suppliedCity,
} from '../../../constraints/presets.js';
import type { GeneralActionDefinition } from '../../definition.js';
import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '../../engine.js';
import { createLogEffect } from '../../engine.js';
import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js';
export interface DeclareWarArgs {
destNationId: number;
}
const ACTION_NAME = '선전포고';
const parseNationId = (raw: unknown): number | null => {
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
}
return raw > 0 ? Math.floor(raw) : null;
};
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, DeclareWarArgs> {
public readonly key = 'che_선전포고';
public readonly name = ACTION_NAME;
parseArgs(raw: unknown): DeclareWarArgs | null {
const data = raw as { destNationId?: unknown };
const destNationId = parseNationId(data?.destNationId);
if (destNationId === null) {
return null;
}
return { destNationId };
}
buildConstraints(_ctx: ConstraintContext, _args: DeclareWarArgs): Constraint[] {
return [
notBeNeutral(),
occupiedCity(),
suppliedCity(),
beChief(),
existsDestNation(),
];
}
resolve(
_context: GeneralActionResolveContext<TriggerState>,
args: DeclareWarArgs
): GeneralActionOutcome<TriggerState> {
void _context;
return {
effects: [
createLogEffect(
`${ACTION_NAME}을 준비했습니다. (국가 ${args.destNationId})`,
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}
),
],
};
}
}
@@ -1,13 +1,19 @@
export type NationTurnCommandKey = '휴식' | 'che_포상' | 'che_발령';
export type NationTurnCommandKey =
| '휴식'
| 'che_포상'
| 'che_발령'
| 'che_선전포고';
import type * as NationRestModule from './휴식.js';
import type * as AwardModule from './che_포상.js';
import type * as AssignmentModule from './che_발령.js';
import type * as DeclarationModule from './che_선전포고.js';
export type NationTurnCommandModule =
| typeof NationRestModule
| typeof AwardModule
| typeof AssignmentModule;
| typeof AssignmentModule
| typeof DeclarationModule;
export type NationTurnCommandImporter = () => Promise<NationTurnCommandModule>;
@@ -18,6 +24,7 @@ const defaultImporters: Record<
휴식: async () => import('./휴식.js'),
che_포상: async () => import('./che_포상.js'),
che_발령: async () => import('./che_발령.js'),
che_선전포고: async () => import('./che_선전포고.js'),
};
export class NationTurnCommandLoader {
@@ -52,3 +59,6 @@ export {
ActionDefinition as AssignmentActionDefinition,
ActionResolver as AssignmentActionResolver,
} from './che_발령.js';
export {
ActionDefinition as DeclarationActionDefinition,
} from './che_선전포고.js';
+77
View File
@@ -174,6 +174,25 @@ export const notBeNeutral = (): Constraint => ({
},
});
export const beNeutral = (): Constraint => ({
name: 'BeNeutral',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
test: (ctx, view) => {
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
if (!view.has(req)) {
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
}
const general = view.get(req) as General | null;
if (!general) {
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
}
if (general.nationId === 0) {
return allow();
}
return { kind: 'deny', reason: '이미 국가에 속해 있습니다.' };
},
});
export const alwaysFail = (reason: string): Constraint => ({
name: 'AlwaysFail',
requires: () => [],
@@ -561,6 +580,36 @@ export const remainCityCapacity = (
},
});
export const remainCityCapacityByMax = (
key: string,
maxKey: string,
label: string
): Constraint => ({
name: 'RemainCityCapacityByMax',
requires: (ctx) =>
ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : [],
test: (ctx, view) => {
const city = readCity(view, ctx.cityId);
if (!city) {
if (ctx.cityId === undefined) {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
}
const req: RequirementKey = { kind: 'city', id: ctx.cityId };
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
}
const record = city as unknown as Record<string, number | undefined>;
const current = record[key];
const max = record[maxKey];
if (current === undefined || max === undefined) {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
}
if (current < max) {
return allow();
}
return { kind: 'deny', reason: `${label}이 충분합니다.` };
},
});
export const reqCityCapacity = (
key: string,
label: string,
@@ -689,6 +738,34 @@ export const existsDestCity = (): Constraint => ({
},
});
export const existsDestNation = (): Constraint => ({
name: 'ExistsDestNation',
requires: (ctx) =>
resolveDestNationId(ctx) !== undefined
? [
{
kind: 'destNation',
id: resolveDestNationId(ctx) ?? 0,
},
]
: [],
test: (ctx, view) => {
const destNationId = resolveDestNationId(ctx);
if (destNationId === undefined) {
return unknownOrDeny(ctx, [], '국가 정보가 없습니다.');
}
const req: RequirementKey = { kind: 'destNation', id: destNationId };
if (!view.has(req)) {
return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.');
}
const nation = view.get(req) as Nation | null;
if (!nation) {
return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.');
}
return allow();
},
});
export const existsDestGeneral = (): Constraint => ({
name: 'ExistsDestGeneral',
requires: (ctx) =>