feat: add new general actions for border return, gifting, disbanding, founding nations, and fierce training

- Implemented '접경귀환' action for returning to a home city within 3 spaces.
- Added '증여' action for transferring resources between generals with constraints.
- Created '해산' action to disband a faction, resetting generals and cities.
- Developed '건국' action for founding a new nation with specified attributes.
- Introduced '맹훈련' action for training generals, enhancing their stats.
- Added tests for the new actions to ensure functionality and constraints are respected.
This commit is contained in:
2026-02-07 06:10:28 +00:00
parent ded90dfb08
commit 952c0d28c6
20 changed files with 2552 additions and 0 deletions
@@ -11,6 +11,14 @@ export const resolveConstraintEnv = (
): ConstraintEnv => {
const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined;
const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined;
const worldMeta = world.meta as Record<string, unknown>;
const rawKillturn = worldMeta.killturn;
const killturn =
typeof rawKillturn === 'number'
? rawKillturn
: typeof rawKillturn === 'string'
? Number(rawKillturn)
: undefined;
return {
currentYear: world.currentYear,
@@ -21,5 +29,6 @@ export const resolveConstraintEnv = (
relYear,
openingPartYear: env.openingPartYear,
minAvailableRecruitPop: env.minAvailableRecruitPop,
...(Number.isFinite(killturn) ? { killturn } : {}),
};
};
@@ -136,6 +136,20 @@ export const buildReservedTurnDefinitions = async (options: {
nation: Map<string, GeneralActionDefinition>;
}> => {
const itemModules = await loadItemModules([...ITEM_KEYS]);
options.env.itemCatalog = Object.fromEntries(
itemModules.map((item) => [
item.key,
{
slot: item.slot,
name: item.name,
rawName: item.rawName,
cost: item.cost,
reqSecu: item.reqSecu,
buyable: item.buyable,
unique: item.unique,
},
])
);
const itemRegistry = createItemModuleRegistry(itemModules);
const itemActionModules = createItemActionModules(itemRegistry);
const inheritBuffModules = createInheritBuffModules();
@@ -64,6 +64,13 @@ const resolveConstraintEnv = (
const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined;
const joinModeRaw = worldMeta.join_mode ?? worldMeta.joinMode;
const joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full';
const killturnRaw = worldMeta.killturn;
const killturn =
typeof killturnRaw === 'number'
? killturnRaw
: typeof killturnRaw === 'string'
? Number(killturnRaw)
: undefined;
return {
...env,
@@ -76,6 +83,7 @@ const resolveConstraintEnv = (
openingPartYear: env.openingPartYear,
minAvailableRecruitPop: env.minAvailableRecruitPop,
join_mode: joinMode,
...(Number.isFinite(killturn) ? { killturn } : {}),
};
};
@@ -1,6 +1,16 @@
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
export interface TurnCommandItemCatalogEntry {
slot: 'horse' | 'weapon' | 'book' | 'item';
name: string;
rawName: string;
cost: number | null;
reqSecu: number;
buyable: boolean;
unique: boolean;
}
export interface TurnCommandEnv {
develCost: number;
minAvailableRecruitPop?: number;
@@ -25,6 +35,7 @@ export interface TurnCommandEnv {
baseGold: number;
baseRice: number;
maxResourceActionAmount: number;
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
generalActionModules?: Array<GeneralActionModule>;
warActionModules?: Array<WarActionModule>;
}
@@ -0,0 +1,139 @@
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowRebellion,
beChief,
notBeNeutral,
notLord,
occupiedCity,
suppliedCity,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
} 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 { JosaUtil } from '@sammo-ts/common';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js';
const ACTION_NAME = '모반시도';
const ACTION_KEY = 'che_모반시도';
export interface RebellionArgs {}
export interface RebellionResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
nationGenerals?: General<TriggerState>[];
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, RebellionArgs, RebellionResolveContext<TriggerState>> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
parseArgs(_raw: unknown): RebellionArgs | null {
return {};
}
buildConstraints(_ctx: ConstraintContext, _args: RebellionArgs): Constraint[] {
return [notBeNeutral(), beChief(), occupiedCity(), suppliedCity(), notLord(), allowRebellion()];
}
resolve(context: RebellionResolveContext<TriggerState>, _args: RebellionArgs): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation;
if (!nation) {
throw new Error('모반시도는 국가 정보가 필요합니다.');
}
const lord =
context.nationGenerals?.find((candidate) => candidate.nationId === nation.id && candidate.officerLevel === 12) ??
null;
if (!lord || lord.id === general.id) {
throw new Error('모반할 대상 군주가 없습니다.');
}
const josaYi = JosaUtil.pick(general.name, '이');
const effects: Array<GeneralActionEffect<TriggerState>> = [];
context.addLog(`<Y><b>【모반】</b></><Y>${general.name}</>${josaYi} <D><b>${nation.name}</b></>의 군주 자리를 찬탈했습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
});
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${lord.name}</>에게서 군주자리를 찬탈`, {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
});
context.addLog('모반에 성공했습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
context.addLog(`모반으로 <D><b>${nation.name}</b></>의 군주자리를 찬탈`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
});
effects.push(
createLogEffect(`<Y>${general.name}</>에게 군주의 자리를 뺏겼습니다.`, {
scope: LogScope.GENERAL,
generalId: lord.id,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
}),
createLogEffect(`<D><b>${general.name}</b></>의 모반으로 인해 <D><b>${nation.name}</b></>의 군주자리를 박탈당함`, {
scope: LogScope.GENERAL,
generalId: lord.id,
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
}),
createGeneralPatchEffect(
{
officerLevel: 12,
meta: {
...general.meta,
officer_city: 0,
officerCity: 0,
},
},
general.id
),
createGeneralPatchEffect(
{
officerLevel: 1,
experience: Math.floor(lord.experience * 0.7),
meta: {
...lord.meta,
officer_city: 0,
officerCity: 0,
},
},
lord.id
)
);
return { effects };
}
}
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const nationId = base.nation?.id ?? base.general.nationId;
return {
...base,
nationGenerals: options.worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
};
};
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
category: '국가',
reqArg: false,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
};
@@ -0,0 +1,260 @@
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowJoinAction,
beLord,
beOpeningPart,
checkNationNameDuplicate,
reqNationGeneralCount,
reqNationValue,
wanderingNation,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
} from '@sammo-ts/logic/actions/engine.js';
import {
createCityPatchEffect,
createGeneralPatchEffect,
createNationPatchEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { z } from 'zod';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { JosaUtil } from '@sammo-ts/common';
const ACTION_NAME = '무작위 도시 건국';
const ACTION_KEY = 'che_무작위건국';
const ARGS_SCHEMA = z.object({
nationName: z.string().min(1),
nationType: z.string().min(1),
colorType: z.number(),
});
export type FoundingArgs = z.infer<typeof ARGS_SCHEMA>;
export interface RandomFoundingResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
allCities?: City[];
nationGenerals?: General<TriggerState>[];
currentYearMonth?: number;
initYearMonth?: number;
}
const NATION_COLORS = [
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#FFA500',
'#FFDAB9',
'#FFD700',
'#FFFF00',
'#7CFC00',
'#00FF00',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#20B2AA',
'#6495ED',
'#7FFFD4',
'#AFEEEE',
'#87CEEB',
'#00FFFF',
'#00BFFF',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#BA55D3',
'#800080',
'#FF00FF',
'#FFC0CB',
'#F5F5DC',
'#E0FFFF',
'#FFFFFF',
'#A9A9A9',
];
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, FoundingArgs, RandomFoundingResolveContext<TriggerState>> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
parseArgs(raw: unknown): FoundingArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
}
buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] {
return [beOpeningPart(), reqNationValue('level', '국가규모', '==', 0, '정식 국가가 아니어야합니다.')];
}
buildConstraints(_ctx: ConstraintContext, args: FoundingArgs): Constraint[] {
return [
beOpeningPart(),
beLord(),
wanderingNation(),
reqNationGeneralCount(2),
checkNationNameDuplicate(args.nationName),
allowJoinAction(),
];
}
resolve(
context: RandomFoundingResolveContext<TriggerState>,
args: FoundingArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation;
if (!nation) {
throw new Error('건국은 국가 정보가 필요합니다.');
}
if ((context.currentYearMonth ?? 0) <= (context.initYearMonth ?? 0)) {
context.addLog('다음 턴부터 건국할 수 있습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
return { effects: [], alternative: { commandKey: 'che_인재탐색', args: {} } };
}
if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) {
throw new Error('Invalid color type');
}
const candidates = (context.allCities ?? []).filter((city) => city.nationId === 0 && [5, 6].includes(city.level));
if (candidates.length === 0) {
context.addLog('건국할 수 있는 도시가 없습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
return { effects: [], alternative: { commandKey: 'che_해산', args: {} } };
}
const picked = candidates[context.rng.nextInt(0, candidates.length)]!;
const cityId = picked.id;
const josaNationUl = JosaUtil.pick(args.nationName, '을');
const josaNationYi = JosaUtil.pick(args.nationName, '이');
const josaGeneralYi = JosaUtil.pick(general.name, '이');
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국하였습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
context.addLog(`<Y>${general.name}</>${josaGeneralYi} <G><b>${picked.name}</b></>에 국가를 건설하였습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
});
context.addLog(`<Y><b>【건국】</b></>${args.nationType} <D><b>${args.nationName}</b></>${josaNationYi} 새로이 등장하였습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
});
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
});
context.addLog(`<Y>${general.name}</>${josaGeneralYi} <D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
});
tryApplyUniqueLottery(context, { acquireType: '건국', reason: ACTION_NAME });
const effects: Array<GeneralActionEffect<TriggerState>> = [
createNationPatchEffect(
{
name: args.nationName,
typeCode: args.nationType,
color: NATION_COLORS[args.colorType]!,
level: 1,
capitalCityId: cityId,
meta: {
...nation.meta,
can_국기변경: 1,
can_무작위수도이전: 1,
},
},
nation.id
),
createCityPatchEffect(
{
nationId: nation.id,
},
cityId
),
createGeneralPatchEffect<TriggerState>({
experience: general.experience + 1000,
dedication: general.dedication + 1000,
}),
];
if (general.cityId !== cityId) {
effects.push(createGeneralPatchEffect({ cityId }, general.id));
const nationGenerals = context.nationGenerals ?? [];
for (const nationGeneral of nationGenerals) {
if (nationGeneral.id === general.id) {
continue;
}
effects.push(createGeneralPatchEffect({ cityId }, nationGeneral.id));
}
}
return { effects };
}
}
const resolveStartYear = (currentYear: number, raw: unknown): number => {
if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.floor(raw);
}
if (typeof raw === 'string') {
const parsed = Number(raw);
if (Number.isFinite(parsed)) {
return Math.floor(parsed);
}
}
return currentYear;
};
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const nationId = base.nation?.id ?? base.general.nationId;
const currentYear = options.world.currentYear;
const currentMonth = options.world.currentMonth;
const startYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear);
const initYear = startYear;
const initMonth = 1;
return {
...base,
allCities: options.worldRef?.listCities() ?? [],
nationGenerals: options.worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
currentYearMonth: currentYear * 12 + currentMonth - 1,
initYearMonth: initYear * 12 + initMonth - 1,
};
};
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
category: '국가',
reqArg: true,
availabilityArgs: {
nationName: 'string',
nationType: 'string',
colorType: 'number',
},
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
};
@@ -0,0 +1,169 @@
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { beLord, existsDestGeneral, friendlyDestGeneral } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
} 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 { JosaUtil } from '@sammo-ts/common';
import { z } from 'zod';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
const ACTION_NAME = '선양';
const ACTION_KEY = 'che_선양';
const ARGS_SCHEMA = z.object({
destGeneralID: z.number().int().positive(),
});
export type AbdicationArgs = z.infer<typeof ARGS_SCHEMA>;
export interface AbdicationResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destGeneral?: General<TriggerState>;
}
const blockedPenaltyKeys = ['noChief', 'noFoundNation', 'noAmbassador'] as const;
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, AbdicationArgs, AbdicationResolveContext<TriggerState>> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
parseArgs(raw: unknown): AbdicationArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
}
buildMinConstraints(_ctx: ConstraintContext, _args: AbdicationArgs): Constraint[] {
return [beLord()];
}
buildConstraints(_ctx: ConstraintContext, _args: AbdicationArgs): Constraint[] {
return [beLord(), existsDestGeneral(), friendlyDestGeneral()];
}
resolve(context: AbdicationResolveContext<TriggerState>, _args: AbdicationArgs): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation;
const destGeneral = context.destGeneral;
if (!nation) {
throw new Error('선양은 국가 정보가 필요합니다.');
}
if (!destGeneral) {
throw new Error('선양 대상 장수가 없습니다.');
}
const penaltyRaw = destGeneral.meta.penalty;
if (penaltyRaw && typeof penaltyRaw === 'object' && !Array.isArray(penaltyRaw)) {
const penaltyMap = penaltyRaw as Record<string, unknown>;
for (const penaltyKey of blockedPenaltyKeys) {
if (Object.prototype.hasOwnProperty.call(penaltyMap, penaltyKey)) {
return {
effects: [
createLogEffect('선양할 수 없는 장수입니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
}
}
const josaYi = JosaUtil.pick(general.name, '이');
const effects: Array<GeneralActionEffect<TriggerState>> = [];
context.addLog(`<Y><b>【선양】</b></><Y>${general.name}</>${josaYi} <D><b>${nation.name}</b></>의 군주 자리를 <Y>${destGeneral.name}</>에게 선양했습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
});
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${destGeneral.name}</>에게 선양`, {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
});
context.addLog(`<Y>${destGeneral.name}</>에게 군주의 자리를 물려줍니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
context.addLog(`<D><b>${nation.name}</b></>의 군주자리를 <Y>${destGeneral.name}</>에게 선양`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
});
effects.push(
createLogEffect(`<Y>${general.name}</>에게서 군주의 자리를 물려받습니다.`, {
scope: LogScope.GENERAL,
generalId: destGeneral.id,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
}),
createLogEffect(`<D><b>${nation.name}</b></>의 군주자리를 물려 받음`, {
scope: LogScope.GENERAL,
generalId: destGeneral.id,
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
}),
createGeneralPatchEffect(
{
officerLevel: 12,
meta: {
...destGeneral.meta,
officer_city: 0,
officerCity: 0,
},
},
destGeneral.id
),
createGeneralPatchEffect(
{
officerLevel: 1,
experience: Math.floor(general.experience * 0.7),
meta: {
...general.meta,
officer_city: 0,
officerCity: 0,
},
},
general.id
)
);
return { effects };
}
}
export const actionContextBuilder: ActionContextBuilder<AbdicationArgs> = (base, options) => {
const destGeneralID = options.actionArgs.destGeneralID;
if (typeof destGeneralID !== 'number') {
return null;
}
const destGeneral = options.worldRef?.getGeneralById(destGeneralID) ?? undefined;
if (!destGeneral) {
return null;
}
return {
...base,
destGeneral,
};
};
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
category: '국가',
reqArg: true,
availabilityArgs: {
destGeneralID: 'number',
},
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
};
@@ -0,0 +1,224 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
alwaysFail,
reqCityCapacity,
reqCityTrader,
reqGeneralGold,
reqGeneralRice,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common';
import { z } from 'zod';
import type {
TurnCommandEnv,
TurnCommandItemCatalogEntry,
} from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
const ACTION_NAME = '장비매매';
const ACTION_KEY = 'che_장비매매';
const ITEM_TYPES = ['horse', 'weapon', 'book', 'item'] as const;
type ItemType = (typeof ITEM_TYPES)[number];
const ARGS_SCHEMA = z.object({
itemType: z.enum(ITEM_TYPES),
itemCode: z.string().min(1),
});
export type TradeItemArgs = z.infer<typeof ARGS_SCHEMA>;
const ITEM_TYPE_NAME: Record<ItemType, string> = {
horse: '명마',
weapon: '무기',
book: '서적',
item: '도구',
};
const readItem = (
catalog: Record<string, TurnCommandItemCatalogEntry> | undefined,
itemCode: string
): TurnCommandItemCatalogEntry | null => {
if (!catalog || itemCode === 'None') {
return null;
}
return catalog[itemCode] ?? null;
};
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, TradeItemArgs> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
constructor(private readonly env: TurnCommandEnv) {}
parseArgs(raw: unknown): TradeItemArgs | null {
const args = parseArgsWithSchema(ARGS_SCHEMA, raw);
if (!args) {
return null;
}
const { itemType, itemCode } = args;
if (itemCode === 'None') {
return args;
}
const item = readItem(this.env.itemCatalog, itemCode);
if (!item) {
return null;
}
if (item.slot !== itemType || !item.buyable) {
return null;
}
return args;
}
buildMinConstraints(_ctx: ConstraintContext, _args: TradeItemArgs): Constraint[] {
return [reqCityTrader()];
}
buildConstraints(_ctx: ConstraintContext, args: TradeItemArgs): Constraint[] {
const selectedItem = readItem(this.env.itemCatalog, args.itemCode);
const reqSecu = selectedItem?.reqSecu ?? 0;
const reqGold = selectedItem?.cost ?? 0;
const constraints: Constraint[] = [
reqCityTrader(),
reqCityCapacity('security', '치안 수치', reqSecu),
reqGeneralGold(() => reqGold),
reqGeneralRice(() => 0),
];
if (args.itemCode === 'None') {
constraints.push({
name: 'reqGeneralValue',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
test: (ctx, view) => {
const req = { kind: 'general', id: ctx.actorId } as const;
const general = view.get(req) as GeneralActionResolveContext<TriggerState>['general'] | null;
if (!general) {
return { kind: 'deny', reason: '장수 정보가 없습니다.' };
}
if (general.role.items[args.itemType] !== null) {
return { kind: 'allow' };
}
return { kind: 'deny', reason: `${ITEM_TYPE_NAME[args.itemType]}이 없습니다.` };
},
});
return constraints;
}
constraints.push({
name: 'AlwaysFail',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
test: (ctx, view) => {
const req = { kind: 'general', id: ctx.actorId } as const;
const general = view.get(req) as GeneralActionResolveContext<TriggerState>['general'] | null;
if (!general) {
return { kind: 'deny', reason: '장수 정보가 없습니다.' };
}
const currentItemCode = general.role.items[args.itemType];
if (currentItemCode === args.itemCode) {
return alwaysFail('이미 가지고 있습니다.').test(ctx, view);
}
if (currentItemCode) {
const currentItem = readItem(this.env.itemCatalog, currentItemCode);
if (currentItem && !currentItem.buyable) {
return alwaysFail('이미 진귀한 것을 가지고 있습니다.').test(ctx, view);
}
}
return { kind: 'allow' };
},
});
return constraints;
}
resolve(context: GeneralActionResolveContext<TriggerState>, args: TradeItemArgs): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation;
const itemType = args.itemType;
const requestedItemCode = args.itemCode;
const currentItemCode = general.role.items[itemType];
const buying = requestedItemCode !== 'None';
const finalItemCode = buying ? requestedItemCode : currentItemCode;
if (!finalItemCode) {
throw new Error('판매할 아이템이 없습니다.');
}
const item = readItem(this.env.itemCatalog, finalItemCode);
const itemName = item?.name ?? finalItemCode;
const itemRawName = item?.rawName ?? itemName;
const itemCost = item?.cost ?? 0;
const josaUl = JosaUtil.pick(itemRawName, '을');
const nextGold = buying ? Math.max(0, general.gold - itemCost) : general.gold + Math.floor(itemCost / 2);
const nextRole = {
...general.role,
items: {
...general.role.items,
[itemType]: buying ? finalItemCode : null,
},
};
if (buying) {
context.addLog(`<C>${itemName}</>${josaUl} 구입했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
} else {
context.addLog(`<C>${itemName}</>${josaUl} 판매했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
}
if (!buying && item && !item.buyable && nation) {
const josaYi = JosaUtil.pick(general.name, '이');
context.addLog(`<Y>${general.name}</>${josaYi} <C>${itemName}</>${josaUl} 판매했습니다!`, {
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
});
context.addLog(`<R><b>【판매】</b></><D><b>${nation.name}</b></>의 <Y>${general.name}</>${josaYi} <C>${itemName}</>${josaUl} 판매했습니다!`, {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
});
}
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
return {
effects: [
createGeneralPatchEffect<TriggerState>({
gold: nextGold,
experience: general.experience + 10,
role: nextRole,
}),
],
};
}
}
export const actionContextBuilder = defaultActionContextBuilder;
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
category: '개인',
reqArg: true,
availabilityArgs: {
itemType: 'string',
itemCode: 'string',
},
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -0,0 +1,268 @@
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js';
import {
allowJoinAction,
beNeutral,
reqEnvValue,
unknownOrDeny,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common';
import { z } from 'zod';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
const ACTION_NAME = '장수를 따라 임관';
const ACTION_KEY = 'che_장수대상임관';
const ARGS_SCHEMA = z.object({
destGeneralID: z.number().int().positive(),
});
export type FollowAppointmentArgs = z.infer<typeof ARGS_SCHEMA>;
export interface FollowAppointmentResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destGeneral?: General<TriggerState>;
destNation?: Nation;
destNationGeneralCount?: number;
initialNationGenLimit?: number;
}
const existsDestNation = (destGeneralID: number): Constraint => ({
name: 'existsDestNation',
requires: () => [{ kind: 'destGeneral', id: destGeneralID }],
test: (ctx, view) => {
const req: RequirementKey = { kind: 'destGeneral', id: destGeneralID };
if (!view.has(req)) {
return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.');
}
const destGeneral = view.get(req) as General | null;
if (!destGeneral || destGeneral.nationId <= 0) {
return { kind: 'deny', reason: '국가 정보가 없습니다.' };
}
return { kind: 'allow' };
},
});
const allowJoinDestNation = (destGeneralID: number): Constraint => ({
name: 'allowJoinDestNation',
requires: () => [
{ kind: 'destGeneral', id: destGeneralID },
{ kind: 'generalList' },
{ kind: 'nationList' },
{ kind: 'env', key: 'relYear' },
{ kind: 'env', key: 'openingPartYear' },
{ kind: 'env', key: 'initialNationGenLimit' },
{ kind: 'env', key: 'maxGeneral' },
],
test: (ctx, view) => {
const req: RequirementKey = { kind: 'destGeneral', id: destGeneralID };
if (!view.has(req)) {
return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.');
}
const destGeneral = view.get(req) as General | null;
if (!destGeneral || destGeneral.nationId <= 0) {
return { kind: 'deny', reason: '국가 정보가 없습니다.' };
}
const listReq: RequirementKey = { kind: 'generalList' };
if (!view.has(listReq)) {
return unknownOrDeny(ctx, [listReq], '장수 정보가 없습니다.');
}
const generals = view.get(listReq) as General[] | null;
if (!generals) {
return unknownOrDeny(ctx, [listReq], '장수 정보가 없습니다.');
}
const nationListReq: RequirementKey = { kind: 'nationList' };
if (!view.has(nationListReq)) {
return unknownOrDeny(ctx, [nationListReq], '국가 정보가 없습니다.');
}
const nations = view.get(nationListReq) as Nation[] | null;
if (!nations) {
return unknownOrDeny(ctx, [nationListReq], '국가 정보가 없습니다.');
}
const destNation = nations.find((nation) => nation.id === destGeneral.nationId);
if (!destNation) {
return { kind: 'deny', reason: '국가 정보가 없습니다.' };
}
const relYear = typeof view.get({ kind: 'env', key: 'relYear' }) === 'number' ? (view.get({ kind: 'env', key: 'relYear' }) as number) : 0;
const openingPartYear =
typeof view.get({ kind: 'env', key: 'openingPartYear' }) === 'number'
? (view.get({ kind: 'env', key: 'openingPartYear' }) as number)
: 3;
const initialNationGenLimit =
typeof view.get({ kind: 'env', key: 'initialNationGenLimit' }) === 'number'
? (view.get({ kind: 'env', key: 'initialNationGenLimit' }) as number)
: 10;
const maxGeneral =
typeof view.get({ kind: 'env', key: 'maxGeneral' }) === 'number'
? (view.get({ kind: 'env', key: 'maxGeneral' }) as number)
: 500;
const currentCount = generals.filter((general) => general.nationId === destNation.id).length;
if (destNation.level === 0) {
return { kind: 'allow' };
}
if (relYear < openingPartYear) {
if (currentCount < initialNationGenLimit) {
return { kind: 'allow' };
}
return { kind: 'deny', reason: '초반 등용 제한 인원을 초과했습니다.' };
}
if (currentCount < maxGeneral) {
return { kind: 'allow' };
}
return { kind: 'deny', reason: '등용 제한 인원을 초과했습니다.' };
},
});
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, FollowAppointmentArgs, FollowAppointmentResolveContext<TriggerState>> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
parseArgs(raw: unknown): FollowAppointmentArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
}
buildMinConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] {
return [
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
beNeutral(),
allowJoinAction(),
];
}
buildConstraints(ctx: ConstraintContext, args: FollowAppointmentArgs): Constraint[] {
void ctx;
return [
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
beNeutral(),
existsDestNation(args.destGeneralID),
allowJoinDestNation(args.destGeneralID),
allowJoinAction(),
];
}
resolve(
context: FollowAppointmentResolveContext<TriggerState>,
_args: FollowAppointmentArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const destGeneral = context.destGeneral;
const destNation = context.destNation;
if (!destNation) {
throw new Error('임관 대상 국가 정보가 없습니다.');
}
const targetCityId = destGeneral?.cityId ?? destNation.capitalCityId ?? general.cityId;
const josaYi = JosaUtil.pick(general.name, '이');
context.addLog(`<D>${destNation.name}</>에 임관했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
context.addLog(`<D><b>${destNation.name}</b></>에 임관`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
});
context.addLog(`<Y>${general.name}</>${josaYi} <D><b>${destNation.name}</b></>에 <S>임관</>했습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
});
const initialNationGenLimit = context.initialNationGenLimit ?? 10;
const destNationGeneralCount = context.destNationGeneralCount ?? 0;
const expGain = destNationGeneralCount < initialNationGenLimit ? 700 : 100;
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
return {
effects: [
createGeneralPatchEffect<TriggerState>({
nationId: destNation.id,
officerLevel: 1,
cityId: targetCityId,
troopId: 0,
experience: general.experience + expGain,
meta: {
...general.meta,
belong: 1,
officer_city: 0,
officerCity: 0,
},
}),
createNationPatchEffect(
{
meta: {
...destNation.meta,
gennum:
typeof destNation.meta.gennum === 'number'
? destNation.meta.gennum + 1
: destNationGeneralCount + 1,
},
},
destNation.id
),
],
};
}
}
export const actionContextBuilder: ActionContextBuilder<FollowAppointmentArgs> = (base, options) => {
const destGeneralID = options.actionArgs.destGeneralID;
if (typeof destGeneralID !== 'number') {
return null;
}
const worldRef = options.worldRef;
if (!worldRef) {
return null;
}
const destGeneral = worldRef.getGeneralById(destGeneralID) ?? undefined;
const destNation = destGeneral ? worldRef.getNationById(destGeneral.nationId) ?? undefined : undefined;
if (!destGeneral || !destNation) {
return null;
}
const currentCount = worldRef.listGenerals().filter((general) => general.nationId === destNation.id).length;
const constValues = (options.scenarioConfig.const ?? {}) as Record<string, unknown>;
const initialNationGenLimitRaw = constValues.initialNationGenLimit;
const initialNationGenLimit =
typeof initialNationGenLimitRaw === 'number' && Number.isFinite(initialNationGenLimitRaw)
? Math.floor(initialNationGenLimitRaw)
: 10;
return {
...base,
destGeneral,
destNation,
destNationGeneralCount: currentCount,
initialNationGenLimit,
};
};
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
category: '인사',
reqArg: true,
availabilityArgs: {
destGeneralID: 'number',
},
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
};
@@ -0,0 +1,142 @@
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
notBeNeutral,
notWanderingNation,
occupiedCity,
reqGeneralAtmosMargin,
reqGeneralCrew,
reqGeneralGold,
reqGeneralRice,
reqGeneralTrainMargin,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import type { GeneralTurnCommandSpec } from './index.js';
const ACTION_NAME = '전투태세';
const ACTION_KEY = 'che_전투태세';
const REQ_TERM = 3;
export interface BattlePreparationArgs {}
const readNationTech = (nation: Nation | null | undefined): number => {
if (!nation) {
return 0;
}
const tech = nation.meta.tech;
return typeof tech === 'number' ? tech : 0;
};
const readBattleStanceTerm = (meta: Record<string, unknown>): number => {
const value = meta.battle_stance_term;
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
};
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, BattlePreparationArgs> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
constructor(private readonly env: TurnCommandEnv) {}
parseArgs(_raw: unknown): BattlePreparationArgs | null {
return {};
}
buildConstraints(_ctx: ConstraintContext, _args: BattlePreparationArgs): Constraint[] {
const nationRequirement =
_ctx.nationId !== undefined ? [{ kind: 'nation', id: _ctx.nationId } as const] : [];
return [
notBeNeutral(),
notWanderingNation(),
occupiedCity(),
reqGeneralCrew(),
reqGeneralGold((ctx, view) => {
const general = view.get({ kind: 'general', id: ctx.actorId }) as { crew?: number } | null;
const nation =
ctx.nationId !== undefined ? (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null) : null;
const crew = typeof general?.crew === 'number' ? general.crew : 0;
const techCost = getTechCost(readNationTech(nation));
return Math.round((crew / 100) * 3 * techCost);
}, nationRequirement),
reqGeneralRice(() => 0),
reqGeneralTrainMargin(Math.max(0, this.env.maxTrainByCommand - 10)),
reqGeneralAtmosMargin(Math.max(0, this.env.maxAtmosByCommand - 10)),
];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: BattlePreparationArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation;
const crew = general.crew;
const techCost = getTechCost(readNationTech(nation));
const costGold = Math.round((crew / 100) * 3 * techCost);
const previousTerm = readBattleStanceTerm(general.meta as Record<string, unknown>);
const term = previousTerm >= REQ_TERM ? 1 : previousTerm + 1;
if (term < REQ_TERM) {
context.addLog(`병사들을 열심히 훈련중... (${term}/3)`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
return {
effects: [
createGeneralPatchEffect<TriggerState>({
gold: Math.max(0, general.gold - costGold),
meta: {
...general.meta,
battle_stance_term: term,
},
}),
],
};
}
context.addLog(`전투태세 완료! (${term}/3)`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
return {
effects: [
createGeneralPatchEffect<TriggerState>({
gold: Math.max(0, general.gold - costGold),
experience: general.experience + 300,
dedication: general.dedication + 210,
meta: {
...general.meta,
battle_stance_term: term,
leadership_exp: leadershipExp + 3,
},
}),
],
};
}
}
export const actionContextBuilder = defaultActionContextBuilder;
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
category: '군사',
reqArg: false,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -0,0 +1,116 @@
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { notBeNeutral, notOccupiedCity, notWanderingNation } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { searchDistance } from '@sammo-ts/logic/world/distance.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
import type { GeneralTurnCommandSpec } from './index.js';
const ACTION_NAME = '접경귀환';
const ACTION_KEY = 'che_접경귀환';
export interface BorderReturnArgs {}
export interface BorderReturnResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
map?: MapDefinition;
allCities?: City[];
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, BorderReturnArgs, BorderReturnResolveContext<TriggerState>> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
parseArgs(_raw: unknown): BorderReturnArgs | null {
return {};
}
buildConstraints(_ctx: ConstraintContext, _args: BorderReturnArgs): Constraint[] {
return [notBeNeutral(), notWanderingNation(), notOccupiedCity()];
}
resolve(
context: BorderReturnResolveContext<TriggerState>,
_args: BorderReturnArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation;
const map = context.map;
const allCities = context.allCities ?? [];
if (!nation || !map) {
context.addLog('3칸 이내에 아국 도시가 없습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
return { effects: [] };
}
const distanceByCity = searchDistance(map, general.cityId, 3);
const candidates = allCities
.filter((city) => city.nationId === nation.id && city.supplyState === 1)
.map((city) => ({ city, distance: distanceByCity[city.id] }))
.filter((entry) => typeof entry.distance === 'number' && Number.isFinite(entry.distance));
if (candidates.length === 0) {
context.addLog('3칸 이내에 아국 도시가 없습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
return { effects: [] };
}
const minDistance = Math.min(...candidates.map((entry) => entry.distance as number));
const nearestCities = candidates
.filter((entry) => entry.distance === minDistance)
.map((entry) => entry.city);
if (nearestCities.length === 0) {
context.addLog('3칸 이내에 아국 도시가 없습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
return { effects: [] };
}
const targetCity = nearestCities[context.rng.nextInt(0, nearestCities.length)]!;
const josaRo = JosaUtil.pick(targetCity.name, '로');
context.addLog(`<G><b>${targetCity.name}</b></>${josaRo} 접경귀환했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
return {
effects: [
createGeneralPatchEffect<TriggerState>({
cityId: targetCity.id,
}),
],
};
}
}
export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
...base,
map: options.map,
allCities: options.worldRef?.listCities() ?? [],
});
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
category: '인사',
reqArg: false,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
};
@@ -0,0 +1,164 @@
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
existsDestGeneral,
friendlyDestGeneral,
notBeNeutral,
occupiedCity,
reqGeneralGold,
reqGeneralRice,
suppliedCity,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } 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 { z } from 'zod';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
const ACTION_NAME = '증여';
const ACTION_KEY = 'che_증여';
const ARGS_SCHEMA = z.object({
isGold: z.boolean(),
amount: z.preprocess(
(value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value),
z.number().int().positive()
),
destGeneralID: z.number().int().positive(),
});
export type GiftArgs = z.infer<typeof ARGS_SCHEMA>;
export interface GiftResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destGeneral?: General<TriggerState>;
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, GiftArgs, GiftResolveContext<TriggerState>> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
constructor(private readonly env: TurnCommandEnv) {}
parseArgs(raw: unknown): GiftArgs | null {
const parsed = parseArgsWithSchema(ARGS_SCHEMA, raw);
if (!parsed) {
return null;
}
const maxAmount = this.env.maxResourceActionAmount > 0 ? this.env.maxResourceActionAmount : 10000;
return {
...parsed,
amount: Math.max(100, Math.min(parsed.amount, maxAmount)),
};
}
buildMinConstraints(_ctx: ConstraintContext, _args: GiftArgs): Constraint[] {
return [notBeNeutral(), occupiedCity(), suppliedCity()];
}
buildConstraints(_ctx: ConstraintContext, args: GiftArgs): Constraint[] {
const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
return [
notBeNeutral(),
occupiedCity(),
suppliedCity(),
existsDestGeneral(),
friendlyDestGeneral(),
args.isGold ? reqGeneralGold(() => minGold) : reqGeneralRice(() => minRice),
];
}
resolve(context: GiftResolveContext<TriggerState>, args: GiftArgs): GeneralActionOutcome<TriggerState> {
const general = context.general;
const destGeneral = context.destGeneral;
if (!destGeneral) {
throw new Error('증여 대상 장수가 없습니다.');
}
const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
const resKey = args.isGold ? 'gold' : 'rice';
const resName = args.isGold ? '금' : '쌀';
const keepMin = args.isGold ? minGold : minRice;
const available = Math.max(0, general[resKey] - keepMin);
const amount = Math.max(0, Math.min(args.amount, available));
const amountText = amount.toLocaleString('en-US');
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
return {
effects: [
createGeneralPatchEffect<TriggerState>(
{
[resKey]: general[resKey] - amount,
experience: general.experience + 70,
dedication: general.dedication + 100,
meta: {
...general.meta,
leadership_exp: leadershipExp + 1,
},
},
general.id
),
createGeneralPatchEffect<TriggerState>(
{
[resKey]: destGeneral[resKey] + amount,
},
destGeneral.id
),
createLogEffect(`<Y>${general.name}</>에게서 ${resName} <C>${amountText}</>을 증여 받았습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: destGeneral.id,
format: LogFormat.PLAIN,
}),
createLogEffect(`<Y>${destGeneral.name}</>에게 ${resName} <C>${amountText}</>을 증여했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}),
],
};
}
}
export const actionContextBuilder: ActionContextBuilder<GiftArgs> = (base, options) => {
const destGeneralID = options.actionArgs.destGeneralID;
if (typeof destGeneralID !== 'number') {
return null;
}
const destGeneral = options.worldRef?.getGeneralById(destGeneralID) ?? undefined;
if (!destGeneral) {
return null;
}
return {
...base,
destGeneral,
};
};
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
category: '국가',
reqArg: true,
availabilityArgs: {
isGold: false,
amount: 0,
destGeneralID: 0,
},
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -0,0 +1,180 @@
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { beLord, wanderingNation } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
GeneralActionOutcome,
GeneralActionResolveContext,
} from '@sammo-ts/logic/actions/engine.js';
import {
createCityPatchEffect,
createGeneralPatchEffect,
createNationPatchEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import type { GeneralTurnCommandSpec } from './index.js';
const ACTION_NAME = '해산';
const ACTION_KEY = 'che_해산';
export interface DisbandFactionArgs {}
export interface DisbandFactionResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
nationGenerals?: General<TriggerState>[];
nationCities?: City[];
currentYearMonth?: number;
initYearMonth?: number;
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, DisbandFactionArgs, DisbandFactionResolveContext<TriggerState>> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
constructor(private readonly env: TurnCommandEnv) {}
parseArgs(_raw: unknown): DisbandFactionArgs | null {
return {};
}
buildConstraints(_ctx: ConstraintContext, _args: DisbandFactionArgs): Constraint[] {
return [beLord(), wanderingNation()];
}
resolve(
context: DisbandFactionResolveContext<TriggerState>,
_args: DisbandFactionArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation;
if (!nation) {
throw new Error('해산은 국가 정보가 필요합니다.');
}
if ((context.currentYearMonth ?? 0) <= (context.initYearMonth ?? 0)) {
context.addLog('다음 턴부터 해산할 수 있습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
return { effects: [], alternative: { commandKey: 'che_인재탐색', args: {} } };
}
const effects: Array<GeneralActionEffect<TriggerState>> = [];
const baseGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
const baseRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
const nationGenerals = context.nationGenerals ?? [];
for (const targetGeneral of nationGenerals) {
const isActor = targetGeneral.id === general.id;
effects.push(
createGeneralPatchEffect(
{
nationId: 0,
officerLevel: 0,
troopId: 0,
gold: Math.min(targetGeneral.gold, baseGold),
rice: Math.min(targetGeneral.rice, baseRice),
meta: {
...targetGeneral.meta,
belong: 0,
officer_city: 0,
officerCity: 0,
...(isActor ? { makelimit: 12 } : {}),
},
},
targetGeneral.id
)
);
}
const nationCities = context.nationCities ?? [];
for (const city of nationCities) {
effects.push(
createCityPatchEffect(
{
nationId: 0,
frontState: 0,
},
city.id
)
);
}
effects.push(
createNationPatchEffect(
{
meta: {
...nation.meta,
collapsed: true,
},
},
nation.id
)
);
const josaYi = JosaUtil.pick(general.name, '이');
const josaUl = JosaUtil.pick(nation.name, '을');
context.addLog('세력을 해산했습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
context.addLog(`<Y>${general.name}</>${josaYi} 세력을 해산했습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
});
context.addLog(`<D><b>${nation.name}</b></>${josaUl} 해산`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
});
return { effects };
}
}
const resolveStartYear = (currentYear: number, raw: unknown): number => {
if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.floor(raw);
}
if (typeof raw === 'string') {
const parsed = Number(raw);
if (Number.isFinite(parsed)) {
return Math.floor(parsed);
}
}
return currentYear;
};
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const nationId = base.nation?.id ?? base.general.nationId;
const worldRef = options.worldRef;
const currentYear = options.world.currentYear;
const currentMonth = options.world.currentMonth;
const initYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear);
const initMonth = 1;
return {
...base,
nationGenerals: worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
nationCities: worldRef?.listCities().filter((city) => city.nationId === nationId) ?? [],
currentYearMonth: currentYear * 12 + currentMonth - 1,
initYearMonth: initYear * 12 + initMonth - 1,
};
};
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
category: '국가',
reqArg: false,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -0,0 +1,188 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowJoinAction,
beLord,
beOpeningPart,
checkNationNameDuplicate,
neutralCity,
reqNationGeneralCount,
reqNationValue,
wanderingNation,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import {
createCityPatchEffect,
createGeneralPatchEffect,
createNationPatchEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { z } from 'zod';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { JosaUtil } from '@sammo-ts/common';
const ACTION_NAME = '건국';
const ACTION_KEY = 'cr_건국';
const ARGS_SCHEMA = z.object({
nationName: z.string().min(1),
nationType: z.string().min(1),
colorType: z.number(),
});
export type FoundingArgs = z.infer<typeof ARGS_SCHEMA>;
const NATION_COLORS = [
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#FFA500',
'#FFDAB9',
'#FFD700',
'#FFFF00',
'#7CFC00',
'#00FF00',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#20B2AA',
'#6495ED',
'#7FFFD4',
'#AFEEEE',
'#87CEEB',
'#00FFFF',
'#00BFFF',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#BA55D3',
'#800080',
'#FF00FF',
'#FFC0CB',
'#F5F5DC',
'#E0FFFF',
'#FFFFFF',
'#A9A9A9',
];
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, FoundingArgs> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
parseArgs(raw: unknown): FoundingArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
}
buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] {
return [beOpeningPart(), reqNationValue('level', '국가규모', '==', 0, '정식 국가가 아니어야합니다.')];
}
buildConstraints(_ctx: ConstraintContext, args: FoundingArgs): Constraint[] {
return [
beOpeningPart(),
beLord(),
wanderingNation(),
reqNationGeneralCount(2),
checkNationNameDuplicate(args.nationName),
allowJoinAction(),
neutralCity(),
];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
args: FoundingArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation;
if (!nation) {
throw new Error('건국은 국가 정보가 필요합니다.');
}
const cityId = general.cityId;
if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) {
throw new Error('Invalid color type');
}
const color = NATION_COLORS[args.colorType];
const josaNationUl = JosaUtil.pick(args.nationName, '을');
const josaNationYi = JosaUtil.pick(args.nationName, '이');
const josaGeneralYi = JosaUtil.pick(general.name, '이');
const cityName = context.city?.name ?? '알 수 없는 도시';
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국하였습니다.`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
context.addLog(`<Y>${general.name}</>${josaGeneralYi} <G><b>${cityName}</b></>에 국가를 건설하였습니다.`, {
category: LogCategory.ACTION,
scope: LogScope.SYSTEM,
});
context.addLog(`<Y><b>【건국】</b></>${args.nationType} <D><b>${args.nationName}</b></>${josaNationYi} 새로이 등장하였습니다.`, {
category: LogCategory.HISTORY,
scope: LogScope.SYSTEM,
});
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
category: LogCategory.HISTORY,
scope: LogScope.GENERAL,
});
context.addLog(`<Y>${general.name}</>${josaGeneralYi} <D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
category: LogCategory.HISTORY,
scope: LogScope.NATION,
});
tryApplyUniqueLottery(context, { acquireType: '건국', reason: ACTION_NAME });
const effects = [
createNationPatchEffect(
{
name: args.nationName,
typeCode: args.nationType,
color: color!,
level: 1,
capitalCityId: cityId,
meta: {
...nation.meta,
can_국기변경: 1,
},
},
nation.id
),
createCityPatchEffect(
{
nationId: nation.id,
},
cityId
),
createGeneralPatchEffect<TriggerState>({
experience: general.experience + 1000,
dedication: general.dedication + 1000,
}),
];
return { effects };
}
}
export const actionContextBuilder = defaultActionContextBuilder;
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
category: '국가',
reqArg: true,
availabilityArgs: {
nationName: 'string',
nationType: 'string',
colorType: 'number',
},
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
};
@@ -0,0 +1,110 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
notBeNeutral,
notWanderingNation,
occupiedCity,
reqGeneralCrew,
reqGeneralTrainMargin,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
const ACTION_NAME = '맹훈련';
const ACTION_KEY = 'cr_맹훈련';
export interface FierceTrainingArgs {}
const clamp = (value: number, min: number, max: number): number => {
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
};
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, FierceTrainingArgs> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
constructor(private readonly env: TurnCommandEnv) {}
parseArgs(_raw: unknown): FierceTrainingArgs | null {
return {};
}
buildMinConstraints(_ctx: ConstraintContext, _args: FierceTrainingArgs): Constraint[] {
return [notBeNeutral(), notWanderingNation(), occupiedCity()];
}
buildConstraints(_ctx: ConstraintContext, _args: FierceTrainingArgs): Constraint[] {
return [
notBeNeutral(),
notWanderingNation(),
occupiedCity(),
reqGeneralCrew(),
reqGeneralTrainMargin(this.env.maxTrainByCommand),
];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: FierceTrainingArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const trainDelta = this.env.trainDelta > 0 ? this.env.trainDelta : 30;
const maxTrain = this.env.maxTrainByCommand > 0 ? this.env.maxTrainByCommand : 100;
const maxAtmos = this.env.maxAtmosByCommand > 0 ? this.env.maxAtmosByCommand : 100;
const score = Math.round((general.stats.leadership * 100 * trainDelta * 2) / (Math.max(general.crew, 1) * 3));
const scoreText = score.toLocaleString('en-US');
context.addLog(`훈련, 사기치가 <C>${scoreText}</> 상승했습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
const nextTrain = clamp(general.train + score, 0, maxTrain);
const nextAtmos = clamp(general.atmos + score, 0, maxAtmos);
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
return {
effects: [
createGeneralPatchEffect<TriggerState>({
rice: Math.max(0, general.rice - 500),
train: nextTrain,
atmos: nextAtmos,
experience: general.experience + 150,
dedication: general.dedication + 100,
meta: {
...general.meta,
leadership_exp: leadershipExp + 1,
},
}),
],
};
}
}
export const actionContextBuilder = defaultActionContextBuilder;
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
category: '군사',
reqArg: false,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -6,13 +6,19 @@ export const GENERAL_TURN_COMMAND_KEYS = [
'che_랜덤임관',
'che_귀환',
'che_등용수락',
'che_장수대상임관',
'che_건국',
'cr_건국',
'che_무작위건국',
'che_훈련',
'cr_맹훈련',
'che_전투태세',
'che_단련',
'che_숙련전환',
'che_사기진작',
'che_요양',
'che_견문',
'che_장비매매',
'che_내정특기초기화',
'che_전투특기초기화',
'che_출병',
@@ -34,9 +40,14 @@ export const GENERAL_TURN_COMMAND_KEYS = [
'che_물자조달',
'che_헌납',
'che_이동',
'che_접경귀환',
'che_방랑',
'che_하야',
'che_은퇴',
'che_선양',
'che_모반시도',
'che_증여',
'che_해산',
'che_등용',
'che_첩보',
'che_파괴',
@@ -60,15 +71,21 @@ const defaultImporters: Record<GeneralTurnCommandKey, GeneralTurnCommandImporter
che_거병: async () => import('./che_거병.js'),
che_임관: async () => import('./che_임관.js'),
che_등용수락: () => import('./che_등용수락.js'),
che_장수대상임관: () => import('./che_장수대상임관.js'),
che_랜덤임관: () => import('./che_랜덤임관.js'),
che_귀환: async () => import('./che_귀환.js'),
che_건국: async () => import('./che_건국.js'),
cr_건국: async () => import('./cr_건국.js'),
che_무작위건국: async () => import('./che_무작위건국.js'),
che_훈련: async () => import('./che_훈련.js'),
cr_맹훈련: async () => import('./cr_맹훈련.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'),
@@ -90,9 +107,14 @@ 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'),
+37
View File
@@ -51,6 +51,41 @@ export const occupiedCity = (options: { allowNeutral?: boolean } = {}): Constrai
},
});
export const notOccupiedCity = (): Constraint => ({
name: 'notOccupiedCity',
requires: (ctx) => {
const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }];
if (ctx.cityId !== undefined) {
reqs.push({ kind: 'city', id: ctx.cityId });
}
return reqs;
},
test: (ctx, view) => {
const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId };
if (!view.has(generalReq)) {
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
}
const general = view.get(generalReq) as General | null;
if (!general) {
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
}
const cityId = ctx.cityId ?? general.cityId;
const cityReq: RequirementKey = { kind: 'city', id: cityId };
if (!view.has(cityReq)) {
return unknownOrDeny(ctx, [cityReq], '도시 정보가 없습니다.');
}
const city = view.get(cityReq) as City | null;
if (!city) {
return unknownOrDeny(ctx, [cityReq], '도시 정보가 없습니다.');
}
if (city.nationId !== general.nationId) {
return allow();
}
return { kind: 'deny', reason: '아국입니다.' };
},
});
export const occupiedDestCity = (): Constraint => ({
name: 'occupiedDestCity',
requires: (ctx) => {
@@ -499,6 +534,8 @@ export const beNeutralCity = (): Constraint => ({
},
});
export const neutralCity = (): Constraint => beNeutralCity();
export const constructableCity = (): Constraint => ({
name: 'constructableCity',
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
+68
View File
@@ -286,6 +286,74 @@ export const noPenalty = (penaltyKey: string): Constraint => ({
},
});
export const allowRebellion = (): Constraint => ({
name: 'allowRebellion',
requires: (ctx) => [
{ kind: 'general', id: ctx.actorId },
{ kind: 'generalList' },
{ kind: 'env', key: 'killturn' },
],
test: (ctx, view) => {
const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId };
if (!view.has(generalReq)) {
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
}
const general = view.get(generalReq) as General | null;
if (!general) {
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
}
if (general.nationId === 0) {
return { kind: 'deny', reason: '재야입니다.' };
}
const generalListReq: RequirementKey = { kind: 'generalList' };
if (!view.has(generalListReq)) {
return unknownOrDeny(ctx, [generalListReq], '장수 정보가 없습니다.');
}
const generals = view.get(generalListReq) as General[] | null;
if (!generals) {
return unknownOrDeny(ctx, [generalListReq], '장수 정보가 없습니다.');
}
const lord = generals.find((entry) => entry.nationId === general.nationId && entry.officerLevel === 12);
if (!lord) {
return unknownOrDeny(ctx, [generalListReq], '군주 정보가 없습니다.');
}
if (lord.id === general.id) {
return { kind: 'deny', reason: '이미 군주입니다.' };
}
const envReq: RequirementKey = { kind: 'env', key: 'killturn' };
if (!view.has(envReq)) {
return unknownOrDeny(ctx, [envReq], '턴 정보가 없습니다.');
}
const rawKillturn = view.get(envReq);
const worldKillturn =
typeof rawKillturn === 'number'
? rawKillturn
: typeof rawKillturn === 'string'
? Number(rawKillturn)
: NaN;
if (!Number.isFinite(worldKillturn)) {
return unknownOrDeny(ctx, [envReq], '턴 정보가 없습니다.');
}
const lordKillturn = typeof lord.meta.killturn === 'number' ? lord.meta.killturn : NaN;
if (!Number.isFinite(lordKillturn)) {
return unknownOrDeny(ctx, [generalListReq], '군주 정보가 없습니다.');
}
if (lordKillturn >= worldKillturn) {
return { kind: 'deny', reason: '군주가 활동중입니다.' };
}
if ([2, 3, 6, 9].includes(lord.npcState)) {
return { kind: 'deny', reason: '군주가 NPC입니다.' };
}
return allow();
},
});
export const reqGeneralValue = (
key: string,
keyNick: string,
@@ -0,0 +1,405 @@
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '../../../src/domain/entities.js';
import type { WorldSnapshot } from '../../../src/world/types.js';
import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
import { MINIMAL_MAP } from '../../fixtures/minimalMap.js';
import type { TurnCommandEnv, TurnCommandItemCatalogEntry } from '../../../src/actions/turn/commandEnv.js';
import { commandSpec as rebellionSpec } from '../../../src/actions/turn/general/che_모반시도.js';
import { commandSpec as abdicationSpec } from '../../../src/actions/turn/general/che_선양.js';
import { commandSpec as giftSpec } from '../../../src/actions/turn/general/che_증여.js';
import { commandSpec as disbandSpec } from '../../../src/actions/turn/general/che_해산.js';
import { commandSpec as foundNationSpec } from '../../../src/actions/turn/general/cr_건국.js';
import { commandSpec as tradeItemSpec } from '../../../src/actions/turn/general/che_장비매매.js';
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
const SYSTEM_ENV: TurnCommandEnv = {
develCost: 100,
trainDelta: 35,
atmosDelta: 35,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
sabotageDefaultProb: 0.5,
sabotageProbCoefByStat: 0.1,
sabotageDefenceCoefByGeneralCount: 0.1,
sabotageDamageMin: 10,
sabotageDamageMax: 30,
openingPartYear: 200,
maxGeneral: 10,
defaultNpcGold: 1000,
defaultNpcRice: 1000,
defaultCrewTypeId: 1,
defaultSpecialDomestic: null,
defaultSpecialWar: null,
initialNationGenLimit: 10,
maxTechLevel: 10,
baseGold: 1000,
baseRice: 1000,
maxResourceActionAmount: 10000,
};
const makeGeneral = (params: {
id: number;
nationId: number;
cityId: number;
name?: string;
officerLevel?: number;
experience?: number;
dedication?: number;
gold?: number;
rice?: number;
crew?: number;
meta?: Record<string, unknown>;
items?: Partial<General['role']['items']>;
}): General => ({
id: params.id,
name: params.name ?? `장수${params.id}`,
nationId: params.nationId,
cityId: params.cityId,
troopId: 0,
stats: { leadership: 80, strength: 70, intelligence: 60 },
experience: params.experience ?? 0,
dedication: params.dedication ?? 0,
officerLevel: params.officerLevel ?? 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: {
horse: params.items?.horse ?? null,
weapon: params.items?.weapon ?? null,
book: params.items?.book ?? null,
item: params.items?.item ?? null,
},
},
injury: 0,
gold: params.gold ?? 1000,
rice: params.rice ?? 1000,
crew: params.crew ?? 500,
crewTypeId: 1,
train: 20,
atmos: 20,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {
killturn: 24,
...(params.meta ?? {}),
},
});
const makeNation = (params: {
id: number;
name?: string;
level?: number;
typeCode?: string;
capitalCityId?: number | null;
chiefGeneralId?: number | null;
gold?: number;
rice?: number;
meta?: Record<string, unknown>;
}): Nation => ({
id: params.id,
name: params.name ?? `국가${params.id}`,
color: '#ff0000',
capitalCityId: params.capitalCityId ?? 1,
chiefGeneralId: params.chiefGeneralId ?? 1,
gold: params.gold ?? 10000,
rice: params.rice ?? 10000,
power: 0,
level: params.level ?? 1,
typeCode: params.typeCode ?? 'che_def',
meta: {
killturn: 24,
...(params.meta ?? {}),
},
});
const makeCity = (params: { id: number; nationId: number; level?: number; supplyState?: number }): City => ({
id: params.id,
name: `도시${params.id}`,
nationId: params.nationId,
level: params.level ?? 1,
state: 0,
population: 20000,
populationMax: 50000,
agriculture: 500,
agricultureMax: 1000,
commerce: 500,
commerceMax: 1000,
security: 500,
securityMax: 1000,
defence: 300,
defenceMax: 1000,
wall: 300,
wallMax: 1000,
supplyState: params.supplyState ?? 1,
frontState: 0,
meta: { trust: 50, trade: 100 },
});
const makeSnapshot = (params: {
generals: General[];
nations: Nation[];
cities: City[];
startYear?: number;
}): WorldSnapshot => ({
scenarioConfig: {
environment: { mapName: 'minimal_map', unitSet: 'default' },
options: {},
const: {},
} as any,
scenarioMeta: {
title: '테스트',
startYear: params.startYear ?? 200,
life: 0,
fiction: 0,
history: [],
ignoreDefaultEvents: false,
},
map: MINIMAL_MAP,
unitSet: { id: 'default', name: 'default', crewTypes: [] } as any,
nations: params.nations,
cities: params.cities,
generals: params.generals,
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
});
describe('migrated general commands', () => {
it('che_모반시도: 군주를 찬탈한다', async () => {
const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, experience: 1000 });
const chief = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '중신', officerLevel: 2, experience: 300 });
const nation = makeNation({ id: 1, name: '위', chiefGeneralId: 1, capitalCityId: 1, level: 1 });
const city = makeCity({ id: 1, nationId: 1 });
const world = new InMemoryWorld(makeSnapshot({ generals: [lord, chief], nations: [nation], cities: [city] }));
const runner = new TestGameRunner(world, 201, 1);
await runner.runTurn([
{
generalId: chief.id,
commandKey: 'che_모반시도',
resolver: rebellionSpec.createDefinition(SYSTEM_ENV),
args: {},
context: {
nationGenerals: [lord, chief],
},
},
]);
const updatedChief = world.getGeneral(chief.id)!;
const updatedLord = world.getGeneral(lord.id)!;
expect(updatedChief.officerLevel).toBe(12);
expect(updatedLord.officerLevel).toBe(1);
expect(updatedLord.experience).toBe(700);
});
it('che_선양: 제약 없는 대상에게 선양한다', async () => {
const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, experience: 1000 });
const heir = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '후계자', officerLevel: 1, experience: 200 });
const nation = makeNation({ id: 1, name: '촉', chiefGeneralId: 1, capitalCityId: 1, level: 1 });
const city = makeCity({ id: 1, nationId: 1 });
const world = new InMemoryWorld(makeSnapshot({ generals: [lord, heir], nations: [nation], cities: [city] }));
const runner = new TestGameRunner(world, 201, 1);
await runner.runTurn([
{
generalId: lord.id,
commandKey: 'che_선양',
resolver: abdicationSpec.createDefinition(SYSTEM_ENV),
args: { destGeneralID: heir.id },
context: {
destGeneral: heir,
},
},
]);
const updatedLord = world.getGeneral(lord.id)!;
const updatedHeir = world.getGeneral(heir.id)!;
expect(updatedHeir.officerLevel).toBe(12);
expect(updatedLord.officerLevel).toBe(1);
expect(updatedLord.experience).toBe(700);
});
it('che_증여: 최소 보유량을 넘는 자원만 이전한다', async () => {
const actor = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 });
const dest = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 });
const nation = makeNation({ id: 1, name: '오', chiefGeneralId: 1, capitalCityId: 1, level: 1 });
const city = makeCity({ id: 1, nationId: 1 });
const world = new InMemoryWorld(makeSnapshot({ generals: [actor, dest], nations: [nation], cities: [city] }));
const runner = new TestGameRunner(world, 201, 1);
await runner.runTurn([
{
generalId: actor.id,
commandKey: 'che_증여',
resolver: giftSpec.createDefinition(SYSTEM_ENV),
args: { isGold: true, amount: 500, destGeneralID: dest.id },
context: {
destGeneral: dest,
},
},
]);
expect(world.getGeneral(actor.id)!.gold).toBe(1000);
expect(world.getGeneral(dest.id)!.gold).toBe(500);
});
it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => {
const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, gold: 1500, rice: 1800 });
const member = makeGeneral({ id: 2, nationId: 1, cityId: 2, name: '부하', officerLevel: 1, gold: 2500, rice: 500 });
const nation = makeNation({
id: 1,
name: '방랑군',
chiefGeneralId: 1,
capitalCityId: 1,
level: 0,
typeCode: 'None',
meta: {},
});
const city1 = makeCity({ id: 1, nationId: 1, level: 5 });
const city2 = makeCity({ id: 2, nationId: 1, level: 5 });
const world = new InMemoryWorld(
makeSnapshot({ generals: [lord, member], nations: [nation], cities: [city1, city2] })
);
const runner = new TestGameRunner(world, 201, 2);
await runner.runTurn([
{
generalId: lord.id,
commandKey: 'che_해산',
resolver: disbandSpec.createDefinition(SYSTEM_ENV),
args: {},
context: {
nationGenerals: [lord, member],
nationCities: [city1, city2],
currentYearMonth: 201 * 12 + 2 - 1,
initYearMonth: 201 * 12 + 1 - 1,
},
},
]);
const updatedLord = world.getGeneral(lord.id)!;
const updatedMember = world.getGeneral(member.id)!;
expect(updatedLord.nationId).toBe(0);
expect(updatedMember.nationId).toBe(0);
expect(updatedLord.meta.makelimit).toBe(12);
expect(updatedLord.gold).toBe(1000);
expect(updatedMember.gold).toBe(1000);
expect(world.getCity(1)!.nationId).toBe(0);
expect(world.getCity(2)!.nationId).toBe(0);
expect(world.getNation(1)!.meta.collapsed).toBe(true);
});
it('cr_건국: 국가 정보를 건국 상태로 갱신한다', async () => {
const lord = makeGeneral({
id: 1,
nationId: 1,
cityId: 1,
name: '건국자',
officerLevel: 12,
experience: 200,
dedication: 300,
});
const follower = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '추종자', officerLevel: 1 });
const nation = makeNation({
id: 1,
name: '옛국가',
chiefGeneralId: 1,
capitalCityId: null,
level: 0,
typeCode: 'None',
meta: {},
});
const city = makeCity({ id: 1, nationId: 0, level: 5 });
const world = new InMemoryWorld(
makeSnapshot({ generals: [lord, follower], nations: [nation], cities: [city], startYear: 200 })
);
const runner = new TestGameRunner(world, 201, 1);
await runner.runTurn([
{
generalId: lord.id,
commandKey: 'cr_건국',
resolver: foundNationSpec.createDefinition(SYSTEM_ENV),
args: { nationName: '신국', nationType: 'che_def', colorType: 1 },
},
]);
const updatedNation = world.getNation(1)!;
const updatedLord = world.getGeneral(1)!;
expect(updatedNation.name).toBe('신국');
expect(updatedNation.level).toBe(1);
expect(updatedNation.capitalCityId).toBe(1);
expect(world.getCity(1)!.nationId).toBe(1);
expect(updatedLord.experience).toBe(1200);
expect(updatedLord.dedication).toBe(1300);
});
it('che_장비매매: 동일 장비 재구매는 AlwaysFail 제약으로 막는다', () => {
const general = makeGeneral({
id: 10,
nationId: 1,
cityId: 1,
gold: 5000,
items: { weapon: 'testSword' },
});
const city = makeCity({ id: 1, nationId: 1, level: 5 });
const nation = makeNation({ id: 1, level: 1, chiefGeneralId: 10, capitalCityId: 1 });
const itemCatalog: Record<string, TurnCommandItemCatalogEntry> = {
testSword: {
slot: 'weapon',
name: '테스트검',
rawName: '테스트검',
cost: 500,
reqSecu: 0,
buyable: true,
unique: false,
},
};
const definition = tradeItemSpec.createDefinition({
...SYSTEM_ENV,
itemCatalog,
});
const args = { itemType: 'weapon' as const, itemCode: 'testSword' };
const context: ConstraintContext = {
actorId: general.id,
cityId: city.id,
nationId: nation.id,
args,
env: {},
mode: 'full',
};
const view: StateView = {
has: (req: RequirementKey) => {
if (req.kind === 'general') return req.id === general.id;
if (req.kind === 'city') return req.id === city.id;
if (req.kind === 'nation') return req.id === nation.id;
return false;
},
get: (req: RequirementKey) => {
if (req.kind === 'general' && req.id === general.id) return general;
if (req.kind === 'city' && req.id === city.id) return city;
if (req.kind === 'nation' && req.id === nation.id) return nation;
return null;
},
};
const result = evaluateActionConstraints(definition, context, view, args);
expect(result.kind).toBe('deny');
if (result.kind === 'deny') {
expect(result.constraintName).toBe('AlwaysFail');
}
});
});
+18
View File
@@ -13,5 +13,23 @@
"다음 턴부터 건국할 수 있습니다."
],
"regex": []
},
"General/che_모반시도": {
"templates": [
"<Y>${}</>에게 군주의 자리를 뺏겼습니다."
],
"regex": []
},
"General/che_선양": {
"templates": [
"<Y>${}</>에게서 군주의 자리를 물려받습니다."
],
"regex": []
},
"General/che_장비매매": {
"templates": [
"${}"
],
"regex": []
}
}