코드 이식
This commit is contained in:
@@ -31,61 +31,61 @@ which commands are available via `GameConst` overrides. See
|
||||
|
||||
| Key | Display name (actionName) |
|
||||
| -------------------- | ------------------------- |
|
||||
| `che_NPC능동` | `NPC능동` |
|
||||
| `che_강행` | `강행` |
|
||||
| `che_거병` | `거병` |
|
||||
| `che_건국` | `건국` |
|
||||
| `che_견문` | `견문` |
|
||||
| `che_군량매매` | `군량매매` |
|
||||
| `che_귀환` | `귀환` |
|
||||
| `che_기술연구` | `기술 연구` |
|
||||
| `che_내정특기초기화` | `내정 특기 초기화` |
|
||||
| `che_농지개간` | `농지 개간` |
|
||||
| `che_단련` | `단련` |
|
||||
| `che_등용` | `등용` |
|
||||
| `che_등용수락` | `등용 수락` |
|
||||
| `che_랜덤임관` | `무작위 국가로 임관` |
|
||||
| `che_모반시도` | `모반시도` |
|
||||
| `che_모병` | `모병` |
|
||||
| `che_무작위건국` | `무작위 도시 건국` |
|
||||
| `che_물자조달` | `물자조달` |
|
||||
| `che_방랑` | `방랑` |
|
||||
| `che_사기진작` | `사기진작` |
|
||||
| `che_상업투자` | `상업 투자` |
|
||||
| `che_선동` | `선동` |
|
||||
| `che_선양` | `선양` |
|
||||
| `che_성벽보수` | `성벽 보수` |
|
||||
| `che_소집해제` | `소집해제` |
|
||||
| `che_수비강화` | `수비 강화` |
|
||||
| `che_숙련전환` | `숙련전환` |
|
||||
| `che_요양` | `요양` |
|
||||
| `che_은퇴` | `은퇴` |
|
||||
| `che_이동` | `이동` |
|
||||
| `che_인재탐색` | `인재탐색` |
|
||||
| `che_임관` | `임관` |
|
||||
| `che_장비매매` | `장비매매` |
|
||||
| `che_장수대상임관` | `장수를 따라 임관` |
|
||||
| `che_전투태세` | `전투태세` |
|
||||
| `che_전투특기초기화` | `전투 특기 초기화` |
|
||||
| `che_접경귀환` | `접경귀환` |
|
||||
| `che_정착장려` | `정착 장려` |
|
||||
| `che_주민선정` | `주민 선정` |
|
||||
| `che_증여` | `증여` |
|
||||
| `che_집합` | `집합` |
|
||||
| `che_징병` | `징병` |
|
||||
| `che_첩보` | `첩보` |
|
||||
| `che_출병` | `출병` |
|
||||
| `che_치안강화` | `치안 강화` |
|
||||
| `che_탈취` | `탈취` |
|
||||
| `che_파괴` | `파괴` |
|
||||
| `che_하야` | `하야` |
|
||||
| `che_해산` | `해산` |
|
||||
| `che_헌납` | `헌납` |
|
||||
| `che_화계` | `화계` |
|
||||
| `che_훈련` | `훈련` |
|
||||
| `cr_건국` | `건국` |
|
||||
| `cr_맹훈련` | `맹훈련` |
|
||||
| `휴식` | `휴식` |
|
||||
| `che_NPC능동` | `NPC능동` | Planned |
|
||||
| `che_강행` | `강행` | Planned |
|
||||
| `che_거병` | `거병` | Ported |
|
||||
| `che_건국` | `건국` | Ported |
|
||||
| `che_견문` | `견문` | Ported |
|
||||
| `che_군량매매` | `군량매매` | Ported |
|
||||
| `che_귀환` | `귀환` | Planned |
|
||||
| `che_기술연구` | `기술 연구` | Ported |
|
||||
| `che_내정특기초기화` | `내정 특기 초기화` | Ported |
|
||||
| `che_농지개간` | `농지 개간` | Ported |
|
||||
| `che_단련` | `단련` | Ported |
|
||||
| `che_등용` | `등용` | Ported |
|
||||
| `che_등용수락` | `등용 수락` | Planned |
|
||||
| `che_랜덤임관` | `무작위 국가로 임관` | Planned |
|
||||
| `che_모반시도` | `모반시도` | Planned |
|
||||
| `che_모병` | `모병` | Ported |
|
||||
| `che_무작위건국` | `무작위 도시 건국` | Planned |
|
||||
| `che_물자조달` | `물자조달` | Ported |
|
||||
| `che_방랑` | `방랑` | Ported |
|
||||
| `che_사기진작` | `사기진작` | Ported |
|
||||
| `che_상업투자` | `상업 투자` | Ported |
|
||||
| `che_선동` | `선동` | Ported |
|
||||
| `che_선양` | `선양` | Planned |
|
||||
| `che_성벽보수` | `성벽 보수` | Ported |
|
||||
| `che_소집해제` | `소집해제` | Ported |
|
||||
| `che_수비강화` | `수비 강화` | Ported |
|
||||
| `che_숙련전환` | `숙련전환` | Ported |
|
||||
| `che_요양` | `요양` | Ported |
|
||||
| `che_은퇴` | `은퇴` | Ported |
|
||||
| `che_이동` | `이동` | Ported |
|
||||
| `che_인재탐색` | `인재탐색` | Ported |
|
||||
| `che_임관` | `임관` | Ported |
|
||||
| `che_장비매매` | `장비매매` | Planned |
|
||||
| `che_장수대상임관` | `장수를 따라 임관` | Planned |
|
||||
| `che_전투태세` | `전투태세` | Planned |
|
||||
| `che_전투특기초기화` | `전투 특기 초기화` | Ported |
|
||||
| `che_접경귀환` | `접경귀환` | Planned |
|
||||
| `che_정착장려` | `정착 장려` | Ported |
|
||||
| `che_주민선정` | `주민 선정` | Ported |
|
||||
| `che_증여` | `증여` | Planned |
|
||||
| `che_집합` | `집합` | Ported |
|
||||
| `che_징병` | `징병` | Ported |
|
||||
| `che_첩보` | `첩보` | Ported |
|
||||
| `che_출병` | `출병` | Ported |
|
||||
| `che_치안강화` | `치안 강화` | Ported |
|
||||
| `che_탈취` | `탈취` | Ported |
|
||||
| `che_파괴` | `파괴` | Ported |
|
||||
| `che_하야` | `하야` | Ported |
|
||||
| `che_해산` | `해산` | Planned |
|
||||
| `che_헌납` | `헌납` | Ported |
|
||||
| `che_화계` | `화계` | Ported |
|
||||
| `che_훈련` | `훈련` | Ported |
|
||||
| `cr_건국` | `건국` | Planned |
|
||||
| `cr_맹훈련` | `맹훈련` | Planned |
|
||||
| `휴식` | `휴식` | Ported |
|
||||
|
||||
## Nation Commands (`legacy/hwe/sammo/Command/Nation/`)
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||
import {
|
||||
mustBeNPC,
|
||||
reqGeneralGold,
|
||||
unknownOrDeny,
|
||||
existsDestCity,
|
||||
} from '@sammo-ts/logic/constraints/presets.js';
|
||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||
import type {
|
||||
GeneralActionOutcome,
|
||||
GeneralActionResolveContext,
|
||||
GeneralActionResolver,
|
||||
GeneralActionEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
|
||||
export interface NPCSelfArgs {
|
||||
optionText: string;
|
||||
destCityId?: number;
|
||||
}
|
||||
|
||||
export type NPCSelfResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> = GeneralActionResolveContext<TriggerState> & {
|
||||
map?: MapDefinition;
|
||||
};
|
||||
|
||||
const ACTION_NAME = 'NPC능동';
|
||||
const ACTION_KEY = 'che_NPC능동';
|
||||
|
||||
export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> implements GeneralActionResolver<TriggerState, NPCSelfArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
|
||||
resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||
|
||||
if (args.optionText === '순간이동') {
|
||||
if (args.destCityId === undefined) {
|
||||
// Should be caught by constraints/validation, but safe guard
|
||||
throw new Error('Missing destCityId for instant move');
|
||||
}
|
||||
|
||||
const destCityId = args.destCityId;
|
||||
let destCityName = `도시(${destCityId})`;
|
||||
if (context.map) {
|
||||
const c = context.map.cities.find((ct) => ct.id === destCityId);
|
||||
if (c) destCityName = c.name;
|
||||
}
|
||||
|
||||
const josaRo = JosaUtil.pick(destCityName, '로');
|
||||
|
||||
// Legacy log: "NPC 전용 명령을 이용해 {$cityName}{$josaRo} 이동했습니다."
|
||||
// Note: Legacy didn't use <G> tag here based on file content, but most move commands do.
|
||||
// Following strict legacy log parity from file:
|
||||
// "NPC 전용 명령을 이용해 {$cityName}{$josaRo} 이동했습니다."
|
||||
context.addLog(`NPC 전용 명령을 이용해 ${destCityName}${josaRo} 이동했습니다.`, {
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
|
||||
effects.push(
|
||||
createGeneralPatchEffect(
|
||||
{
|
||||
...general,
|
||||
cityId: destCityId,
|
||||
// Legacy doesn't show cost/dedication/exp change in 'che_NPC능동.php' run() method for '순간이동'
|
||||
// It only does setVar('city', destCityID) and Logging and LastTurn.
|
||||
},
|
||||
general.id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> implements GeneralActionDefinition<TriggerState, NPCSelfArgs, NPCSelfResolveContext<TriggerState>> {
|
||||
public readonly key = ACTION_KEY;
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor() {
|
||||
this.resolver = new ActionResolver();
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): NPCSelfArgs | null {
|
||||
const data = raw as Partial<NPCSelfArgs>;
|
||||
if (!data.optionText) return null;
|
||||
|
||||
if (data.optionText === '순간이동') {
|
||||
if (typeof data.destCityId !== 'number') return null;
|
||||
// We can check city existence here deeply but constraint is better place usually,
|
||||
// simplified parse just structural.
|
||||
return { optionText: data.optionText, destCityId: data.destCityId };
|
||||
}
|
||||
|
||||
// Only '순간이동' implemented in legacy file shown.
|
||||
return null;
|
||||
}
|
||||
|
||||
buildConstraints(ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] {
|
||||
const constraints = [
|
||||
mustBeNPC()
|
||||
];
|
||||
|
||||
if (args.optionText === '순간이동') {
|
||||
constraints.push(existsDestCity());
|
||||
}
|
||||
|
||||
return constraints;
|
||||
}
|
||||
|
||||
resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> {
|
||||
return this.resolver.resolve(context, args);
|
||||
}
|
||||
}
|
||||
|
||||
export const commandSpec: GeneralTurnCommandSpec = {
|
||||
key: 'che_NPC능동',
|
||||
category: '특수', // Valid category? Legacy didn't specify category in static prop usually, handled by mapping. Defaulting to '특수'.
|
||||
reqArg: true,
|
||||
args: { optionText: '', destCityId: 0 },
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||
import {
|
||||
notSameDestCity,
|
||||
nearCity,
|
||||
reqGeneralGold,
|
||||
reqGeneralRice,
|
||||
unknownOrDeny,
|
||||
existsDestCity,
|
||||
} from '@sammo-ts/logic/constraints/presets.js';
|
||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||
import type {
|
||||
GeneralActionOutcome,
|
||||
GeneralActionResolveContext,
|
||||
GeneralActionResolver,
|
||||
GeneralActionEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
|
||||
export interface ForcedMoveArgs {
|
||||
destCityId: number;
|
||||
}
|
||||
|
||||
export interface ForcedMoveResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
moveGenerals?: General<TriggerState>[]; // For roaming move
|
||||
map?: MapDefinition;
|
||||
startDevelCost?: number;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '강행';
|
||||
const ACTION_KEY = 'che_강행';
|
||||
|
||||
export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> implements GeneralActionResolver<TriggerState, ForcedMoveArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
|
||||
resolve(context: ForcedMoveResolveContext<TriggerState>, args: ForcedMoveArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
const nation = context.nation;
|
||||
const { destCityId } = args;
|
||||
|
||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||
|
||||
// Determine if roaming leader logic applies
|
||||
// Legacy: if ($general->getVar('officer_level') == 12 && $this->nation['level'] == 0)
|
||||
// Roaming nation leader moving -> moves everyone in nation that isn't self (handled in legacy by finding generals)
|
||||
// Actually legacy loop: SELECT no FROM general WHERE nation=%i AND no!=%i
|
||||
|
||||
const isRoamingLeader = general.officerLevel === 12 && nation && nation.level === 0;
|
||||
let moveTargets: General<TriggerState>[] = [general];
|
||||
|
||||
if (isRoamingLeader && context.moveGenerals) {
|
||||
const others = context.moveGenerals.filter((g) => g.nationId === nation.id && g.id !== general.id);
|
||||
// Legacy updates DB directly for others, and logs for them.
|
||||
// Here we queue patch effects for everyone.
|
||||
moveTargets = [general, ...others]; // Self first
|
||||
}
|
||||
|
||||
// Cost calculation
|
||||
// Legacy: env['develcost'] * 5 gold.
|
||||
const develCost = context.startDevelCost ?? 0;
|
||||
const goldCost = develCost * 5;
|
||||
|
||||
// Log destination
|
||||
let destCityName = `도시(${destCityId})`;
|
||||
if (context.map) {
|
||||
const c = context.map.cities.find((ct) => ct.id === destCityId);
|
||||
if (c) destCityName = c.name;
|
||||
}
|
||||
|
||||
const josaRo = JosaUtil.pick(destCityName, '로');
|
||||
|
||||
// Log for self: "<G><b>{$destCityName}</b></>{$josaRo} 강행했습니다. <1>$date</>"
|
||||
context.addLog(`<G><b>${destCityName}</b></>${josaRo} 강행했습니다.`, {
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
|
||||
// Effects for self:
|
||||
// city = dest
|
||||
// gold -= cost (limit 0)
|
||||
// train -= 5 (limit 20)
|
||||
// atmos -= 5 (limit 20)
|
||||
// exp += 100
|
||||
// leadership_exp += 1
|
||||
|
||||
const nextGold = Math.max(0, general.gold - goldCost);
|
||||
const nextTrain = Math.max(20, general.train - 5);
|
||||
const nextAtmos = Math.max(20, general.atmos - 5);
|
||||
const nextExp = general.experience + 100;
|
||||
const nextLeadershipExp = (typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1;
|
||||
|
||||
effects.push(
|
||||
createGeneralPatchEffect(
|
||||
{
|
||||
...general,
|
||||
cityId: destCityId,
|
||||
gold: nextGold,
|
||||
train: nextTrain,
|
||||
atmos: nextAtmos,
|
||||
experience: nextExp,
|
||||
meta: {
|
||||
...general.meta,
|
||||
leadership_exp: nextLeadershipExp,
|
||||
},
|
||||
},
|
||||
general.id
|
||||
)
|
||||
);
|
||||
|
||||
// Effects/Logs for subordinates (if roaming leader)
|
||||
if (isRoamingLeader && moveTargets.length > 1) {
|
||||
for (const target of moveTargets) {
|
||||
if (target.id === general.id) continue;
|
||||
|
||||
// Legacy: "방랑군 세력이 <G><b>{$destCityName}</b></>{$josaRo} 강행했습니다." (LOG_PLAIN)
|
||||
// NOTE: We need a way to push log to OTHER general's logger.
|
||||
// In current engine, we return effects. Log effects?
|
||||
// Currently addLog attaches provided logs to turnLog (which is for the actor).
|
||||
// To log for OTHERS, we might need specific effect or handle it differently.
|
||||
// For now, I will omit logs for others or use a special effect if available.
|
||||
// The legacy TS porting pattern for "others" logs isn't fully standardized yet in shared snippets.
|
||||
// Assuming createGeneralPatchEffect handles state. Logs for others might be missing in this iteration unless I find `createLogEffect`.
|
||||
|
||||
effects.push(
|
||||
createGeneralPatchEffect(
|
||||
{
|
||||
...target,
|
||||
cityId: destCityId
|
||||
},
|
||||
target.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState
|
||||
> implements GeneralActionDefinition<TriggerState, ForcedMoveArgs, ForcedMoveResolveContext<TriggerState>> {
|
||||
public readonly key = ACTION_KEY;
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor() {
|
||||
this.resolver = new ActionResolver();
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): ForcedMoveArgs | null {
|
||||
const data = raw as Partial<ForcedMoveArgs>;
|
||||
if (typeof data.destCityId !== 'number') return null;
|
||||
return { destCityId: data.destCityId };
|
||||
}
|
||||
|
||||
buildConstraints(ctx: ConstraintContext, _args: ForcedMoveArgs): Constraint[] {
|
||||
return [
|
||||
existsDestCity(),
|
||||
notSameDestCity(),
|
||||
nearCity(3),
|
||||
reqGeneralGold((_c, _v) => {
|
||||
const cost = ctx.env.develCost as number;
|
||||
return (cost ?? 0) * 5;
|
||||
}),
|
||||
reqGeneralRice(() => 0) // Legacy checks cost[1] which is 0, but included constraint.
|
||||
];
|
||||
}
|
||||
|
||||
resolve(context: ForcedMoveResolveContext<TriggerState>, args: ForcedMoveArgs): GeneralActionOutcome<TriggerState> {
|
||||
return this.resolver.resolve(context, args);
|
||||
}
|
||||
}
|
||||
|
||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
return {
|
||||
...base,
|
||||
moveGenerals: options.worldRef?.listGenerals() ?? [],
|
||||
map: options.map,
|
||||
startDevelCost: options.scenarioConfig.const.develCost as number | undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const commandSpec: GeneralTurnCommandSpec = {
|
||||
key: 'che_강행',
|
||||
category: '군사',
|
||||
reqArg: true,
|
||||
args: { destCityId: 0 },
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
|
||||
import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||
import {
|
||||
allow,
|
||||
notBeNeutral,
|
||||
notWanderingNation,
|
||||
notCapital,
|
||||
readMetaNumberFromUnknown,
|
||||
unknownOrDeny,
|
||||
} from '@sammo-ts/logic/constraints/presets.js';
|
||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||
import type {
|
||||
GeneralActionOutcome,
|
||||
GeneralActionResolveContext,
|
||||
GeneralActionResolver,
|
||||
GeneralActionEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
|
||||
export interface ReturnArgs { }
|
||||
|
||||
const ACTION_NAME = '귀환';
|
||||
const ACTION_KEY = 'che_귀환';
|
||||
|
||||
export interface ReturnResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
nationCities?: City[];
|
||||
// Need city list to find officer city name? Or just assume it exists if ID is valid?
|
||||
// Actually we need the city object to get its name for logging.
|
||||
// Legacy: CityConst::byID($destCityID)->name
|
||||
}
|
||||
|
||||
export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionResolver<TriggerState, ReturnArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
|
||||
resolve(context: ReturnResolveContext<TriggerState>, _args: ReturnArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
const nation = context.nation;
|
||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||
|
||||
if (!nation) {
|
||||
throw new Error('Return requires a nation context.');
|
||||
}
|
||||
|
||||
const officerLevel = general.officerLevel;
|
||||
let destCityId: number | null = null;
|
||||
|
||||
// Logic: specific officer levels return to their assigned city
|
||||
// 2 (Chief?), 3 (Staff?), 4 (Governor/Taishu)
|
||||
if (officerLevel >= 2 && officerLevel <= 4) {
|
||||
const officerCity = readMetaNumberFromUnknown(general.meta, 'officer_city');
|
||||
if (officerCity) {
|
||||
destCityId = officerCity;
|
||||
}
|
||||
}
|
||||
|
||||
if (destCityId === null) {
|
||||
destCityId = nation.capitalCityId;
|
||||
}
|
||||
|
||||
if (destCityId === null) {
|
||||
throw new Error('Destination city not found (No capital?).');
|
||||
}
|
||||
|
||||
// We need city name for log.
|
||||
// If we don't have city object in context, we can't log name easily unless we fetch it.
|
||||
// We should add `getWorldCity(id)` to context or something.
|
||||
// Or assume resolving involves looking up city.
|
||||
|
||||
// For now, let's look it up from `context.nationCities` if available, or request it in context builder.
|
||||
// Actually context has `city` (location), but dest is different.
|
||||
// We need to request `destCity` in context builder? But we determine it dynamically.
|
||||
// We can request ALL cities or use `worldRef` if available in context?
|
||||
// `GeneralActionResolveContext` usually doesn't have `worldRef`.
|
||||
// We must rely on `contextBuilder` to populate necessary data.
|
||||
|
||||
// Let's iterate `nationCities` if provided.
|
||||
// Or use `ActionContextBuilder` to fetch city by ID if we knew it?
|
||||
// We don't know ID until we check logic.
|
||||
|
||||
// In `ActionContextBuilder`, we can just load all cities of the nation?
|
||||
// `nationCities` is common.
|
||||
|
||||
let destCityName = '알 수 없는 도시';
|
||||
let foundDestCity: City | undefined;
|
||||
|
||||
if (context.nationCities) {
|
||||
foundDestCity = context.nationCities.find(c => c.id === destCityId);
|
||||
if (foundDestCity) {
|
||||
destCityName = foundDestCity.name;
|
||||
}
|
||||
}
|
||||
|
||||
const josaRo = JosaUtil.pick(destCityName, '로');
|
||||
const date = 'XX:XX'; // Placeholder for date, actual date is handled by log format usually, but legacy includes it explicitly?
|
||||
// Legacy: <1>$date</>
|
||||
// LogFormat.MONTH handles date prefix usually. LogFormat.HM handles HH:MM.
|
||||
// Legacy output: "StartCity로 귀환했습니다. 10:00"
|
||||
|
||||
context.addLog(`<G><b>${destCityName}</b></>${josaRo} 귀환했습니다.`, {
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH, // Or HM if we want HH:MM suffix?
|
||||
// Legacy uses explicit date at end.
|
||||
// We can just use standard MONTH format for now.
|
||||
});
|
||||
|
||||
const exp = 70;
|
||||
const ded = 100;
|
||||
|
||||
effects.push(createGeneralPatchEffect({
|
||||
...general,
|
||||
cityId: destCityId,
|
||||
experience: general.experience + exp,
|
||||
dedication: general.dedication + ded,
|
||||
stats: {
|
||||
...general.stats,
|
||||
leadership: general.stats.leadership, // Update not needed unless verified
|
||||
},
|
||||
// leadership_exp + 1 in legacy?
|
||||
// "increaseVar('leadership_exp', 1)"
|
||||
// General entity doesn't show leadership_exp in Interface?
|
||||
// Checking `entities.ts`... `stats` is Leadership/Strength/Intel.
|
||||
// `experience` is total exp?
|
||||
// If `leadership_exp` is missing in Entity, we must use meta or omit.
|
||||
// Default to meta if needed.
|
||||
meta: {
|
||||
...general.meta,
|
||||
leadership_exp: (readMetaNumberFromUnknown(general.meta, 'leadership_exp') ?? 0) + 1
|
||||
}
|
||||
}, general.id));
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionDefinition<TriggerState, ReturnArgs, ReturnResolveContext<TriggerState>> {
|
||||
public readonly key = ACTION_KEY;
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor() {
|
||||
this.resolver = new ActionResolver();
|
||||
}
|
||||
|
||||
parseArgs(_raw: unknown): ReturnArgs | null {
|
||||
return {};
|
||||
}
|
||||
|
||||
buildConstraints(_ctx: ConstraintContext, _args: ReturnArgs): Constraint[] {
|
||||
return [
|
||||
notBeNeutral(),
|
||||
notWanderingNation(),
|
||||
notCapital(true), // Check if ALREADY in capital (true = check current city)
|
||||
];
|
||||
}
|
||||
|
||||
resolve(context: ReturnResolveContext<TriggerState>, args: ReturnArgs): GeneralActionOutcome<TriggerState> {
|
||||
return this.resolver.resolve(context, args);
|
||||
}
|
||||
}
|
||||
|
||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
return {
|
||||
...base,
|
||||
// We effectively need all cities to find the destination name if it's not the capital.
|
||||
// Or at least nation cities.
|
||||
nationCities: options.worldRef?.listCities().filter(c => c.nationId === base.nation?.id) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
export const commandSpec: GeneralTurnCommandSpec = {
|
||||
key: 'che_귀환',
|
||||
category: '군사',
|
||||
reqArg: false,
|
||||
args: {},
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
|
||||
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||
import {
|
||||
allow,
|
||||
existsDestNation,
|
||||
existsDestGeneral,
|
||||
notSameDestNation,
|
||||
destGeneralInDestNation,
|
||||
notLord,
|
||||
readMetaNumberFromUnknown,
|
||||
unknownOrDeny,
|
||||
} from '@sammo-ts/logic/constraints/presets.js';
|
||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||
import type {
|
||||
GeneralActionOutcome,
|
||||
GeneralActionResolveContext,
|
||||
GeneralActionResolver,
|
||||
GeneralActionEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
|
||||
export interface AcceptScoutArgs {
|
||||
destNationId: number;
|
||||
destGeneralId: number;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '등용수락';
|
||||
const ACTION_KEY = 'che_등용수락';
|
||||
|
||||
export interface AcceptScoutResolveContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
destNation?: Nation;
|
||||
destGeneral?: General<TriggerState>;
|
||||
}
|
||||
|
||||
export class ActionResolver<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionResolver<TriggerState, AcceptScoutArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
|
||||
resolve(context: AcceptScoutResolveContext<TriggerState>, _args: AcceptScoutArgs): GeneralActionOutcome<TriggerState> {
|
||||
const general = context.general;
|
||||
const currentNation = context.nation;
|
||||
const destNation = context.destNation;
|
||||
const destGeneral = context.destGeneral;
|
||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||
|
||||
if (!destNation) throw new Error('Target nation not found.');
|
||||
if (!destGeneral) throw new Error('Recruiter not found.');
|
||||
|
||||
// 1. Logs
|
||||
const destNationName = destNation.name;
|
||||
const recruiterName = destGeneral.name;
|
||||
const generalName = general.name;
|
||||
|
||||
const josaRo = JosaUtil.pick(destNationName, '로');
|
||||
const josaYi = JosaUtil.pick(generalName, '이');
|
||||
|
||||
// Self Log
|
||||
context.addLog(`<D>${destNationName}</>${josaRo} 망명하여 수도로 이동합니다.`, { // Text says "Move to Capital", but logic might move to recruiter city.
|
||||
// Legacy log says "수도로 이동합니다", but implementation moves to destGeneral city if present!
|
||||
// We should match implementation or text? Text is just flavor.
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.InGame, // Using InGame or specific format?
|
||||
});
|
||||
|
||||
// Recruiter Log
|
||||
// We need to add log to recruiter? `context.addSideEffectLog`?
|
||||
// Current system mostly logs for the actor.
|
||||
// If we want to log for recruiter, we might need a way to push logs to others in `effects` or strictly via `addLog` with target?
|
||||
// `GeneralActionResolveContext` usually implies logs are for the actor.
|
||||
// But `GeneralTurnOutcome` doesn't explicitly return logs for others.
|
||||
// We can create a patch for recruiter that appends to their log?
|
||||
// Or usage of `addLog` might support target? No, `addLog` in context usually targets actor.
|
||||
// We will skip Recruiter Log for now or rely on Global Log.
|
||||
|
||||
// Global Log
|
||||
context.addLog(`<Y>${generalName}</>${josaYi} <D><b>${destNationName}</b></>${josaRo} <S>망명</>하였습니다.`, {
|
||||
category: LogCategory.ACTION, // Global category?
|
||||
format: LogFormat.InGame, // Global logs are handled by system?
|
||||
// In new system, we might need to specify it.
|
||||
});
|
||||
|
||||
// 2. Recruiter Rewards
|
||||
effects.push(createGeneralPatchEffect({
|
||||
experience: destGeneral.experience + 100,
|
||||
dedication: destGeneral.dedication + 100,
|
||||
}, destGeneral.id));
|
||||
|
||||
// 3. Betrayal Logic
|
||||
// If currentNation exists (and > 0), handle betrayal return logic.
|
||||
const defaultGold = 1000; // From env? context.env.defaultNpcGold? Or GameConst?
|
||||
// Using context.env values if available. SystemEnv has `baseGold`?
|
||||
// Legacy: GameConst::$defaultGold (usually 1000/2000).
|
||||
const safeGold = context.env.baseGold || 1000;
|
||||
const safeRice = context.env.baseRice || 1000;
|
||||
|
||||
let newGold = general.gold;
|
||||
let newRice = general.rice;
|
||||
let newExp = general.experience;
|
||||
let newDed = general.dedication;
|
||||
|
||||
const betrayCount = (readMetaNumberFromUnknown(general.meta, 'betray') ?? 0);
|
||||
let newBetray = betrayCount;
|
||||
|
||||
if (currentNation && currentNation.id !== 0) {
|
||||
// Return excess gold/rice to current nation
|
||||
let returnGold = 0;
|
||||
let returnRice = 0;
|
||||
|
||||
if (general.gold > safeGold) {
|
||||
returnGold = general.gold - safeGold;
|
||||
newGold = safeGold;
|
||||
}
|
||||
if (general.rice > safeRice) {
|
||||
returnRice = general.rice - safeRice;
|
||||
newRice = safeRice;
|
||||
}
|
||||
|
||||
if (returnGold > 0 || returnRice > 0) {
|
||||
effects.push(createNationPatchEffect({
|
||||
gold: currentNation.gold + returnGold,
|
||||
rice: currentNation.rice + returnRice
|
||||
}, currentNation.id));
|
||||
}
|
||||
|
||||
// Penalty
|
||||
// 10% * betray count deduction
|
||||
const penaltyFactor = 1 - (0.1 * betrayCount);
|
||||
if (penaltyFactor < 0) { // Should not be less than 0? capped at ?
|
||||
// Legacy: (1 - 0.1 * betray).
|
||||
}
|
||||
// Apply penalty
|
||||
newExp = Math.floor(newExp * Math.max(0, penaltyFactor));
|
||||
newDed = Math.floor(newDed * Math.max(0, penaltyFactor));
|
||||
newBetray += 1;
|
||||
} else {
|
||||
// Neutral -> Join: Grant Bonus
|
||||
newExp += 100;
|
||||
newDed += 100;
|
||||
}
|
||||
|
||||
// 4. Update General (Self)
|
||||
let targetCityId = destGeneral.cityId; // Join recruiter
|
||||
// If recruiter is not valid city?
|
||||
if (!targetCityId) targetCityId = destNation.capitalCityId!;
|
||||
|
||||
effects.push(createGeneralPatchEffect({
|
||||
nationId: destNation.id,
|
||||
cityId: targetCityId,
|
||||
experience: newExp,
|
||||
dedication: newDed,
|
||||
gold: newGold,
|
||||
rice: newRice,
|
||||
officerLevel: 1, // Reset rank
|
||||
// officer_city: 0 via meta
|
||||
crew: general.crew, // Keep crew? Legacy implies checking troop leader.
|
||||
// If troop leader, disband troop.
|
||||
// TS entity `troopId`.
|
||||
troopId: 0, // Quit troop
|
||||
meta: {
|
||||
...general.meta,
|
||||
officer_city: 0,
|
||||
betray: newBetray,
|
||||
// killturn logic?
|
||||
}
|
||||
}, general.id));
|
||||
|
||||
// 5. Update Nations Gen Count (Visual only? or real count)
|
||||
// Legacy updates `gennum`.
|
||||
// We can create patches for nations if `gennum` is part of Nation entity?
|
||||
// Nation entity usually doesn't store computed `gennum` in TS domain?
|
||||
// If it's real column, we can update.
|
||||
// Checking entity: `Nation` interface does NOT have `gennum`.
|
||||
// So we skip updating gennum on Nation entity.
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionDefinition<TriggerState, AcceptScoutArgs, AcceptScoutResolveContext<TriggerState>> {
|
||||
public readonly key = ACTION_KEY;
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor() {
|
||||
this.resolver = new ActionResolver();
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): AcceptScoutArgs | null {
|
||||
// Validate args
|
||||
const args = raw as Partial<AcceptScoutArgs>;
|
||||
if (typeof args.destNationId !== 'number' || typeof args.destGeneralId !== 'number') return null;
|
||||
return { destNationId: args.destNationId, destGeneralId: args.destGeneralId };
|
||||
}
|
||||
|
||||
buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
|
||||
return [
|
||||
// notBeNeutral(), // Ignored to allow betrayal
|
||||
existsDestNation(),
|
||||
existsDestGeneral(), // Need to check if destGeneral exists
|
||||
notSameDestNation(),
|
||||
destGeneralInDestNation(),
|
||||
notLord(),
|
||||
];
|
||||
}
|
||||
|
||||
resolve(context: AcceptScoutResolveContext<TriggerState>, args: AcceptScoutArgs): GeneralActionOutcome<TriggerState> {
|
||||
return this.resolver.resolve(context, args);
|
||||
}
|
||||
}
|
||||
|
||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
// Populate destNation and destGeneral
|
||||
const args = base.args as Partial<AcceptScoutArgs>;
|
||||
let destNation: Nation | undefined;
|
||||
let destGeneral: General | undefined;
|
||||
|
||||
if (args.destNationId) {
|
||||
destNation = options.worldRef?.getNation(args.destNationId);
|
||||
}
|
||||
if (args.destGeneralId) {
|
||||
destGeneral = options.worldRef?.getGeneral(args.destGeneralId);
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
destNation,
|
||||
destGeneral,
|
||||
};
|
||||
};
|
||||
|
||||
export const commandSpec: GeneralTurnCommandSpec = {
|
||||
key: 'che_등용수락',
|
||||
category: '계략', // Strategy? or '인사'(Personnel)? Legacy not checked for category. "군사" in task.md?
|
||||
// che_등용수락 is usually separate.
|
||||
// che_등용 is 인사(Personnel).
|
||||
// Let's use '인사'.
|
||||
reqArg: true,
|
||||
args: {
|
||||
destNationId: 'number',
|
||||
destGeneralId: 'number',
|
||||
},
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
};
|
||||
@@ -3,6 +3,9 @@ import type { TurnCommandModule, TurnCommandSpecBase } from '@sammo-ts/logic/act
|
||||
export const GENERAL_TURN_COMMAND_KEYS = [
|
||||
'che_거병',
|
||||
'che_임관',
|
||||
'che_랜덤임관',
|
||||
'che_귀환',
|
||||
'che_등용수락',
|
||||
'che_건국',
|
||||
'che_훈련',
|
||||
'che_단련',
|
||||
@@ -39,6 +42,9 @@ export const GENERAL_TURN_COMMAND_KEYS = [
|
||||
'che_파괴',
|
||||
'che_선동',
|
||||
'che_탈취',
|
||||
'che_NPC능동',
|
||||
'che_강행',
|
||||
'che_귀환',
|
||||
'휴식',
|
||||
] as const;
|
||||
|
||||
@@ -53,6 +59,9 @@ export type GeneralTurnCommandImporter = () => Promise<GeneralTurnCommandModule>
|
||||
const defaultImporters: Record<GeneralTurnCommandKey, GeneralTurnCommandImporter> = {
|
||||
che_거병: async () => import('./che_거병.js'),
|
||||
che_임관: async () => import('./che_임관.js'),
|
||||
che_등용수락: () => import('./che_등용수락.js'),
|
||||
che_랜덤임관: () => import('./che_랜덤임관.js'),
|
||||
che_귀환: async () => import('./che_귀환.js'),
|
||||
che_건국: async () => import('./che_건국.js'),
|
||||
che_훈련: async () => import('./che_훈련.js'),
|
||||
che_단련: async () => import('./che_단련.js'),
|
||||
@@ -89,6 +98,8 @@ const defaultImporters: Record<GeneralTurnCommandKey, GeneralTurnCommandImporter
|
||||
che_파괴: async () => import('./che_파괴.js'),
|
||||
che_선동: async () => import('./che_선동.js'),
|
||||
che_탈취: async () => import('./che_탈취.js'),
|
||||
che_NPC능동: async () => import('./che_NPC능동.js'),
|
||||
che_강행: async () => import('./che_강행.js'),
|
||||
휴식: async () => import('./휴식.js'),
|
||||
};
|
||||
|
||||
@@ -100,7 +111,7 @@ export class GeneralTurnCommandLoader {
|
||||
|
||||
constructor(
|
||||
private readonly importers: Record<GeneralTurnCommandKey, GeneralTurnCommandImporter> = defaultImporters
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async load(key: GeneralTurnCommandKey): Promise<GeneralTurnCommandModule> {
|
||||
const cached = this.cache.get(key);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import { getCityDistance } from '../world/distance.js';
|
||||
import {
|
||||
allow,
|
||||
parsePercent,
|
||||
@@ -113,11 +114,11 @@ export const suppliedDestCity = (): Constraint => ({
|
||||
requires: (ctx) =>
|
||||
resolveDestCityId(ctx) !== undefined
|
||||
? [
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
test: (ctx, view) => {
|
||||
const destCity = readDestCity(ctx, view);
|
||||
@@ -249,11 +250,11 @@ export const existsDestCity = (): Constraint => ({
|
||||
requires: (ctx) =>
|
||||
resolveDestCityId(ctx) !== undefined
|
||||
? [
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
test: (ctx, view) => {
|
||||
const destCityId = resolveDestCityId(ctx);
|
||||
@@ -315,11 +316,11 @@ export const notNeutralDestCity = (): Constraint => ({
|
||||
requires: (ctx) =>
|
||||
resolveDestCityId(ctx) !== undefined
|
||||
? [
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
{
|
||||
kind: 'destCity',
|
||||
id: resolveDestCityId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
test: (ctx, view) => {
|
||||
const destCity = readDestCity(ctx, view);
|
||||
@@ -480,3 +481,94 @@ export const reqCityLevel = (levels: number[]): Constraint => ({
|
||||
return { kind: 'deny', reason: '규모가 맞지 않습니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const nearCity = (maxDistance: number): Constraint => ({
|
||||
name: 'nearCity',
|
||||
requires: (ctx) => {
|
||||
const reqs: RequirementKey[] = [{ kind: 'env', key: 'map' }];
|
||||
const destCityId = resolveDestCityId(ctx);
|
||||
if (destCityId !== undefined) {
|
||||
reqs.push({ kind: 'destCity', id: destCityId });
|
||||
}
|
||||
if (ctx.cityId !== undefined) {
|
||||
reqs.push({ kind: 'city', id: ctx.cityId });
|
||||
}
|
||||
return reqs;
|
||||
},
|
||||
test: (ctx, view) => {
|
||||
const map = view.get({ kind: 'env', key: 'map' }) as MapDefinition | null;
|
||||
if (!map) {
|
||||
return unknownOrDeny(ctx, [], '지도 정보가 없습니다.');
|
||||
}
|
||||
|
||||
const destCityId = resolveDestCityId(ctx);
|
||||
if (destCityId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||
}
|
||||
|
||||
const city = readCity(view, ctx.cityId);
|
||||
|
||||
if (!city) {
|
||||
if (ctx.cityId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||
}
|
||||
const req: RequirementKey = { kind: 'city', id: ctx.cityId };
|
||||
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
|
||||
}
|
||||
|
||||
const distance = getCityDistance(map, city.id, destCityId);
|
||||
if (distance <= maxDistance) {
|
||||
return allow();
|
||||
}
|
||||
|
||||
return { kind: 'deny', reason: '너무 멉니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const notCapital = (checkCurrentCity = false): Constraint => ({
|
||||
name: 'notCapital',
|
||||
requires: (ctx) => {
|
||||
const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }];
|
||||
if (checkCurrentCity) {
|
||||
if (ctx.cityId !== undefined) {
|
||||
reqs.push({ kind: 'city', id: ctx.cityId });
|
||||
}
|
||||
} else {
|
||||
// If checking dest, we need destCityId
|
||||
const destCityId = resolveDestCityId(ctx);
|
||||
if (destCityId !== undefined) {
|
||||
reqs.push({ kind: 'destCity', id: destCityId });
|
||||
}
|
||||
}
|
||||
// Need nation to know capital
|
||||
reqs.push({ kind: 'nation', id: ctx.nationId });
|
||||
return reqs;
|
||||
},
|
||||
test: (ctx, view) => {
|
||||
const nationReq: RequirementKey = { kind: 'nation', id: ctx.nationId };
|
||||
if (!view.has(nationReq)) return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.');
|
||||
const nation = view.get(nationReq) as Nation | null;
|
||||
if (!nation) return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.');
|
||||
|
||||
let targetCityId: number | undefined;
|
||||
|
||||
if (checkCurrentCity) {
|
||||
targetCityId = ctx.cityId;
|
||||
if (targetCityId === undefined) {
|
||||
const general = readGeneral(ctx, view);
|
||||
targetCityId = general?.cityId;
|
||||
}
|
||||
} else {
|
||||
targetCityId = resolveDestCityId(ctx);
|
||||
}
|
||||
|
||||
if (targetCityId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||
}
|
||||
|
||||
if (targetCityId === nation.capitalCityId) {
|
||||
return { kind: 'deny', reason: '수도입니다.' };
|
||||
}
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -181,11 +181,11 @@ export const existsDestGeneral = (): Constraint => ({
|
||||
requires: (ctx) =>
|
||||
resolveDestGeneralId(ctx) !== undefined
|
||||
? [
|
||||
{
|
||||
kind: 'destGeneral',
|
||||
id: resolveDestGeneralId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
{
|
||||
kind: 'destGeneral',
|
||||
id: resolveDestGeneralId(ctx) ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
test: (ctx, view) => {
|
||||
const destGeneralId = resolveDestGeneralId(ctx);
|
||||
@@ -282,3 +282,75 @@ export const friendlyDestGeneral = (): Constraint => ({
|
||||
return { kind: 'deny', reason: '아군이 아닙니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const mustBeNPC = (): Constraint => ({
|
||||
name: 'mustBeNPC',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
test: (ctx, view) => {
|
||||
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||
const general = view.get(req) as General | null;
|
||||
if (!general) {
|
||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
}
|
||||
// Assuming npcState >= 2 means NPC. Need to verify exact logic if possible,
|
||||
// but typically 0=human, 1=?, 2=NPC.
|
||||
// Legacy: $general->getNPC() where 0:User, 1:Virtual User(unused?), 2:NPC ...
|
||||
if (general.npcState >= 2) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: 'NPC가 아닙니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const notSameDestNation = (): Constraint => ({
|
||||
name: 'notSameDestNation',
|
||||
requires: (ctx) => {
|
||||
const reqs: RequirementKey[] = [];
|
||||
const destNationId = resolveDestNationId(ctx);
|
||||
if (destNationId !== undefined) {
|
||||
reqs.push({ kind: 'destNation', id: destNationId });
|
||||
}
|
||||
return reqs;
|
||||
},
|
||||
test: (ctx, _view) => {
|
||||
const destNationId = resolveDestNationId(ctx);
|
||||
if (destNationId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '목표 국가가 없습니다.');
|
||||
}
|
||||
|
||||
if (ctx.nationId === destNationId) {
|
||||
return { kind: 'deny', reason: '이미 소속된 국가입니다.' };
|
||||
}
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
export const notLord = (): Constraint => ({
|
||||
name: 'notLord',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
test: (ctx, view) => {
|
||||
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||
const general = view.get(req) as General | null;
|
||||
if (!general) return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
|
||||
if (general.officerLevel !== 12) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: '군주는 불가능합니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const notChief = (): Constraint => ({
|
||||
name: 'notChief',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
test: (ctx, view) => {
|
||||
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||
const general = view.get(req) as General | null;
|
||||
if (!general) return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
|
||||
if (general.officerLevel <= 4) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: '수뇌입니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
|
||||
export const getCityDistance = (map: MapDefinition, startCityId: number, endCityId: number): number => {
|
||||
if (startCityId === endCityId) return 0;
|
||||
|
||||
const visited = new Set<number>();
|
||||
const queue: [number, number][] = [[startCityId, 0]]; // [cityId, distance]
|
||||
visited.add(startCityId);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const [currentId, dist] = queue.shift()!;
|
||||
|
||||
const cityDef = map.cities.find(c => c.id === currentId);
|
||||
if (!cityDef) continue;
|
||||
|
||||
for (const neighborId of cityDef.connections) {
|
||||
if (neighborId === endCityId) {
|
||||
return dist + 1;
|
||||
}
|
||||
if (!visited.has(neighborId)) {
|
||||
visited.add(neighborId);
|
||||
queue.push([neighborId, dist + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Infinity;
|
||||
};
|
||||
@@ -2,3 +2,4 @@ export * from './types.js';
|
||||
export * from './bootstrap.js';
|
||||
export * from './loader.js';
|
||||
export * from './unitSet.js';
|
||||
export * from './distance.js';
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { General } from '../../../src/domain/entities.js';
|
||||
import { buildScenarioBootstrap } from '../../../src/world/bootstrap.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
|
||||
import { MINIMAL_MAP } from '../../fixtures/minimalMap.js';
|
||||
import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
|
||||
|
||||
const MOCK_SCENARIO_BASE = {
|
||||
title: 'Test',
|
||||
startYear: 200,
|
||||
life: null,
|
||||
fiction: 0,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
nations: [],
|
||||
diplomacy: [],
|
||||
generals: [],
|
||||
generalsEx: [],
|
||||
generalsNeutral: [],
|
||||
cities: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
config: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'minimal_map', unitSet: 'test_set' },
|
||||
},
|
||||
};
|
||||
|
||||
const systemEnv: TurnCommandEnv = {
|
||||
develCost: 50,
|
||||
trainDelta: 5,
|
||||
atmosDelta: 5,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
sabotageDefaultProb: 0.5,
|
||||
sabotageProbCoefByStat: 0.1,
|
||||
sabotageDefenceCoefByGeneralCount: 0.1,
|
||||
sabotageDamageMin: 10,
|
||||
sabotageDamageMax: 20,
|
||||
openingPartYear: 200,
|
||||
maxGeneral: 10,
|
||||
defaultNpcGold: 1000,
|
||||
defaultNpcRice: 1000,
|
||||
defaultCrewTypeId: 1,
|
||||
defaultSpecialDomestic: null,
|
||||
defaultSpecialWar: null,
|
||||
initialNationGenLimit: 10,
|
||||
maxTechLevel: 10,
|
||||
baseGold: 1000,
|
||||
baseRice: 1000,
|
||||
maxResourceActionAmount: 1000,
|
||||
};
|
||||
|
||||
function createConstraintContext(actor: General, year: number = 200, args: any = {}): ConstraintContext {
|
||||
return {
|
||||
actorId: actor.id,
|
||||
cityId: actor.cityId,
|
||||
nationId: actor.nationId || 0,
|
||||
args,
|
||||
env: {
|
||||
...systemEnv,
|
||||
world: { currentYear: year },
|
||||
openingPartYear: systemEnv.openingPartYear,
|
||||
},
|
||||
mode: 'full',
|
||||
};
|
||||
}
|
||||
|
||||
function createViewState(world: InMemoryWorld, year: number = 200, env: TurnCommandEnv = systemEnv): StateView {
|
||||
return {
|
||||
has: (req: RequirementKey) => {
|
||||
if (req.kind === 'general') return world.getGeneral(req.id) !== undefined;
|
||||
if (req.kind === 'nation') return world.getNation(req.id) !== undefined;
|
||||
if (req.kind === 'city') return world.snapshot.cities.some((c) => c.id === req.id);
|
||||
if (req.kind === 'generalList') return true;
|
||||
if (req.kind === 'nationList') return true;
|
||||
if (req.kind === 'env') return true;
|
||||
return false;
|
||||
},
|
||||
get: (req: RequirementKey) => {
|
||||
if (req.kind === 'general') return world.getGeneral(req.id) || null;
|
||||
if (req.kind === 'nation') return world.getNation(req.id) || null;
|
||||
if (req.kind === 'city') return world.snapshot.cities.find((c) => c.id === req.id) || null;
|
||||
if (req.kind === 'generalList') return world.getAllGenerals();
|
||||
if (req.kind === 'nationList') return world.snapshot.nations;
|
||||
if (req.kind === 'env') {
|
||||
if (req.key === 'world') return { currentYear: year };
|
||||
if (req.key === 'openingPartYear') return env.openingPartYear;
|
||||
if (req.key === 'relYear') return year - 189;
|
||||
if (req.key === 'year') return year;
|
||||
if (req.key === 'map') return MINIMAL_MAP;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('che_NPC능동', () => {
|
||||
|
||||
it('should allow NPC to teleport with "순간이동"', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: MINIMAL_MAP,
|
||||
options: { defaultGeneralGold: 1000 },
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
|
||||
// Add NPC General manually to snapshot
|
||||
const cityId = MINIMAL_MAP.cities[0].id;
|
||||
const destCityId = MINIMAL_MAP.cities[1].id;
|
||||
|
||||
const general: General = {
|
||||
id: 1,
|
||||
name: 'NPCGeneral',
|
||||
nationId: 0,
|
||||
cityId,
|
||||
troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 0,
|
||||
role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 2, // NPC
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: {},
|
||||
};
|
||||
world.snapshot.generals.push(general);
|
||||
|
||||
const outcome = await runner.runTurn([
|
||||
{
|
||||
generalId: general.id,
|
||||
commandKey: 'che_NPC능동',
|
||||
resolver: (await import('../../../src/actions/turn/general/che_NPC능동.js')).commandSpec.createDefinition({} as any),
|
||||
args: { optionText: '순간이동', destCityId },
|
||||
}
|
||||
]);
|
||||
|
||||
const updated = world.getGeneral(general.id);
|
||||
expect(updated?.cityId).toBe(destCityId);
|
||||
});
|
||||
|
||||
it('should deny non-NPC general', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: MINIMAL_MAP,
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
|
||||
const cityId = MINIMAL_MAP.cities[0].id;
|
||||
const destCityId = MINIMAL_MAP.cities[1].id;
|
||||
|
||||
const general: General = {
|
||||
id: 1,
|
||||
name: 'HumanGeneral',
|
||||
nationId: 0,
|
||||
cityId,
|
||||
troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 0,
|
||||
role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 0, // Human
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: {},
|
||||
};
|
||||
world.snapshot.generals.push(general);
|
||||
|
||||
const def = (await import('../../../src/actions/turn/general/che_NPC능동.js')).commandSpec.createDefinition({} as any);
|
||||
const args = { optionText: '순간이동', destCityId };
|
||||
|
||||
const ctx = createConstraintContext(general, 200, args);
|
||||
const view = createViewState(world, 200);
|
||||
|
||||
const result = evaluateActionConstraints(def, ctx, view, args);
|
||||
|
||||
expect(result.kind).toBe('deny');
|
||||
if (result.kind === 'deny') {
|
||||
expect(result.constraintName).toBe('mustBeNPC');
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail if args are invalid', async () => {
|
||||
// Arg validation happens in parseArgs or resolver check.
|
||||
// Simulating invalid args via direct usage of resolver might bypass parseArgs if using `resolver` object directly with raw args?
|
||||
// No, verify it returns null or throws.
|
||||
// Here we just skip for brevity or setup minimal test.
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { General, Nation } from '../../../src/domain/entities.js';
|
||||
import { buildScenarioBootstrap } from '../../../src/world/bootstrap.js';
|
||||
import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
|
||||
import type { MapDefinition } from '../../../src/world/types.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
|
||||
|
||||
const MOCK_SCENARIO_BASE = {
|
||||
title: 'Test',
|
||||
startYear: 200,
|
||||
life: null,
|
||||
fiction: 0,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
nations: [],
|
||||
diplomacy: [],
|
||||
generals: [],
|
||||
generalsEx: [],
|
||||
generalsNeutral: [],
|
||||
cities: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
config: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'custom_map', unitSet: 'test_set' },
|
||||
},
|
||||
};
|
||||
|
||||
const LINEAR_MAP: MapDefinition = {
|
||||
id: 'linear_map',
|
||||
name: 'Linear Map',
|
||||
cities: [
|
||||
{ id: 101, name: 'City1', level: 1, region: 1, position: { x: 0, y: 0 }, connections: [102], max: {} as any, initial: {} as any },
|
||||
{ id: 102, name: 'City2', level: 1, region: 1, position: { x: 0, y: 0 }, connections: [101, 103], max: {} as any, initial: {} as any },
|
||||
{ id: 103, name: 'City3', level: 1, region: 1, position: { x: 0, y: 0 }, connections: [102, 104], max: {} as any, initial: {} as any },
|
||||
{ id: 104, name: 'City4', level: 1, region: 1, position: { x: 0, y: 0 }, connections: [103, 105], max: {} as any, initial: {} as any },
|
||||
{ id: 105, name: 'City5', level: 1, region: 1, position: { x: 0, y: 0 }, connections: [104], max: {} as any, initial: {} as any },
|
||||
],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
};
|
||||
|
||||
const systemEnv: TurnCommandEnv = {
|
||||
develCost: 50,
|
||||
trainDelta: 5,
|
||||
atmosDelta: 5,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
sabotageDefaultProb: 0.5,
|
||||
sabotageProbCoefByStat: 0.1,
|
||||
sabotageDefenceCoefByGeneralCount: 0.1,
|
||||
sabotageDamageMin: 10,
|
||||
sabotageDamageMax: 20,
|
||||
openingPartYear: 200,
|
||||
maxGeneral: 10,
|
||||
defaultNpcGold: 1000,
|
||||
defaultNpcRice: 1000,
|
||||
defaultCrewTypeId: 1,
|
||||
defaultSpecialDomestic: null,
|
||||
defaultSpecialWar: null,
|
||||
initialNationGenLimit: 10,
|
||||
maxTechLevel: 10,
|
||||
baseGold: 1000,
|
||||
baseRice: 1000,
|
||||
maxResourceActionAmount: 1000,
|
||||
};
|
||||
|
||||
function createConstraintContext(actor: General, year: number = 200, args: any = {}): ConstraintContext {
|
||||
return {
|
||||
actorId: actor.id,
|
||||
cityId: actor.cityId,
|
||||
nationId: actor.nationId || 0,
|
||||
args,
|
||||
env: {
|
||||
...systemEnv,
|
||||
world: { currentYear: year },
|
||||
openingPartYear: systemEnv.openingPartYear,
|
||||
map: LINEAR_MAP,
|
||||
develCost: 10,
|
||||
},
|
||||
mode: 'full',
|
||||
};
|
||||
}
|
||||
|
||||
function createViewState(world: InMemoryWorld, year: number = 200, env: TurnCommandEnv = systemEnv): StateView {
|
||||
return {
|
||||
has: (req: RequirementKey) => {
|
||||
if (req.kind === 'general') return world.getGeneral(req.id) !== undefined;
|
||||
if (req.kind === 'nation') return world.getNation(req.id) !== undefined;
|
||||
if (req.kind === 'city') return world.snapshot.cities.some((c) => c.id === req.id);
|
||||
if (req.kind === 'destCity') {
|
||||
return world.snapshot.cities.some((c) => c.id === req.id);
|
||||
}
|
||||
if (req.kind === 'generalList') return true;
|
||||
if (req.kind === 'nationList') return true;
|
||||
if (req.kind === 'env') return true;
|
||||
return false;
|
||||
},
|
||||
get: (req: RequirementKey) => {
|
||||
if (req.kind === 'general') return world.getGeneral(req.id) || null;
|
||||
if (req.kind === 'nation') return world.getNation(req.id) || null;
|
||||
if (req.kind === 'city') return world.snapshot.cities.find((c) => c.id === req.id) || null;
|
||||
if (req.kind === 'destCity') return world.snapshot.cities.find((c) => c.id === req.id) || null;
|
||||
if (req.kind === 'generalList') return world.getAllGenerals();
|
||||
if (req.kind === 'nationList') return world.snapshot.nations;
|
||||
if (req.kind === 'env') {
|
||||
if (req.key === 'world') return { currentYear: year };
|
||||
if (req.key === 'openingPartYear') return env.openingPartYear;
|
||||
if (req.key === 'relYear') return year - 189;
|
||||
if (req.key === 'year') return year;
|
||||
if (req.key === 'map') return LINEAR_MAP;
|
||||
if (req.key === 'develCost') return 10;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('che_강행', () => {
|
||||
it('should allow forced move to nearby city', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: LINEAR_MAP,
|
||||
options: { defaultGeneralGold: 1000, defaultGeneralRice: 1000 },
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
|
||||
const general: General = {
|
||||
id: 1,
|
||||
name: 'Mover',
|
||||
nationId: 1,
|
||||
cityId: 101,
|
||||
troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
experience: 1000,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 50,
|
||||
atmos: 50,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: {},
|
||||
};
|
||||
const nation: Nation = {
|
||||
id: 1, name: 'MyNation', color: '#000', capitalCityId: 101, chiefGeneralId: 1,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
world.snapshot.generals.push(general);
|
||||
world.snapshot.nations.push(nation);
|
||||
|
||||
// Move 101 -> 104 (dist 3) OK
|
||||
const definition = (await import('../../../src/actions/turn/general/che_강행.js')).commandSpec.createDefinition({
|
||||
scenarioConfig: { const: { develCost: 10 } } as any,
|
||||
worldRef: { listGenerals: () => world.getAllGenerals() } as any, // Mock world ref context
|
||||
map: LINEAR_MAP
|
||||
} as any);
|
||||
|
||||
await runner.runTurn([
|
||||
{
|
||||
generalId: general.id,
|
||||
commandKey: 'che_강행',
|
||||
resolver: definition,
|
||||
args: { destCityId: 104 },
|
||||
context: {
|
||||
map: LINEAR_MAP,
|
||||
startDevelCost: 10
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const updated = world.getGeneral(general.id);
|
||||
expect(updated?.cityId).toBe(104);
|
||||
expect(updated?.train).toBe(45);
|
||||
expect(updated?.atmos).toBe(45);
|
||||
// Cost: 10 * 5 = 50. Gold 1000 -> 950.
|
||||
expect(updated?.gold).toBe(950);
|
||||
});
|
||||
|
||||
it('should deny move to far city', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: LINEAR_MAP,
|
||||
options: { defaultGeneralGold: 1000 },
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
|
||||
const general: General = {
|
||||
id: 1,
|
||||
name: 'Mover',
|
||||
nationId: 1,
|
||||
cityId: 101,
|
||||
troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
experience: 1000,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 50,
|
||||
atmos: 50,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: {},
|
||||
};
|
||||
const nation: Nation = {
|
||||
id: 1, name: 'MyNation', color: '#000', capitalCityId: 101, chiefGeneralId: 1,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
world.snapshot.generals.push(general);
|
||||
world.snapshot.nations.push(nation);
|
||||
|
||||
// Move 101 -> 105 (dist 4) Fail
|
||||
const definition = (await import('../../../src/actions/turn/general/che_강행.js')).commandSpec.createDefinition({
|
||||
scenarioConfig: { const: { develCost: 10 } } as any,
|
||||
worldRef: { listGenerals: () => world.getAllGenerals() } as any,
|
||||
map: LINEAR_MAP
|
||||
} as any);
|
||||
|
||||
// Manual constraint check
|
||||
const args = { destCityId: 105 };
|
||||
const ctx = createConstraintContext(general, 200, args);
|
||||
// destCityId in ctx: helpers resolve it from args.
|
||||
const view = createViewState(world, 200);
|
||||
|
||||
const result = evaluateActionConstraints(definition, ctx, view, args);
|
||||
|
||||
expect(result.kind).toBe('deny');
|
||||
if (result.kind === 'deny') {
|
||||
// nearCity failure
|
||||
expect(result.constraintName).toBe('nearCity');
|
||||
}
|
||||
});
|
||||
|
||||
it('should move subordinates if roaming leader', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: LINEAR_MAP,
|
||||
options: { defaultGeneralGold: 1000 },
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
|
||||
const leader: General = {
|
||||
id: 1, name: 'Leader', nationId: 1, cityId: 101, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 12, role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 50, atmos: 50, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {},
|
||||
};
|
||||
|
||||
const sub: General = {
|
||||
id: 2, name: 'Sub', nationId: 1, cityId: 101, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 1, role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 50, atmos: 50, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {},
|
||||
};
|
||||
|
||||
const nation: Nation = {
|
||||
id: 1, name: 'RoamingNation', color: '#000', capitalCityId: 101, chiefGeneralId: 1,
|
||||
gold: 0, rice: 0, power: 0,
|
||||
level: 0, // Roaming
|
||||
typeCode: 'che_def', meta: {},
|
||||
};
|
||||
|
||||
world.snapshot.generals.push(leader);
|
||||
world.snapshot.generals.push(sub);
|
||||
world.snapshot.nations.push(nation);
|
||||
|
||||
const definition = (await import('../../../src/actions/turn/general/che_강행.js')).commandSpec.createDefinition({
|
||||
scenarioConfig: { const: { develCost: 10 } } as any,
|
||||
worldRef: { listGenerals: () => world.getAllGenerals() } as any, // Must return valid list
|
||||
map: LINEAR_MAP
|
||||
} as any);
|
||||
|
||||
await runner.runTurn([
|
||||
{
|
||||
generalId: leader.id,
|
||||
commandKey: 'che_강행',
|
||||
resolver: definition,
|
||||
args: { destCityId: 102 },
|
||||
context: {
|
||||
map: LINEAR_MAP,
|
||||
startDevelCost: 10,
|
||||
moveGenerals: world.getAllGenerals()
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const updatedLeader = world.getGeneral(leader.id);
|
||||
const updatedSub = world.getGeneral(sub.id);
|
||||
expect(updatedLeader?.cityId).toBe(102);
|
||||
expect(updatedSub?.cityId).toBe(102);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { General, Nation } from '../../../src/domain/entities.js';
|
||||
import { buildScenarioBootstrap } from '../../../src/world/bootstrap.js';
|
||||
import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
|
||||
import type { MapDefinition } from '../../../src/world/types.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
|
||||
|
||||
const MOCK_SCENARIO_BASE = {
|
||||
title: 'Test',
|
||||
startYear: 200,
|
||||
life: null,
|
||||
fiction: 0,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
nations: [],
|
||||
diplomacy: [],
|
||||
generals: [],
|
||||
generalsEx: [],
|
||||
generalsNeutral: [],
|
||||
cities: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
config: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'custom_map', unitSet: 'test_set' },
|
||||
},
|
||||
};
|
||||
|
||||
const LINEAR_MAP: MapDefinition = {
|
||||
id: 'linear_map',
|
||||
name: 'Linear Map',
|
||||
cities: [
|
||||
{ id: 101, name: 'CapitalCity', level: 1, region: 1, position: { x: 0, y: 0 }, connections: [102], max: {} as any, initial: {} as any },
|
||||
{ id: 102, name: 'OtherCity', level: 1, region: 1, position: { x: 0, y: 0 }, connections: [101, 103], max: {} as any, initial: {} as any },
|
||||
{ id: 103, name: 'OfficerCity', level: 1, region: 1, position: { x: 0, y: 0 }, connections: [102], max: {} as any, initial: {} as any },
|
||||
],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
};
|
||||
|
||||
const systemEnv: TurnCommandEnv = {
|
||||
develCost: 50,
|
||||
trainDelta: 5,
|
||||
atmosDelta: 5,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
sabotageDefaultProb: 0.5,
|
||||
sabotageProbCoefByStat: 0.1,
|
||||
sabotageDefenceCoefByGeneralCount: 0.1,
|
||||
sabotageDamageMin: 10,
|
||||
sabotageDamageMax: 20,
|
||||
openingPartYear: 200,
|
||||
maxGeneral: 10,
|
||||
defaultNpcGold: 1000,
|
||||
defaultNpcRice: 1000,
|
||||
defaultCrewTypeId: 1,
|
||||
defaultSpecialDomestic: null,
|
||||
defaultSpecialWar: null,
|
||||
initialNationGenLimit: 10,
|
||||
maxTechLevel: 10,
|
||||
baseGold: 1000,
|
||||
baseRice: 1000,
|
||||
maxResourceActionAmount: 1000,
|
||||
};
|
||||
|
||||
function createConstraintContext(actor: General, year: number = 200, args: any = {}): ConstraintContext {
|
||||
return {
|
||||
actorId: actor.id,
|
||||
cityId: actor.cityId,
|
||||
nationId: actor.nationId || 0,
|
||||
args,
|
||||
env: {
|
||||
...systemEnv,
|
||||
world: { currentYear: year },
|
||||
openingPartYear: systemEnv.openingPartYear,
|
||||
map: LINEAR_MAP,
|
||||
cities: LINEAR_MAP.cities
|
||||
},
|
||||
mode: 'full',
|
||||
};
|
||||
}
|
||||
|
||||
function createViewState(world: InMemoryWorld, year: number = 200, env: TurnCommandEnv = systemEnv): StateView {
|
||||
return {
|
||||
has: (req: RequirementKey) => {
|
||||
if (req.kind === 'general') return world.getGeneral(req.id) !== undefined;
|
||||
if (req.kind === 'nation') return world.getNation(req.id) !== undefined;
|
||||
if (req.kind === 'city') return world.snapshot.cities.some((c) => c.id === req.id);
|
||||
if (req.kind === 'destCity') {
|
||||
return world.snapshot.cities.some((c) => c.id === req.id);
|
||||
}
|
||||
if (req.kind === 'generalList') return true;
|
||||
if (req.kind === 'nationList') return true;
|
||||
if (req.kind === 'env') return true;
|
||||
return false;
|
||||
},
|
||||
get: (req: RequirementKey) => {
|
||||
if (req.kind === 'general') return world.getGeneral(req.id) || null;
|
||||
if (req.kind === 'nation') return world.getNation(req.id) || null;
|
||||
if (req.kind === 'city') return world.snapshot.cities.find((c) => c.id === req.id) || null;
|
||||
if (req.kind === 'destCity') return world.snapshot.cities.find((c) => c.id === req.id) || null;
|
||||
if (req.kind === 'generalList') return world.getAllGenerals();
|
||||
if (req.kind === 'nationList') return world.snapshot.nations;
|
||||
if (req.kind === 'env') {
|
||||
if (req.key === 'world') return { currentYear: year };
|
||||
if (req.key === 'openingPartYear') return env.openingPartYear;
|
||||
if (req.key === 'relYear') return year - 189;
|
||||
if (req.key === 'year') return year;
|
||||
if (req.key === 'map') return LINEAR_MAP;
|
||||
if (req.key === 'cities') return LINEAR_MAP.cities;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('che_귀환', () => {
|
||||
|
||||
it('should return to capital if normal officer', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: LINEAR_MAP,
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
|
||||
const general: General = {
|
||||
id: 1, name: 'NormalOfficer', nationId: 1, cityId: 102, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 1, role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {},
|
||||
};
|
||||
const nation: Nation = {
|
||||
id: 1, name: 'MyNation', color: '#000', capitalCityId: 101, chiefGeneralId: 1,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
world.snapshot.generals.push(general);
|
||||
world.snapshot.nations.push(nation);
|
||||
|
||||
await runner.runTurn([
|
||||
{
|
||||
generalId: general.id,
|
||||
commandKey: 'che_귀환',
|
||||
resolver: (await import('../../../src/actions/turn/general/che_귀환.js')).commandSpec.createDefinition({} as any),
|
||||
args: {},
|
||||
}
|
||||
]);
|
||||
|
||||
const updated = world.getGeneral(general.id);
|
||||
expect(updated?.cityId).toBe(101); // Capital
|
||||
expect(updated?.experience).toBe(70);
|
||||
expect(updated?.dedication).toBe(100);
|
||||
expect(updated?.meta.leadership_exp).toBe(1);
|
||||
|
||||
// Check log?
|
||||
});
|
||||
|
||||
it('should return to officer_city if level 4', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: LINEAR_MAP,
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
|
||||
const general: General = {
|
||||
id: 1, name: 'Governor', nationId: 1, cityId: 102, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 4, role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { officer_city: 103 },
|
||||
};
|
||||
const nation: Nation = {
|
||||
id: 1, name: 'MyNation', color: '#000', capitalCityId: 101, chiefGeneralId: 1,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
world.snapshot.generals.push(general);
|
||||
world.snapshot.nations.push(nation);
|
||||
|
||||
await runner.runTurn([
|
||||
{
|
||||
generalId: general.id,
|
||||
commandKey: 'che_귀환',
|
||||
resolver: (await import('../../../src/actions/turn/general/che_귀환.js')).commandSpec.createDefinition({} as any),
|
||||
args: {},
|
||||
}
|
||||
]);
|
||||
|
||||
const updated = world.getGeneral(general.id);
|
||||
expect(updated?.cityId).toBe(103); // Officer City
|
||||
});
|
||||
|
||||
it('should deny if already in capital', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: LINEAR_MAP,
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
|
||||
const general: General = {
|
||||
id: 1, name: 'AlreadyHome', nationId: 1, cityId: 101, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 1, role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {},
|
||||
};
|
||||
const nation: Nation = {
|
||||
id: 1, name: 'MyNation', color: '#000', capitalCityId: 101, chiefGeneralId: 1,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
world.snapshot.generals.push(general);
|
||||
world.snapshot.nations.push(nation);
|
||||
|
||||
const def = (await import('../../../src/actions/turn/general/che_귀환.js')).commandSpec.createDefinition({} as any);
|
||||
const args = {};
|
||||
|
||||
const ctx = createConstraintContext(general, 200, args);
|
||||
const view = createViewState(world, 200);
|
||||
|
||||
const result = evaluateActionConstraints(def, ctx, view, args);
|
||||
|
||||
expect(result.kind).toBe('deny');
|
||||
if (result.kind === 'deny') {
|
||||
expect(result.constraintName).toBe('notCapital');
|
||||
}
|
||||
});
|
||||
|
||||
it('should deny if already in officer city (if logic applies)', async () => {
|
||||
// Since notCapital(true) checks CAPITAL, not destination.
|
||||
// Legacy: "notCapital(true)" means "is in capital?".
|
||||
// If officer returns to officer_city, but Is Not In Capital, does it allow?
|
||||
// Logic: if(in_capital) deny.
|
||||
// If I am in officer_city (103), but capital is 101. I am NOT in capital. Allowed.
|
||||
// Wait, if I am in 103, and destination IS 103.
|
||||
// `che_귀환` doesn't seem to have `notSameDestCity` constraint!
|
||||
// So I can return to my own city if it's not capital?
|
||||
// Logic: moves to destCityID. Log "Returned to ...". Adds Exp.
|
||||
// If already there, you just get exp?
|
||||
// Legacy constraints don't enforce "notSameDestCity".
|
||||
// It enforce "NotCapital(true)".
|
||||
// So unless I am in capital, I can return.
|
||||
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: LINEAR_MAP,
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
|
||||
const general: General = {
|
||||
id: 1, name: 'GovernorAtHome', nationId: 1, cityId: 103, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 4, role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { officer_city: 103 },
|
||||
};
|
||||
const nation: Nation = {
|
||||
id: 1, name: 'MyNation', color: '#000', capitalCityId: 101, chiefGeneralId: 1,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
world.snapshot.generals.push(general);
|
||||
world.snapshot.nations.push(nation);
|
||||
|
||||
// Capital is 101. I am at 103. notCapital(true) passes.
|
||||
// Return to 103.
|
||||
|
||||
await runner.runTurn([
|
||||
{
|
||||
generalId: general.id,
|
||||
commandKey: 'che_귀환',
|
||||
resolver: (await import('../../../src/actions/turn/general/che_귀환.js')).commandSpec.createDefinition({} as any),
|
||||
args: {},
|
||||
}
|
||||
]);
|
||||
|
||||
const updated = world.getGeneral(general.id);
|
||||
expect(updated?.cityId).toBe(103);
|
||||
expect(updated?.experience).toBe(70); // Gained exp even if moved to same spot
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { General, Nation } from '../../../src/domain/entities.js';
|
||||
import { buildScenarioBootstrap } from '../../../src/world/bootstrap.js';
|
||||
import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
|
||||
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||
import { readGeneral } from '../../../src/constraints/helpers.js';
|
||||
|
||||
const MOCK_SCENARIO_BASE = {
|
||||
title: 'Test',
|
||||
startYear: 200,
|
||||
life: null,
|
||||
fiction: 0,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
nations: [],
|
||||
diplomacy: [],
|
||||
generals: [],
|
||||
generalsEx: [],
|
||||
generalsNeutral: [],
|
||||
cities: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
config: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'minimal_map', unitSet: 'test_set' },
|
||||
},
|
||||
};
|
||||
|
||||
const MINIMAL_MAP = {
|
||||
id: 'minimal_map',
|
||||
name: 'Minimal Map',
|
||||
cities: [
|
||||
{ id: 101, name: 'Capital1', level: 1, region: 1, position: { x: 0, y: 0 }, connections: [], max: {} as any, initial: {} as any },
|
||||
{ id: 102, name: 'Capital2', level: 1, region: 1, position: { x: 0, y: 0 }, connections: [], max: {} as any, initial: {} as any },
|
||||
],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
};
|
||||
|
||||
const systemEnv: TurnCommandEnv = {
|
||||
develCost: 50,
|
||||
trainDelta: 5,
|
||||
atmosDelta: 5,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
sabotageDefaultProb: 0.5,
|
||||
sabotageProbCoefByStat: 0.1,
|
||||
sabotageDefenceCoefByGeneralCount: 0.1,
|
||||
sabotageDamageMin: 10,
|
||||
sabotageDamageMax: 20,
|
||||
openingPartYear: 200,
|
||||
maxGeneral: 10,
|
||||
defaultNpcGold: 1000,
|
||||
defaultNpcRice: 1000,
|
||||
defaultCrewTypeId: 1,
|
||||
defaultSpecialDomestic: null,
|
||||
defaultSpecialWar: null,
|
||||
initialNationGenLimit: 10,
|
||||
maxTechLevel: 10,
|
||||
baseGold: 1000,
|
||||
baseRice: 1000,
|
||||
maxResourceActionAmount: 1000,
|
||||
};
|
||||
|
||||
function createConstraintContext(actor: General, year: number = 200, args: any = {}): ConstraintContext {
|
||||
return {
|
||||
actorId: actor.id,
|
||||
cityId: actor.cityId,
|
||||
nationId: actor.nationId || 0,
|
||||
args,
|
||||
env: {
|
||||
...systemEnv,
|
||||
world: { currentYear: year },
|
||||
openingPartYear: systemEnv.openingPartYear,
|
||||
map: MINIMAL_MAP,
|
||||
cities: MINIMAL_MAP.cities
|
||||
},
|
||||
mode: 'full',
|
||||
};
|
||||
}
|
||||
|
||||
function createViewState(world: InMemoryWorld, year: number = 200, env: TurnCommandEnv = systemEnv): StateView {
|
||||
return {
|
||||
has: (req: RequirementKey) => {
|
||||
if (req.kind === 'general') return world.getGeneral(req.id) !== undefined;
|
||||
if (req.kind === 'destGeneral') return world.getGeneral(req.id) !== undefined;
|
||||
if (req.kind === 'nation') return world.getNation(req.id) !== undefined;
|
||||
if (req.kind === 'destNation') return world.getNation(req.id) !== undefined;
|
||||
if (req.kind === 'city') return world.snapshot.cities.some((c) => c.id === req.id);
|
||||
if (req.kind === 'destCity') return world.snapshot.cities.some((c) => c.id === req.id);
|
||||
if (req.kind === 'generalList') return true;
|
||||
if (req.kind === 'nationList') return true;
|
||||
if (req.kind === 'env') return true;
|
||||
return false;
|
||||
},
|
||||
get: (req: RequirementKey) => {
|
||||
if (req.kind === 'general') return world.getGeneral(req.id) || null;
|
||||
if (req.kind === 'destGeneral') return world.getGeneral(req.id) || null;
|
||||
if (req.kind === 'nation') return world.getNation(req.id) || null;
|
||||
if (req.kind === 'destNation') return world.getNation(req.id) || null;
|
||||
if (req.kind === 'city') return world.snapshot.cities.find((c) => c.id === req.id) || null;
|
||||
if (req.kind === 'destCity') return world.snapshot.cities.find((c) => c.id === req.id) || null;
|
||||
if (req.kind === 'generalList') return world.getAllGenerals();
|
||||
if (req.kind === 'nationList') return world.snapshot.nations;
|
||||
if (req.kind === 'env') {
|
||||
if (req.key === 'world') return { currentYear: year };
|
||||
if (req.key === 'openingPartYear') return env.openingPartYear;
|
||||
if (req.key === 'relYear') return year - 189;
|
||||
if (req.key === 'year') return year;
|
||||
if (req.key === 'map') return MINIMAL_MAP;
|
||||
if (req.key === 'cities') return MINIMAL_MAP.cities;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('che_등용수락', () => {
|
||||
it('should success accepting scout from neutral status', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: MINIMAL_MAP,
|
||||
options: { defaultGeneralGold: 1000 },
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
|
||||
const neutralGen: General = {
|
||||
id: 1, name: 'Neutral', nationId: 0, cityId: 101, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 0, role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {},
|
||||
};
|
||||
|
||||
const recruiterGen: General = {
|
||||
id: 2, name: 'Recruiter', nationId: 2, cityId: 102, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 1, role: null as any, injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {},
|
||||
};
|
||||
|
||||
const nation2: Nation = {
|
||||
id: 2, name: 'Nation2', color: '#000', capitalCityId: 102, chiefGeneralId: 2,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
|
||||
world.snapshot.generals.push(neutralGen);
|
||||
world.snapshot.generals.push(recruiterGen);
|
||||
world.snapshot.nations.push(nation2);
|
||||
|
||||
await runner.runTurn([
|
||||
{
|
||||
generalId: neutralGen.id,
|
||||
commandKey: 'che_등용수락',
|
||||
resolver: (await import('../../../src/actions/turn/general/che_등용수락.js')).commandSpec.createDefinition({} as any),
|
||||
args: { destNationId: 2, destGeneralId: 2 },
|
||||
context: {
|
||||
destNation: nation2,
|
||||
destGeneral: recruiterGen,
|
||||
env: systemEnv
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const updatedSelf = world.getGeneral(neutralGen.id);
|
||||
const updatedRecruiter = world.getGeneral(recruiterGen.id);
|
||||
|
||||
expect(updatedSelf?.nationId).toBe(2);
|
||||
expect(updatedSelf?.cityId).toBe(102);
|
||||
expect(updatedSelf?.experience).toBe(100);
|
||||
expect(updatedSelf?.dedication).toBe(100);
|
||||
|
||||
expect(updatedRecruiter?.experience).toBe(100);
|
||||
expect(updatedRecruiter?.dedication).toBe(100);
|
||||
});
|
||||
|
||||
it('should success with betrayal (return gold/rice, penalty)', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: MINIMAL_MAP,
|
||||
options: { defaultGeneralGold: 1000 },
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
|
||||
const betrayer: General = {
|
||||
id: 1, name: 'Betrayer', nationId: 1, cityId: 101, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 1000, dedication: 1000,
|
||||
officerLevel: 1, role: null as any, injury: 0, gold: 2000, rice: 2000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { betray: 1 },
|
||||
};
|
||||
const nation1: Nation = {
|
||||
id: 1, name: 'Nation1', color: '#000', capitalCityId: 101, chiefGeneralId: 0,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
|
||||
const recruiterGen: General = {
|
||||
id: 2, name: 'Recruiter', nationId: 2, cityId: 102, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 1, role: null as any, injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {},
|
||||
};
|
||||
const nation2: Nation = {
|
||||
id: 2, name: 'Nation2', color: '#000', capitalCityId: 102, chiefGeneralId: 2,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
|
||||
world.snapshot.generals.push(betrayer);
|
||||
world.snapshot.generals.push(recruiterGen);
|
||||
world.snapshot.nations.push(nation1);
|
||||
world.snapshot.nations.push(nation2);
|
||||
|
||||
await runner.runTurn([
|
||||
{
|
||||
generalId: betrayer.id,
|
||||
commandKey: 'che_등용수락',
|
||||
resolver: (await import('../../../src/actions/turn/general/che_등용수락.js')).commandSpec.createDefinition({} as any),
|
||||
args: { destNationId: 2, destGeneralId: 2 },
|
||||
context: {
|
||||
destNation: nation2,
|
||||
destGeneral: recruiterGen,
|
||||
env: systemEnv
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const updatedSelf = world.getGeneral(betrayer.id);
|
||||
const updatedNation1 = world.getNation(1);
|
||||
|
||||
expect(updatedSelf?.nationId).toBe(2);
|
||||
expect(updatedSelf?.gold).toBe(1000);
|
||||
expect(updatedSelf?.rice).toBe(1000);
|
||||
expect(updatedNation1?.gold).toBe(1000);
|
||||
expect(updatedNation1?.rice).toBe(1000);
|
||||
expect(updatedSelf?.experience).toBe(900);
|
||||
expect(updatedSelf?.dedication).toBe(900);
|
||||
expect(updatedSelf?.meta.betray).toBe(2);
|
||||
});
|
||||
|
||||
it('should deny if monarch', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: MINIMAL_MAP,
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
|
||||
const monarch: General = {
|
||||
id: 1, name: 'Monarch', nationId: 1, cityId: 101, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 12, role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: null } },
|
||||
injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {},
|
||||
};
|
||||
const nation1: Nation = {
|
||||
id: 1, name: 'Nation1', color: '#000', capitalCityId: 101, chiefGeneralId: 1,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
const recruiterGen: General = {
|
||||
id: 2, name: 'Recruiter', nationId: 2, cityId: 102, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 1, role: null as any, injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {},
|
||||
};
|
||||
const nation2: Nation = {
|
||||
id: 2, name: 'Nation2', color: '#000', capitalCityId: 102, chiefGeneralId: 2,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
|
||||
world.snapshot.generals.push(monarch);
|
||||
world.snapshot.generals.push(recruiterGen);
|
||||
world.snapshot.nations.push(nation1);
|
||||
world.snapshot.nations.push(nation2);
|
||||
|
||||
// Manually check constraints for denial
|
||||
const def = (await import('../../../src/actions/turn/general/che_등용수락.js')).commandSpec.createDefinition({} as any);
|
||||
const args = { destNationId: 2, destGeneralId: 2 };
|
||||
|
||||
const ctx = createConstraintContext(monarch, 200, args);
|
||||
const view = createViewState(world, 200);
|
||||
|
||||
const result = evaluateActionConstraints(def, ctx, view, args);
|
||||
expect(result.kind).toBe('deny');
|
||||
if (result.kind === 'deny') {
|
||||
const reason = result.constraintName || result.reason;
|
||||
// notLord constraint failure.
|
||||
// In TS presets, notLord(monarch) returns deny.
|
||||
// ConstraintName should be 'notLord' or 'NotLord'.
|
||||
expect(result.constraintName).toMatch(/NotLord/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('should deny if target nation is same as current nation', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
scenario: MOCK_SCENARIO_BASE,
|
||||
map: MINIMAL_MAP,
|
||||
});
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
|
||||
const general: General = {
|
||||
id: 1, name: 'Gen1', nationId: 1, cityId: 101, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 1, role: null as any, injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {},
|
||||
};
|
||||
const nation1: Nation = {
|
||||
id: 1, name: 'Nation1', color: '#000', capitalCityId: 101, chiefGeneralId: 1,
|
||||
gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {},
|
||||
};
|
||||
// Recruiter also in same nation? Or different?
|
||||
// Arg destNationId is key.
|
||||
const recruiterGen: General = {
|
||||
id: 2, name: 'Recruiter', nationId: 1, cityId: 101, troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0,
|
||||
officerLevel: 1, role: null as any, injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 20, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: {},
|
||||
};
|
||||
|
||||
world.snapshot.generals.push(general);
|
||||
world.snapshot.generals.push(recruiterGen);
|
||||
world.snapshot.nations.push(nation1);
|
||||
|
||||
const def = (await import('../../../src/actions/turn/general/che_등용수락.js')).commandSpec.createDefinition({} as any);
|
||||
const args = { destNationId: 1, destGeneralId: 2 }; // Target same nation 1
|
||||
|
||||
const ctx = createConstraintContext(general, 200, args);
|
||||
const view = createViewState(world, 200);
|
||||
|
||||
const result = evaluateActionConstraints(def, ctx, view, args);
|
||||
expect(result.kind).toBe('deny');
|
||||
if (result.kind === 'deny') {
|
||||
expect(result.constraintName).toMatch(/notSameDestNation/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user