feat: Add new strategic actions for nation turns

- Implemented '백성동원' (Mobilize People) action to enhance city defense and gain experience for generals.
- Added '수몰' (Flood) action to damage enemy city defenses and affect diplomatic relations.
- Created '이호경식' (Degrade Relations) action to manipulate diplomatic states between nations.
- Introduced '필사즉생' (Desperate Fight) action to boost training and atmosphere for generals in a nation.
- Developed '허보' (Deception) action to mislead enemy generals and alter their city assignments.
This commit is contained in:
2026-01-04 06:38:20 +00:00
parent c23b370f40
commit 436a55297e
14 changed files with 1811 additions and 2 deletions
+2
View File
@@ -129,6 +129,8 @@ class MemoryStateView implements StateView {
return `destNation:${req.id}`;
case 'diplomacy':
return `diplomacy:${req.srcNationId}:${req.destNationId}`;
case 'diplomacyList':
return 'diplomacy:list';
case 'arg':
return `arg:${req.key}`;
case 'env':
@@ -351,6 +351,8 @@ class WorldStateView implements StateView {
req.srcNationId,
req.destNationId
);
case 'diplomacyList':
return this.world.listDiplomacy();
case 'arg':
return this.args[req.key] ?? null;
case 'env':
@@ -37,6 +37,14 @@ export interface ActionContextWorldRef {
toNationId: number;
state: number;
}>;
getDiplomacyEntry(fromNationId: number, toNationId: number): {
fromNationId: number;
toNationId: number;
state: number;
term: number;
dead?: number;
meta?: Record<string, unknown>;
} | null;
getGeneralById(id: number): ActionContextGeneral | null;
getCityById(id: number): City | null;
getNationById(id: number): Nation | null;
@@ -0,0 +1,289 @@
import type {
General,
GeneralTriggerState,
Nation,
} from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowDiplomacyWithTerm,
availableStrategicCommand,
beChief,
existsDestNation,
occupiedCity,
} from '@sammo-ts/logic/constraints/presets.js';
import {
GeneralActionPipeline,
type GeneralActionModule,
} from '@sammo-ts/logic/triggers/general-action.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
GeneralActionResolver,
} from '@sammo-ts/logic/actions/engine.js';
import {
createDiplomacyPatchEffect,
createLogEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { buildDefaultDiplomacy } from '../../../diplomacy/index.js';
import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js';
export interface RaidArgs {
destNationId: number;
}
export interface RaidResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionResolveContext<TriggerState> {
destNation: Nation;
diplomacy: { state: number; term: number };
reverseDiplomacy: { state: number; term: number };
friendlyGenerals: Array<General<TriggerState>>;
destNationGenerals: Array<General<TriggerState>>;
}
const ACTION_NAME = '급습';
const DEFAULT_GLOBAL_DELAY = 9;
const PRE_REQ_TURN = 0;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
const TERM_REDUCE = 3;
const parseNationId = (raw: unknown): number | null => {
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
}
const value = Math.floor(raw);
return value > 0 ? value : null;
};
// 급습 쿨타임 계산을 담당한다.
export class CommandResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.pipeline = new GeneralActionPipeline(modules);
}
getGlobalDelay(context: RaidResolveContext<TriggerState>): number {
return Math.round(
this.pipeline.onCalcStrategic(
context,
ACTION_NAME,
'globalDelay',
DEFAULT_GLOBAL_DELAY
)
);
}
}
// 급습 실행 결과를 계산한다.
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionResolver<TriggerState, RaidArgs> {
readonly key = 'che_급습';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
}
resolve(
context: RaidResolveContext<TriggerState>,
_args: RaidArgs
): GeneralActionOutcome<TriggerState> {
void _args;
const { general, nation } = context;
const generalName = general.name;
const generalJosa = JosaUtil.pick(generalName, '이');
const nationName = nation?.name ?? '아국';
const destNationName = context.destNation.name;
const actionJosa = JosaUtil.pick(ACTION_NAME, '을');
const broadcastMessage = `<Y>${generalName}</>${generalJosa} <G><b>${destNationName}</b></>에 <M>${ACTION_NAME}</>${actionJosa} 발동하였습니다.`;
general.experience += EXP_DED_GAIN;
general.dedication += EXP_DED_GAIN;
context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH });
context.addLog(
`<D><b>${destNationName}</b></>에 <M>${ACTION_NAME}</>${actionJosa} 발동`,
{
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
);
const effects: Array<GeneralActionEffect<TriggerState>> = [
createDiplomacyPatchEffect(
general.nationId,
context.destNation.id,
{
term: context.diplomacy.term - TERM_REDUCE,
}
),
createDiplomacyPatchEffect(
context.destNation.id,
general.nationId,
{
term: context.reverseDiplomacy.term - TERM_REDUCE,
}
),
];
for (const target of context.friendlyGenerals) {
if (target.id === general.id) {
continue;
}
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: target.id,
format: LogFormat.PLAIN,
})
);
}
const destBroadcast = `아국에 <M>${ACTION_NAME}</>${JosaUtil.pick(ACTION_NAME, '이')} 발동되었습니다.`;
for (const target of context.destNationGenerals) {
effects.push(
createLogEffect(destBroadcast, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: target.id,
format: LogFormat.PLAIN,
})
);
}
if (nation) {
const globalDelay = this.command.getGlobalDelay(context);
nation.meta = {
...(nation.meta as object),
strategic_cmd_limit: globalDelay,
};
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: nation.id,
format: LogFormat.YEAR_MONTH,
})
);
}
effects.push(
createLogEffect(
`<D><b>${nationName}</b></>의 <Y>${generalName}</>${generalJosa} 아국에 <M>${ACTION_NAME}</>${actionJosa} 발동`,
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: context.destNation.id,
format: LogFormat.PLAIN,
}
)
);
return { effects };
}
}
// 급습 실행을 위한 정의/제약을 구성한다.
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<
TriggerState,
RaidArgs,
RaidResolveContext<TriggerState>
> {
public readonly key = 'che_급습';
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
}
parseArgs(raw: unknown): RaidArgs | null {
const data = raw as { destNationId?: unknown };
const destNationId = parseNationId(data?.destNationId);
if (destNationId === null) {
return null;
}
return { destNationId };
}
buildConstraints(_ctx: ConstraintContext, _args: RaidArgs): Constraint[] {
void _ctx;
void _args;
return [
occupiedCity(),
beChief(),
existsDestNation(),
allowDiplomacyWithTerm(
1,
12,
'선포 12개월 이상인 상대국에만 가능합니다.'
),
availableStrategicCommand(),
];
}
resolve(
context: RaidResolveContext<TriggerState>,
args: RaidArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
// 예약 턴 실행에 필요한 대상 국가/외교 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const destNationId = options.actionArgs.destNationId;
if (typeof destNationId !== 'number') {
return null;
}
const worldRef = options.worldRef;
if (!worldRef) {
return null;
}
const destNation = worldRef.getNationById(destNationId);
if (!destNation) {
return null;
}
const diplomacy =
worldRef.getDiplomacyEntry(base.general.nationId, destNationId) ??
buildDefaultDiplomacy(base.general.nationId, destNationId);
const reverseDiplomacy =
worldRef.getDiplomacyEntry(destNationId, base.general.nationId) ??
buildDefaultDiplomacy(destNationId, base.general.nationId);
const generals = worldRef.listGenerals();
const friendlyGenerals = generals.filter(
(general) => general.nationId === base.general.nationId
);
const destNationGenerals = generals.filter(
(general) => general.nationId === destNationId
);
return {
...base,
destNation,
diplomacy: { state: diplomacy.state, term: diplomacy.term },
reverseDiplomacy: { state: reverseDiplomacy.state, term: reverseDiplomacy.term },
friendlyGenerals,
destNationGenerals,
};
};
export const commandSpec: NationTurnCommandSpec = {
key: 'che_급습',
category: '외교',
reqArg: true,
args: { destNationId: 0 },
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? []),
};
@@ -0,0 +1,244 @@
import type {
City,
General,
GeneralTriggerState,
} from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
availableStrategicCommand,
beChief,
occupiedCity,
occupiedDestCity,
} from '@sammo-ts/logic/constraints/presets.js';
import {
GeneralActionPipeline,
type GeneralActionModule,
} from '@sammo-ts/logic/triggers/general-action.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
GeneralActionResolver,
} from '@sammo-ts/logic/actions/engine.js';
import { createCityPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js';
export interface MobilizePeopleArgs {
destCityId: number;
}
export interface MobilizePeopleResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionResolveContext<TriggerState> {
destCity: City;
friendlyGenerals: Array<General<TriggerState>>;
}
const ACTION_NAME = '백성동원';
const DEFAULT_GLOBAL_DELAY = 9;
const PRE_REQ_TURN = 0;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
const DEFENCE_RATE = 0.8;
const parseCityId = (raw: unknown): number | null => {
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
}
const value = Math.floor(raw);
return value > 0 ? value : null;
};
// 백성동원 쿨타임 계산을 담당한다.
export class CommandResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.pipeline = new GeneralActionPipeline(modules);
}
getGlobalDelay(context: MobilizePeopleResolveContext<TriggerState>): number {
return Math.round(
this.pipeline.onCalcStrategic(
context,
ACTION_NAME,
'globalDelay',
DEFAULT_GLOBAL_DELAY
)
);
}
}
// 백성동원 실행 결과를 계산한다.
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionResolver<TriggerState, MobilizePeopleArgs> {
readonly key = 'che_백성동원';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
}
resolve(
context: MobilizePeopleResolveContext<TriggerState>,
_args: MobilizePeopleArgs
): GeneralActionOutcome<TriggerState> {
void _args;
const { general, nation } = context;
const generalName = general.name;
const generalJosa = JosaUtil.pick(generalName, '이');
const cityName = context.destCity.name;
const broadcastMessage = `<Y>${generalName}</>${generalJosa} <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>을 하였습니다.`;
general.experience += EXP_DED_GAIN;
general.dedication += EXP_DED_GAIN;
context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH });
context.addLog(
`<G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>을 발동`,
{
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
);
const effects: Array<GeneralActionEffect<TriggerState>> = [];
for (const target of context.friendlyGenerals) {
if (target.id === general.id) {
continue;
}
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: target.id,
format: LogFormat.PLAIN,
})
);
}
const nextDefence = Math.max(
context.destCity.defence,
context.destCity.defenceMax * DEFENCE_RATE
);
const nextWall = Math.max(
context.destCity.wall,
context.destCity.wallMax * DEFENCE_RATE
);
effects.push(
createCityPatchEffect(
{
defence: nextDefence,
wall: nextWall,
},
context.destCity.id
)
);
if (nation) {
const globalDelay = this.command.getGlobalDelay(context);
nation.meta = {
...(nation.meta as object),
strategic_cmd_limit: globalDelay,
};
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: nation.id,
format: LogFormat.YEAR_MONTH,
})
);
}
return { effects };
}
}
// 백성동원 실행을 위한 정의/제약을 구성한다.
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<
TriggerState,
MobilizePeopleArgs,
MobilizePeopleResolveContext<TriggerState>
> {
public readonly key = 'che_백성동원';
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
}
parseArgs(raw: unknown): MobilizePeopleArgs | null {
const data = raw as { destCityId?: unknown };
const destCityId = parseCityId(data?.destCityId);
if (destCityId === null) {
return null;
}
return { destCityId };
}
buildConstraints(
_ctx: ConstraintContext,
_args: MobilizePeopleArgs
): Constraint[] {
void _ctx;
void _args;
return [
occupiedCity(),
beChief(),
occupiedDestCity(),
availableStrategicCommand(),
];
}
resolve(
context: MobilizePeopleResolveContext<TriggerState>,
args: MobilizePeopleArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
// 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const destCityId = options.actionArgs.destCityId;
if (typeof destCityId !== 'number') {
return null;
}
const worldRef = options.worldRef;
if (!worldRef) {
return null;
}
const destCity = worldRef.getCityById(destCityId);
if (!destCity) {
return null;
}
const friendlyGenerals = worldRef
.listGenerals()
.filter((general) => general.nationId === base.general.nationId);
return {
...base,
destCity,
friendlyGenerals,
};
};
export const commandSpec: NationTurnCommandSpec = {
key: 'che_백성동원',
category: '전략',
reqArg: true,
args: { destCityId: 0 },
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? []),
};
@@ -0,0 +1,276 @@
import type {
City,
General,
GeneralTriggerState,
Nation,
} from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowDiplomacyBetweenStatus,
availableStrategicCommand,
beChief,
notNeutralDestCity,
notOccupiedDestCity,
occupiedCity,
} from '@sammo-ts/logic/constraints/presets.js';
import {
GeneralActionPipeline,
type GeneralActionModule,
} from '@sammo-ts/logic/triggers/general-action.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
GeneralActionResolver,
} from '@sammo-ts/logic/actions/engine.js';
import { createCityPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js';
export interface FloodArgs {
destCityId: number;
}
export interface FloodResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionResolveContext<TriggerState> {
destCity: City;
destNation: Nation | null;
friendlyGenerals: Array<General<TriggerState>>;
destNationGenerals: Array<General<TriggerState>>;
}
const ACTION_NAME = '수몰';
const DEFAULT_GLOBAL_DELAY = 9;
const PRE_REQ_TURN = 2;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
const DAMAGE_RATE = 0.2;
const parseCityId = (raw: unknown): number | null => {
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
}
const value = Math.floor(raw);
return value > 0 ? value : null;
};
// 수몰 쿨타임 계산을 담당한다.
export class CommandResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.pipeline = new GeneralActionPipeline(modules);
}
getGlobalDelay(context: FloodResolveContext<TriggerState>): number {
return Math.round(
this.pipeline.onCalcStrategic(
context,
ACTION_NAME,
'globalDelay',
DEFAULT_GLOBAL_DELAY
)
);
}
}
// 수몰 실행 결과를 계산한다.
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionResolver<TriggerState, FloodArgs> {
readonly key = 'che_수몰';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
}
resolve(
context: FloodResolveContext<TriggerState>,
_args: FloodArgs
): GeneralActionOutcome<TriggerState> {
void _args;
const { general, nation } = context;
const generalName = general.name;
const generalJosa = JosaUtil.pick(generalName, '이');
const cityName = context.destCity.name;
const broadcastMessage = `<Y>${generalName}</>${generalJosa} <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>을 발동하였습니다.`;
const destBroadcastMessage = `<G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>이 발동되었습니다.`;
general.experience += EXP_DED_GAIN;
general.dedication += EXP_DED_GAIN;
context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH });
context.addLog(
`<G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>을 발동`,
{
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
);
const effects: Array<GeneralActionEffect<TriggerState>> = [];
for (const target of context.friendlyGenerals) {
if (target.id === general.id) {
continue;
}
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: target.id,
format: LogFormat.PLAIN,
})
);
}
for (const target of context.destNationGenerals) {
effects.push(
createLogEffect(destBroadcastMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: target.id,
format: LogFormat.PLAIN,
})
);
}
effects.push(
createCityPatchEffect(
{
defence: context.destCity.defence * DAMAGE_RATE,
wall: context.destCity.wall * DAMAGE_RATE,
},
context.destCity.id
)
);
if (nation) {
const globalDelay = this.command.getGlobalDelay(context);
nation.meta = {
...(nation.meta as object),
strategic_cmd_limit: globalDelay,
};
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: nation.id,
format: LogFormat.YEAR_MONTH,
})
);
}
if (context.destNation) {
effects.push(
createLogEffect(
`<D><b>${nation?.name ?? '상대국'}</b></>의 <Y>${generalName}</>${generalJosa} 아국의 <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>을 발동`,
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: context.destNation.id,
format: LogFormat.PLAIN,
}
)
);
}
return { effects };
}
}
// 수몰 실행을 위한 정의/제약을 구성한다.
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<
TriggerState,
FloodArgs,
FloodResolveContext<TriggerState>
> {
public readonly key = 'che_수몰';
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
}
parseArgs(raw: unknown): FloodArgs | null {
const data = raw as { destCityId?: unknown };
const destCityId = parseCityId(data?.destCityId);
if (destCityId === null) {
return null;
}
return { destCityId };
}
buildConstraints(
_ctx: ConstraintContext,
_args: FloodArgs
): Constraint[] {
void _ctx;
void _args;
return [
occupiedCity(),
beChief(),
notNeutralDestCity(),
notOccupiedDestCity(),
allowDiplomacyBetweenStatus([0], '교전중인 국가의 도시가 아닙니다.'),
availableStrategicCommand(),
];
}
resolve(
context: FloodResolveContext<TriggerState>,
args: FloodArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
// 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const destCityId = options.actionArgs.destCityId;
if (typeof destCityId !== 'number') {
return null;
}
const worldRef = options.worldRef;
if (!worldRef) {
return null;
}
const destCity = worldRef.getCityById(destCityId);
if (!destCity) {
return null;
}
const destNation = worldRef.getNationById(destCity.nationId);
const generals = worldRef.listGenerals();
const friendlyGenerals = generals.filter(
(general) => general.nationId === base.general.nationId
);
const destNationGenerals = generals.filter(
(general) => general.nationId === destCity.nationId
);
return {
...base,
destCity,
destNation: destNation ?? null,
friendlyGenerals,
destNationGenerals,
};
};
export const commandSpec: NationTurnCommandSpec = {
key: 'che_수몰',
category: '전략',
reqArg: true,
args: { destCityId: 0 },
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? []),
};
@@ -0,0 +1,305 @@
import type {
General,
GeneralTriggerState,
Nation,
} from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowDiplomacyBetweenStatus,
availableStrategicCommand,
beChief,
existsDestNation,
occupiedCity,
} from '@sammo-ts/logic/constraints/presets.js';
import {
GeneralActionPipeline,
type GeneralActionModule,
} from '@sammo-ts/logic/triggers/general-action.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
GeneralActionResolver,
} from '@sammo-ts/logic/actions/engine.js';
import {
createDiplomacyPatchEffect,
createLogEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import {
buildDefaultDiplomacy,
DIPLOMACY_STATE,
} from '../../../diplomacy/index.js';
import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js';
export interface DegradeRelationsArgs {
destNationId: number;
}
export interface DegradeRelationsResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionResolveContext<TriggerState> {
destNation: Nation;
diplomacy: { state: number; term: number };
reverseDiplomacy: { state: number; term: number };
friendlyGenerals: Array<General<TriggerState>>;
destNationGenerals: Array<General<TriggerState>>;
}
const ACTION_NAME = '이호경식';
const DEFAULT_GLOBAL_DELAY = 9;
const PRE_REQ_TURN = 0;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
const parseNationId = (raw: unknown): number | null => {
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
}
const value = Math.floor(raw);
return value > 0 ? value : null;
};
const resolveNextTerm = (state: number, term: number): number =>
state === DIPLOMACY_STATE.WAR ? 3 : term + 3;
// 이호경식 쿨타임 계산을 담당한다.
export class CommandResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.pipeline = new GeneralActionPipeline(modules);
}
getGlobalDelay(context: DegradeRelationsResolveContext<TriggerState>): number {
return Math.round(
this.pipeline.onCalcStrategic(
context,
ACTION_NAME,
'globalDelay',
DEFAULT_GLOBAL_DELAY
)
);
}
}
// 이호경식 실행 결과를 계산한다.
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionResolver<TriggerState, DegradeRelationsArgs> {
readonly key = 'che_이호경식';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
}
resolve(
context: DegradeRelationsResolveContext<TriggerState>,
_args: DegradeRelationsArgs
): GeneralActionOutcome<TriggerState> {
void _args;
const { general, nation } = context;
const generalName = general.name;
const generalJosa = JosaUtil.pick(generalName, '이');
const nationName = nation?.name ?? '아국';
const nationJosa = JosaUtil.pick(nationName, '이');
const destNationName = context.destNation.name;
const actionJosa = JosaUtil.pick(ACTION_NAME, '을');
const broadcastMessage = `<Y>${generalName}</>${generalJosa} <G><b>${destNationName}</b></>에 <M>${ACTION_NAME}</>${actionJosa} 발동하였습니다.`;
general.experience += EXP_DED_GAIN;
general.dedication += EXP_DED_GAIN;
context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH });
context.addLog(
`<D><b>${destNationName}</b></>에 <M>${ACTION_NAME}</>${actionJosa} 발동`,
{
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
);
const effects: Array<GeneralActionEffect<TriggerState>> = [
createDiplomacyPatchEffect(
general.nationId,
context.destNation.id,
{
state: DIPLOMACY_STATE.DECLARATION,
term: resolveNextTerm(
context.diplomacy.state,
context.diplomacy.term
),
}
),
createDiplomacyPatchEffect(
context.destNation.id,
general.nationId,
{
state: DIPLOMACY_STATE.DECLARATION,
term: resolveNextTerm(
context.reverseDiplomacy.state,
context.reverseDiplomacy.term
),
}
),
];
for (const target of context.friendlyGenerals) {
if (target.id === general.id) {
continue;
}
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: target.id,
format: LogFormat.PLAIN,
})
);
}
const destBroadcast = `<D><b>${nationName}</b></>${nationJosa} 아국에 <M>${ACTION_NAME}</>${actionJosa} 발동하였습니다.`;
for (const target of context.destNationGenerals) {
effects.push(
createLogEffect(destBroadcast, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: target.id,
format: LogFormat.PLAIN,
})
);
}
if (nation) {
const globalDelay = this.command.getGlobalDelay(context);
nation.meta = {
...(nation.meta as object),
strategic_cmd_limit: globalDelay,
};
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: nation.id,
format: LogFormat.YEAR_MONTH,
})
);
}
effects.push(
createLogEffect(
`<D><b>${nationName}</b></>의 <Y>${generalName}</>${generalJosa} 아국에 <M>${ACTION_NAME}</>${actionJosa} 발동`,
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: context.destNation.id,
format: LogFormat.PLAIN,
}
)
);
return { effects };
}
}
// 이호경식 실행을 위한 정의/제약을 구성한다.
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<
TriggerState,
DegradeRelationsArgs,
DegradeRelationsResolveContext<TriggerState>
> {
public readonly key = 'che_이호경식';
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
}
parseArgs(raw: unknown): DegradeRelationsArgs | null {
const data = raw as { destNationId?: unknown };
const destNationId = parseNationId(data?.destNationId);
if (destNationId === null) {
return null;
}
return { destNationId };
}
buildConstraints(
_ctx: ConstraintContext,
_args: DegradeRelationsArgs
): Constraint[] {
void _ctx;
void _args;
return [
occupiedCity(),
beChief(),
existsDestNation(),
allowDiplomacyBetweenStatus(
[0, 1],
'선포, 전쟁중인 상대국에게만 가능합니다.'
),
availableStrategicCommand(),
];
}
resolve(
context: DegradeRelationsResolveContext<TriggerState>,
args: DegradeRelationsArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
// 예약 턴 실행에 필요한 대상 국가/외교 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const destNationId = options.actionArgs.destNationId;
if (typeof destNationId !== 'number') {
return null;
}
const worldRef = options.worldRef;
if (!worldRef) {
return null;
}
const destNation = worldRef.getNationById(destNationId);
if (!destNation) {
return null;
}
const diplomacy =
worldRef.getDiplomacyEntry(base.general.nationId, destNationId) ??
buildDefaultDiplomacy(base.general.nationId, destNationId);
const reverseDiplomacy =
worldRef.getDiplomacyEntry(destNationId, base.general.nationId) ??
buildDefaultDiplomacy(destNationId, base.general.nationId);
const generals = worldRef.listGenerals();
const friendlyGenerals = generals.filter(
(general) => general.nationId === base.general.nationId
);
const destNationGenerals = generals.filter(
(general) => general.nationId === destNationId
);
return {
...base,
destNation,
diplomacy: { state: diplomacy.state, term: diplomacy.term },
reverseDiplomacy: { state: reverseDiplomacy.state, term: reverseDiplomacy.term },
friendlyGenerals,
destNationGenerals,
};
};
export const commandSpec: NationTurnCommandSpec = {
key: 'che_이호경식',
category: '외교',
reqArg: true,
args: { destNationId: 0 },
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? []),
};
@@ -0,0 +1,221 @@
import type {
General,
GeneralTriggerState,
} from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowDiplomacyStatus,
availableStrategicCommand,
beChief,
occupiedCity,
} from '@sammo-ts/logic/constraints/presets.js';
import {
GeneralActionPipeline,
type GeneralActionModule,
} from '@sammo-ts/logic/triggers/general-action.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
GeneralActionResolver,
} from '@sammo-ts/logic/actions/engine.js';
import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js';
export interface DesperateFightArgs {}
export interface DesperateFightResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionResolveContext<TriggerState> {
nationGenerals: Array<General<TriggerState>>;
}
const ACTION_NAME = '필사즉생';
const DEFAULT_GLOBAL_DELAY = 9;
const PRE_REQ_TURN = 2;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
const TRAIN_CAP = 100;
const ATMOS_CAP = 100;
// 필사즉생 쿨타임 계산을 담당한다.
export class CommandResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.pipeline = new GeneralActionPipeline(modules);
}
getGlobalDelay(context: DesperateFightResolveContext<TriggerState>): number {
return Math.round(
this.pipeline.onCalcStrategic(
context,
ACTION_NAME,
'globalDelay',
DEFAULT_GLOBAL_DELAY
)
);
}
}
// 필사즉생 실행 결과를 계산한다.
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionResolver<TriggerState, DesperateFightArgs> {
readonly key = 'che_필사즉생';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
}
resolve(
context: DesperateFightResolveContext<TriggerState>,
_args: DesperateFightArgs
): GeneralActionOutcome<TriggerState> {
void _args;
const { general, nation } = context;
const generalName = general.name;
const generalJosa = JosaUtil.pick(generalName, '이');
const broadcastMessage = `<Y>${generalName}</>${generalJosa} <M>${ACTION_NAME}</>을 발동하였습니다.`;
general.experience += EXP_DED_GAIN;
general.dedication += EXP_DED_GAIN;
context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH });
context.addLog(`<M>${ACTION_NAME}</>을 발동`, {
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
});
const effects: Array<GeneralActionEffect<TriggerState>> = [];
const updateTrainAtmos = (
target: General<TriggerState>
): { train: number; atmos: number } | null => {
const nextTrain = Math.max(target.train, TRAIN_CAP);
const nextAtmos = Math.max(target.atmos, ATMOS_CAP);
if (nextTrain === target.train && nextAtmos === target.atmos) {
return null;
}
return { train: nextTrain, atmos: nextAtmos };
};
const selfPatch = updateTrainAtmos(general);
if (selfPatch) {
general.train = selfPatch.train;
general.atmos = selfPatch.atmos;
}
for (const target of context.nationGenerals) {
if (target.id === general.id) {
continue;
}
const patch = updateTrainAtmos(target);
if (patch) {
effects.push(
createGeneralPatchEffect(patch, target.id)
);
}
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: target.id,
format: LogFormat.PLAIN,
})
);
}
if (nation) {
const globalDelay = this.command.getGlobalDelay(context);
nation.meta = {
...(nation.meta as object),
strategic_cmd_limit: globalDelay,
};
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: nation.id,
format: LogFormat.YEAR_MONTH,
})
);
}
return { effects };
}
}
// 필사즉생 실행을 위한 정의/제약을 구성한다.
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<
TriggerState,
DesperateFightArgs,
DesperateFightResolveContext<TriggerState>
> {
public readonly key = 'che_필사즉생';
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
}
parseArgs(_raw: unknown): DesperateFightArgs | null {
void _raw;
return {};
}
buildConstraints(
_ctx: ConstraintContext,
_args: DesperateFightArgs
): Constraint[] {
void _ctx;
void _args;
return [
occupiedCity(),
beChief(),
allowDiplomacyStatus([0], '전쟁중이 아닙니다.'),
availableStrategicCommand(),
];
}
resolve(
context: DesperateFightResolveContext<TriggerState>,
args: DesperateFightArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
// 예약 턴 실행에 필요한 국가 장수 목록을 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const worldRef = options.worldRef;
if (!worldRef) {
return null;
}
const nationGenerals = worldRef
.listGenerals()
.filter((entry) => entry.nationId === base.general.nationId);
return {
...base,
nationGenerals,
};
};
export const commandSpec: NationTurnCommandSpec = {
key: 'che_필사즉생',
category: '전략',
reqArg: false,
args: {},
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? []),
};
@@ -0,0 +1,304 @@
import type {
City,
General,
GeneralTriggerState,
Nation,
} from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowDiplomacyBetweenStatus,
availableStrategicCommand,
beChief,
notNeutralDestCity,
notOccupiedDestCity,
occupiedCity,
} from '@sammo-ts/logic/constraints/presets.js';
import {
GeneralActionPipeline,
type GeneralActionModule,
} from '@sammo-ts/logic/triggers/general-action.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
GeneralActionResolver,
} from '@sammo-ts/logic/actions/engine.js';
import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js';
export interface DeceptionArgs {
destCityId: number;
}
export interface DeceptionResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> extends GeneralActionResolveContext<TriggerState> {
destCity: City;
destNation: Nation | null;
destCityGenerals: Array<General<TriggerState>>;
friendlyGenerals: Array<General<TriggerState>>;
destNationSupplyCities: City[];
}
const ACTION_NAME = '허보';
const DEFAULT_GLOBAL_DELAY = 9;
const PRE_REQ_TURN = 1;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
const parseCityId = (raw: unknown): number | null => {
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return null;
}
const value = Math.floor(raw);
return value > 0 ? value : null;
};
const pickMoveCityId = (
rng: GeneralActionResolveContext['rng'],
destCityId: number,
candidates: City[]
): number => {
if (candidates.length === 0) {
return destCityId;
}
let idx = rng.nextInt(0, candidates.length);
let cityId = candidates[idx]?.id ?? destCityId;
if (cityId === destCityId && candidates.length > 1) {
idx = rng.nextInt(0, candidates.length);
cityId = candidates[idx]?.id ?? destCityId;
}
return cityId;
};
// 허보 쿨타임 계산을 담당한다.
export class CommandResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.pipeline = new GeneralActionPipeline(modules);
}
getGlobalDelay(context: DeceptionResolveContext<TriggerState>): number {
return Math.round(
this.pipeline.onCalcStrategic(
context,
ACTION_NAME,
'globalDelay',
DEFAULT_GLOBAL_DELAY
)
);
}
}
// 허보 실행 결과를 계산한다.
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionResolver<TriggerState, DeceptionArgs> {
readonly key = 'che_허보';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
}
resolve(
context: DeceptionResolveContext<TriggerState>,
_args: DeceptionArgs
): GeneralActionOutcome<TriggerState> {
void _args;
const { general, nation } = context;
const generalName = general.name;
const generalJosa = JosaUtil.pick(generalName, '이');
const cityName = context.destCity.name;
const broadcastMessage = `<Y>${generalName}</>${generalJosa} <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>를 발동하였습니다.`;
const destBroadcastMessage = `상대의 <M>${ACTION_NAME}</>에 당했다!`;
general.experience += EXP_DED_GAIN;
general.dedication += EXP_DED_GAIN;
context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH });
context.addLog(
`<G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>를 발동`,
{
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
);
const effects: Array<GeneralActionEffect<TriggerState>> = [];
for (const target of context.friendlyGenerals) {
if (target.id === general.id) {
continue;
}
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: target.id,
format: LogFormat.PLAIN,
})
);
}
for (const target of context.destCityGenerals) {
const moveCityId = pickMoveCityId(
context.rng,
context.destCity.id,
context.destNationSupplyCities
);
effects.push(
createLogEffect(destBroadcastMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: target.id,
format: LogFormat.PLAIN,
})
);
if (moveCityId !== target.cityId) {
effects.push(
createGeneralPatchEffect(
{ cityId: moveCityId },
target.id
)
);
}
}
if (nation) {
const globalDelay = this.command.getGlobalDelay(context);
nation.meta = {
...(nation.meta as object),
strategic_cmd_limit: globalDelay,
};
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: nation.id,
format: LogFormat.YEAR_MONTH,
})
);
}
if (context.destNation) {
effects.push(
createLogEffect(
`<D><b>${nation?.name ?? '상대국'}</b></>의 <Y>${generalName}</>${generalJosa} 아국의 <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>를 발동`,
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: context.destNation.id,
format: LogFormat.PLAIN,
}
)
);
}
return { effects };
}
}
// 허보 실행을 위한 정의/제약을 구성한다.
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState
> implements GeneralActionDefinition<TriggerState, DeceptionArgs, DeceptionResolveContext<TriggerState>> {
public readonly key = 'che_허보';
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
}
parseArgs(raw: unknown): DeceptionArgs | null {
const data = raw as { destCityId?: unknown };
const destCityId = parseCityId(data?.destCityId);
if (destCityId === null) {
return null;
}
return { destCityId };
}
buildConstraints(
_ctx: ConstraintContext,
_args: DeceptionArgs
): Constraint[] {
void _ctx;
void _args;
return [
occupiedCity(),
beChief(),
notNeutralDestCity(),
notOccupiedDestCity(),
allowDiplomacyBetweenStatus(
[0, 1],
'선포, 전쟁중인 상대국에게만 가능합니다.'
),
availableStrategicCommand(),
];
}
resolve(
context: DeceptionResolveContext<TriggerState>,
args: DeceptionArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
// 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const destCityId = options.actionArgs.destCityId;
if (typeof destCityId !== 'number') {
return null;
}
const worldRef = options.worldRef;
if (!worldRef) {
return null;
}
const destCity = worldRef.getCityById(destCityId);
if (!destCity) {
return null;
}
const destNation = worldRef.getNationById(destCity.nationId);
const generals = worldRef.listGenerals();
const destCityGenerals = generals.filter(
(general) =>
general.nationId === destCity.nationId &&
general.cityId === destCity.id
);
const friendlyGenerals = generals.filter(
(general) => general.nationId === base.general.nationId
);
const destNationSupplyCities = worldRef
.listCities()
.filter(
(city) =>
city.nationId === destCity.nationId && city.supplyState > 0
);
return {
...base,
destCity,
destNation: destNation ?? null,
destCityGenerals,
friendlyGenerals,
destNationSupplyCities,
};
};
export const commandSpec: NationTurnCommandSpec = {
key: 'che_허보',
category: '전략',
reqArg: true,
args: { destCityId: 0 },
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? []),
};
@@ -8,6 +8,12 @@ export const NATION_TURN_COMMAND_KEYS = [
'che_불가침제의',
'che_불가침파기제의',
'che_의병모집',
'che_허보',
'che_필사즉생',
'che_백성동원',
'che_이호경식',
'che_수몰',
'che_급습',
] as const;
export type NationTurnCommandKey =
@@ -32,6 +38,12 @@ const defaultImporters: Record<
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'),
};
export const isNationTurnCommandKey = (
+132
View File
@@ -9,6 +9,37 @@ import {
} from './helpers.js';
import type { Constraint, RequirementKey } from './types.js';
const readDiplomacyEntry = (
view: { has(req: RequirementKey): boolean; get(req: RequirementKey): unknown },
srcNationId: number,
destNationId: number
): { state: number | null; term: number | null } | null => {
const req: RequirementKey = {
kind: 'diplomacy',
srcNationId,
destNationId,
};
if (!view.has(req)) {
return null;
}
const value = view.get(req);
if (typeof value === 'number') {
return { state: value, term: null };
}
if (value && typeof value === 'object') {
const record = value as { state?: number; stateCode?: number; term?: number };
const state =
typeof record.state === 'number'
? record.state
: typeof record.stateCode === 'number'
? record.stateCode
: null;
const term = typeof record.term === 'number' ? record.term : null;
return { state, term };
}
return null;
};
export const disallowDiplomacyBetweenStatus = (
disallowList: Record<number, string>
): Constraint => ({
@@ -118,3 +149,104 @@ export const allowDiplomacyBetweenStatus = (
return allow();
},
});
export const allowDiplomacyWithTerm = (
requiredState: number,
minTerm: number,
reason: string
): Constraint => ({
name: 'AllowDiplomacyWithTerm',
requires: (ctx) => {
const reqs: RequirementKey[] = [];
if (ctx.nationId !== undefined) {
reqs.push({ kind: 'nation', id: ctx.nationId });
}
const destNationId = resolveDestNationId(ctx);
if (destNationId !== undefined) {
reqs.push({ kind: 'destNation', id: destNationId });
if (ctx.nationId !== undefined) {
reqs.push({
kind: 'diplomacy',
srcNationId: ctx.nationId,
destNationId,
});
}
}
const destCityId = resolveDestCityId(ctx);
if (destCityId !== undefined) {
reqs.push({ kind: 'destCity', id: destCityId });
}
return reqs;
},
test: (ctx, view) => {
const general = readGeneral(ctx, view);
const baseNationId = ctx.nationId ?? general?.nationId;
if (baseNationId === undefined) {
return unknownOrDeny(ctx, [], '국가 정보가 없습니다.');
}
const destCity = readDestCity(ctx, view);
const destNationId =
resolveDestNationId(ctx) ?? destCity?.nationId;
if (destNationId === undefined) {
return unknownOrDeny(ctx, [], '상대 국가 정보가 없습니다.');
}
const entry = readDiplomacyEntry(view, baseNationId, destNationId);
if (!entry || entry.state === null || entry.term === null) {
const req: RequirementKey = {
kind: 'diplomacy',
srcNationId: baseNationId,
destNationId,
};
return unknownOrDeny(ctx, [req], '외교 정보가 없습니다.');
}
if (entry.state !== requiredState || entry.term < minTerm) {
return { kind: 'deny', reason };
}
return allow();
},
});
export const allowDiplomacyStatus = (
allowList: number[],
reason: string
): Constraint => ({
name: 'AllowDiplomacyStatus',
requires: (ctx) => {
const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }];
if (ctx.nationId !== undefined) {
reqs.push({ kind: 'nation', id: ctx.nationId });
}
reqs.push({ kind: 'diplomacyList' });
return reqs;
},
test: (ctx, view) => {
const general = readGeneral(ctx, view);
const baseNationId = ctx.nationId ?? general?.nationId;
if (baseNationId === undefined) {
return unknownOrDeny(ctx, [], '국가 정보가 없습니다.');
}
const req: RequirementKey = { kind: 'diplomacyList' };
if (!view.has(req)) {
return unknownOrDeny(ctx, [req], '외교 정보가 없습니다.');
}
const list = view.get(req);
if (!Array.isArray(list)) {
return unknownOrDeny(ctx, [req], '외교 정보가 없습니다.');
}
const matched = list.some((entry) => {
if (!entry || typeof entry !== 'object') {
return false;
}
const record = entry as { fromNationId?: number; state?: number };
return (
record.fromNationId === baseNationId &&
typeof record.state === 'number' &&
allowList.includes(record.state)
);
});
if (!matched) {
return { kind: 'deny', reason };
}
return allow();
},
});
+1
View File
@@ -11,6 +11,7 @@ export type RequirementKey =
| { kind: 'destCity'; id: number }
| { kind: 'destNation'; id: number }
| { kind: 'diplomacy'; srcNationId: number; destNationId: number }
| { kind: 'diplomacyList' }
| { kind: 'arg'; key: string }
| { kind: 'env'; key: string };
+8 -1
View File
@@ -23,7 +23,14 @@ export type TriggerDomesticVarType =
| 'rice'
| 'probability';
export type TriggerStrategicActionType = '의병모집';
export type TriggerStrategicActionType =
| '의병모집'
| '허보'
| '필사즉생'
| '백성동원'
| '이호경식'
| '수몰'
| '급습';
export type TriggerStrategicVarType = 'delay' | 'globalDelay';
+7 -1
View File
@@ -26,6 +26,12 @@
"che_선전포고",
"che_불가침제의",
"che_불가침파기제의",
"che_의병모집"
"che_의병모집",
"che_허보",
"che_필사즉생",
"che_백성동원",
"che_이호경식",
"che_수몰",
"che_급습"
]
}