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:
@@ -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'),
|
||||
|
||||
@@ -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 }] : []),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,5 +13,23 @@
|
||||
"다음 턴부터 건국할 수 있습니다."
|
||||
],
|
||||
"regex": []
|
||||
},
|
||||
"General/che_모반시도": {
|
||||
"templates": [
|
||||
"<Y>${}</>에게 군주의 자리를 뺏겼습니다."
|
||||
],
|
||||
"regex": []
|
||||
},
|
||||
"General/che_선양": {
|
||||
"templates": [
|
||||
"<Y>${}</>에게서 군주의 자리를 물려받습니다."
|
||||
],
|
||||
"regex": []
|
||||
},
|
||||
"General/che_장비매매": {
|
||||
"templates": [
|
||||
"${}"
|
||||
],
|
||||
"regex": []
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user