커맨드 보정
This commit is contained in:
@@ -290,6 +290,8 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
id: effect.targetId,
|
id: effect.targetId,
|
||||||
patch: effect.patch as Partial<General>,
|
patch: effect.patch as Partial<General>,
|
||||||
});
|
});
|
||||||
|
} else if (effect.type === 'general:patch') {
|
||||||
|
Object.assign(draft.general, effect.patch);
|
||||||
} else if (
|
} else if (
|
||||||
effect.type === 'city:patch' &&
|
effect.type === 'city:patch' &&
|
||||||
effect.targetId !== undefined &&
|
effect.targetId !== undefined &&
|
||||||
@@ -299,6 +301,8 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
id: effect.targetId,
|
id: effect.targetId,
|
||||||
patch: effect.patch,
|
patch: effect.patch,
|
||||||
});
|
});
|
||||||
|
} else if (effect.type === 'city:patch' && draft.city) {
|
||||||
|
Object.assign(draft.city, effect.patch);
|
||||||
} else if (
|
} else if (
|
||||||
effect.type === 'nation:patch' &&
|
effect.type === 'nation:patch' &&
|
||||||
effect.targetId !== undefined &&
|
effect.targetId !== undefined &&
|
||||||
@@ -308,6 +312,8 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
id: effect.targetId,
|
id: effect.targetId,
|
||||||
patch: effect.patch,
|
patch: effect.patch,
|
||||||
});
|
});
|
||||||
|
} else if (effect.type === 'nation:patch' && draft.nation) {
|
||||||
|
Object.assign(draft.nation, effect.patch);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import {
|
||||||
|
notBeNeutral,
|
||||||
|
occupiedCity,
|
||||||
|
suppliedCity,
|
||||||
|
existsDestGeneral,
|
||||||
|
resolveDestGeneralId,
|
||||||
|
} 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, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import { LogCategory, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
|
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
|
export interface EmployArgs {
|
||||||
|
destGeneralId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployResolveContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
destGeneral?: General;
|
||||||
|
env?: TurnCommandEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_NAME = '등용';
|
||||||
|
const ACTION_KEY = 'che_등용';
|
||||||
|
|
||||||
|
const differentNationDestGeneral = (): Constraint => ({
|
||||||
|
name: 'DifferentNationDestGeneral',
|
||||||
|
requires: (ctx) => {
|
||||||
|
const destGeneralId = resolveDestGeneralId(ctx);
|
||||||
|
if (destGeneralId === undefined) return [];
|
||||||
|
return [
|
||||||
|
{ kind: 'general', id: ctx.actorId },
|
||||||
|
{ kind: 'destGeneral', id: destGeneralId },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
test: (ctx, view) => {
|
||||||
|
const generalKey = { kind: 'general' as const, id: ctx.actorId };
|
||||||
|
const general = view.get(generalKey) as General | null;
|
||||||
|
const destId = resolveDestGeneralId(ctx);
|
||||||
|
const destGeneral =
|
||||||
|
destId !== undefined ? (view.get({ kind: 'destGeneral', id: destId }) as General | null) : null;
|
||||||
|
|
||||||
|
if (!general || !destGeneral) return { kind: 'deny', reason: '장수 정보가 없습니다.' };
|
||||||
|
|
||||||
|
if (general.nationId !== destGeneral.nationId) return { kind: 'allow' };
|
||||||
|
return { kind: 'deny', reason: '이미 아국 장수입니다.' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const notDestGeneralLord = (): Constraint => ({
|
||||||
|
name: 'NotDestGeneralLord',
|
||||||
|
requires: (ctx) => {
|
||||||
|
const destGeneralId = resolveDestGeneralId(ctx);
|
||||||
|
if (destGeneralId === undefined) return [];
|
||||||
|
return [{ kind: 'destGeneral', id: destGeneralId }];
|
||||||
|
},
|
||||||
|
test: (ctx, view) => {
|
||||||
|
const destId = resolveDestGeneralId(ctx);
|
||||||
|
const destGeneral =
|
||||||
|
destId !== undefined ? (view.get({ kind: 'destGeneral', id: destId }) as General | null) : null;
|
||||||
|
if (!destGeneral) return { kind: 'deny', reason: '장수 정보가 없습니다.' };
|
||||||
|
if (destGeneral.officerLevel !== 12) return { kind: 'allow' };
|
||||||
|
return { kind: 'deny', reason: '군주에게는 등용장을 보낼 수 없습니다.' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, EmployArgs> {
|
||||||
|
readonly key = ACTION_KEY;
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: EmployArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
const ctx = context as EmployResolveContext<TriggerState>;
|
||||||
|
const general = ctx.general;
|
||||||
|
const { destGeneralId } = args;
|
||||||
|
|
||||||
|
const destGeneral = ctx.destGeneral;
|
||||||
|
if (!destGeneral) {
|
||||||
|
throw new Error('Target general missing in context');
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = ctx.env;
|
||||||
|
const develCost = env?.develCost ?? 100;
|
||||||
|
const extraCost = Math.floor((destGeneral.experience + destGeneral.dedication) / 1000) * 10;
|
||||||
|
const reqGold = develCost + extraCost;
|
||||||
|
|
||||||
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...general,
|
||||||
|
gold: Math.max(0, general.gold - reqGold),
|
||||||
|
experience: general.experience + 100,
|
||||||
|
dedication: general.dedication + 200,
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
leadership_exp:
|
||||||
|
(typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
general.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.addLog(`<Y>${destGeneral.name}</>에게 등용 권유 서신을 보냈습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
});
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createLogEffect(
|
||||||
|
`<Y>${general.name}</>(${ctx.nation?.name ?? '재야'})로 부터 등용 권유 서신이 도착했습니다.`,
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
generalId: destGeneralId,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, EmployArgs, GeneralActionResolveContext<TriggerState>> {
|
||||||
|
public readonly key = ACTION_KEY;
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor(private env: TurnCommandEnv) {
|
||||||
|
this.resolver = new ActionResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(raw: unknown): EmployArgs | null {
|
||||||
|
const data = raw as Partial<EmployArgs>;
|
||||||
|
if (typeof data.destGeneralId !== 'number') return null;
|
||||||
|
return { destGeneralId: data.destGeneralId };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(_ctx: ConstraintContext, args: EmployArgs): Constraint[] {
|
||||||
|
const develCost = this.env.develCost ?? 100;
|
||||||
|
|
||||||
|
const reqCostConstraint: Constraint = {
|
||||||
|
name: 'ReqScoutCost',
|
||||||
|
requires: (c) => [
|
||||||
|
{ kind: 'general', id: c.actorId },
|
||||||
|
{ kind: 'destGeneral', id: args.destGeneralId },
|
||||||
|
],
|
||||||
|
test: (c, view) => {
|
||||||
|
const gen = view.get({ kind: 'general', id: c.actorId }) as General | null;
|
||||||
|
const dest = view.get({ kind: 'destGeneral', id: args.destGeneralId }) as General | null;
|
||||||
|
if (!gen || !dest) return { kind: 'deny', reason: '정보 부족' };
|
||||||
|
|
||||||
|
const cost = develCost + Math.floor((dest.experience + dest.dedication) / 1000) * 10;
|
||||||
|
if (gen.gold >= cost) return { kind: 'allow' };
|
||||||
|
return { kind: 'deny', reason: `자금 ${cost}이 필요합니다.` };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
notBeNeutral(),
|
||||||
|
occupiedCity(),
|
||||||
|
suppliedCity(),
|
||||||
|
existsDestGeneral(),
|
||||||
|
differentNationDestGeneral(),
|
||||||
|
notDestGeneralLord(),
|
||||||
|
reqCostConstraint,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: EmployArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
||||||
|
const destGeneralId = options.actionArgs?.destGeneralId;
|
||||||
|
let destGeneral = null;
|
||||||
|
if (typeof destGeneralId === 'number' && options.worldRef) {
|
||||||
|
destGeneral = options.worldRef.getGeneralById(destGeneralId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
destGeneral,
|
||||||
|
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
key: 'che_등용',
|
||||||
|
category: '인사',
|
||||||
|
reqArg: true,
|
||||||
|
args: { destGeneralId: 0 },
|
||||||
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
|
};
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import { notBeNeutral, notWanderingNation, occupiedCity, suppliedCity } from '@sammo-ts/logic/constraints/presets.js';
|
||||||
|
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
|
import type {
|
||||||
|
GeneralActionOutcome,
|
||||||
|
GeneralActionResolveContext,
|
||||||
|
GeneralActionResolver,
|
||||||
|
} 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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
|
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
|
export interface ProcureArgs {}
|
||||||
|
|
||||||
|
const ACTION_NAME = '물자조달';
|
||||||
|
const ACTION_KEY = 'che_물자조달';
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, ProcureArgs> {
|
||||||
|
readonly key = ACTION_KEY;
|
||||||
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
|
||||||
|
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||||
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
context: GeneralActionResolveContext<TriggerState>,
|
||||||
|
_args: ProcureArgs
|
||||||
|
): GeneralActionOutcome<TriggerState> {
|
||||||
|
const general = context.general;
|
||||||
|
const nation = context.nation;
|
||||||
|
|
||||||
|
if (!nation) {
|
||||||
|
throw new Error('Procure requires a nation context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Choose Gold or Rice
|
||||||
|
const picked = context.rng.nextBool(0.5) ? 'gold' : 'rice';
|
||||||
|
const resName = picked === 'gold' ? '금' : '쌀';
|
||||||
|
const resKey = picked === 'gold' ? 'gold' : 'rice';
|
||||||
|
|
||||||
|
// 2. Base Score
|
||||||
|
let score = general.stats.leadership + general.stats.strength + general.stats.intelligence;
|
||||||
|
|
||||||
|
// Domestic Bonus (Hardcoded simplified logic or need helper)
|
||||||
|
// In legacy: getDomesticExpLevelBonus($general->getVar('explevel'))
|
||||||
|
// For now, assume simplified or no bonus unless strictly required to port helper.
|
||||||
|
// Assuming 1.0 for now if helper not available, or implement simple bonus.
|
||||||
|
// Legacy: $score *= $rng->nextRange(0.8, 1.2);
|
||||||
|
score *= context.rng.nextFloat() * 0.4 + 0.8;
|
||||||
|
|
||||||
|
// 3. Success/Fail Ratio
|
||||||
|
let successRatio = 0.1;
|
||||||
|
let failRatio = 0.3;
|
||||||
|
|
||||||
|
successRatio = this.pipeline.onCalcDomestic(context, '조달', 'success', successRatio);
|
||||||
|
failRatio = this.pipeline.onCalcDomestic(context, '조달', 'fail', failRatio);
|
||||||
|
|
||||||
|
// 4. Determine Outcome
|
||||||
|
const roll = context.rng.nextFloat();
|
||||||
|
let outcome: 'fail' | 'success' | 'normal' = 'normal';
|
||||||
|
|
||||||
|
if (roll < failRatio) {
|
||||||
|
outcome = 'fail';
|
||||||
|
} else if (roll < failRatio + successRatio) {
|
||||||
|
outcome = 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Critical Score Modifier
|
||||||
|
// Legacy: CriticalScoreEx($rng, $pick);
|
||||||
|
// fail -> 0.5, success -> 1.5, normal -> 1.0 roughly
|
||||||
|
if (outcome === 'fail') {
|
||||||
|
score *= 0.5;
|
||||||
|
} else if (outcome === 'success') {
|
||||||
|
score *= 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
score = this.pipeline.onCalcDomestic(context, '조달', 'score', score);
|
||||||
|
score = Math.floor(score);
|
||||||
|
|
||||||
|
// 6. Calculate Exp/Dedication
|
||||||
|
const exp = (score * 0.7) / 3;
|
||||||
|
const ded = (score * 1.0) / 3;
|
||||||
|
|
||||||
|
// 7. Update General
|
||||||
|
const nextExp = general.experience + Math.floor(exp);
|
||||||
|
const nextDed = general.dedication + Math.floor(ded);
|
||||||
|
|
||||||
|
// Stat Exp
|
||||||
|
// Legacy: choose weighted among L/S/I
|
||||||
|
const statChoice =
|
||||||
|
context.rng.nextFloat() * (general.stats.leadership + general.stats.strength + general.stats.intelligence);
|
||||||
|
let statKey: 'leadership_exp' | 'strength_exp' | 'intel_exp' = 'leadership_exp';
|
||||||
|
|
||||||
|
if (statChoice < general.stats.leadership) {
|
||||||
|
statKey = 'leadership_exp';
|
||||||
|
} else if (statChoice < general.stats.leadership + general.stats.strength) {
|
||||||
|
statKey = 'strength_exp';
|
||||||
|
} else {
|
||||||
|
statKey = 'intel_exp';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Update Nation
|
||||||
|
const nextNationRes = (nation[resKey] ?? 0) + score;
|
||||||
|
|
||||||
|
// 9. Logging
|
||||||
|
const scoreText = score.toLocaleString();
|
||||||
|
if (outcome === 'fail') {
|
||||||
|
context.addLog(
|
||||||
|
`조달을 <span class='ev_failed'>실패</span>하여 ${resName}을 <C>${scoreText}</> 조달했습니다.`,
|
||||||
|
{
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (outcome === 'success') {
|
||||||
|
context.addLog(`조달을 <S>성공</>하여 ${resName}을 <C>${scoreText}</> 조달했습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
context.addLog(`${resName}을 <C>${scoreText}</> 조달했습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
effects: [
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...general,
|
||||||
|
experience: nextExp,
|
||||||
|
dedication: nextDed,
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
[statKey]:
|
||||||
|
(typeof general.meta[statKey] === 'number' ? (general.meta[statKey] as number) : 0) + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
general.id
|
||||||
|
),
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
...nation,
|
||||||
|
[resKey]: nextNationRes,
|
||||||
|
},
|
||||||
|
nation.id
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, ProcureArgs, GeneralActionResolveContext<TriggerState>> {
|
||||||
|
public readonly key = ACTION_KEY;
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
|
||||||
|
this.resolver = new ActionResolver(modules);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(_raw: unknown): ProcureArgs | null {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(_ctx: ConstraintContext, _args: ProcureArgs): Constraint[] {
|
||||||
|
return [notBeNeutral(), notWanderingNation(), occupiedCity(), suppliedCity()];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: ProcureArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionContextBuilder = defaultActionContextBuilder;
|
||||||
|
|
||||||
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
key: 'che_물자조달',
|
||||||
|
category: '내정',
|
||||||
|
reqArg: false,
|
||||||
|
args: {},
|
||||||
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
|
||||||
|
};
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import { allow, notWanderingNation, 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,
|
||||||
|
createCityPatchEffect,
|
||||||
|
createDiplomacyPatchEffect,
|
||||||
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
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 WanderArgs {}
|
||||||
|
|
||||||
|
const ACTION_NAME = '방랑';
|
||||||
|
const ACTION_KEY = 'che_방랑';
|
||||||
|
|
||||||
|
export interface WanderResolveContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
nationCities?: City[];
|
||||||
|
nationGenerals?: General<TriggerState>[];
|
||||||
|
diplomacyList?: Array<{ fromNationId: number; toNationId: number; state: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beLord = (): Constraint => ({
|
||||||
|
name: 'BeLord',
|
||||||
|
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||||
|
test: (ctx, view) => {
|
||||||
|
const generalKey: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||||
|
const general = view.get(generalKey) as General | null;
|
||||||
|
if (!general) return unknownOrDeny(ctx, [generalKey], '장수 정보가 없습니다.');
|
||||||
|
if (general.officerLevel === 12) return allow();
|
||||||
|
return { kind: 'deny', reason: '군주가 아닙니다.' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: DeleteConflict (Stub logic by clearing conflict meta/state on cities)
|
||||||
|
// Legacy `DeleteConflict` logic: update city set conflict='{}' where nation=...
|
||||||
|
// Also removes war records?
|
||||||
|
// We will simply clear 'conflict' on owned cities.
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, WanderArgs> {
|
||||||
|
readonly key = ACTION_KEY;
|
||||||
|
|
||||||
|
resolve(context: WanderResolveContext<TriggerState>, _args: WanderArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
const general = context.general;
|
||||||
|
const nation = context.nation;
|
||||||
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
|
||||||
|
if (!nation) {
|
||||||
|
throw new Error('Wander requires a nation context.');
|
||||||
|
}
|
||||||
|
// 1. Logs
|
||||||
|
|
||||||
|
context.addLog(`영토를 버리고 방랑의 길을 떠납니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
// TODO: Global logs
|
||||||
|
|
||||||
|
// 2. Nation Update
|
||||||
|
effects.push(
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
...nation,
|
||||||
|
name: general.name,
|
||||||
|
color: '#330000', // Default dark red for wanderer
|
||||||
|
level: 0,
|
||||||
|
typeCode: 'None',
|
||||||
|
meta: { ...nation.meta, tech: 0 },
|
||||||
|
capitalCityId: null,
|
||||||
|
},
|
||||||
|
nation.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. General Update (Leader)
|
||||||
|
// makelimit=12, officer_city=0
|
||||||
|
// Update self
|
||||||
|
effects.push(
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...general,
|
||||||
|
// meta: { ...general.meta, makelimit: 12 }, // If used. Legacy uses makelimit column. New entity might not have it.
|
||||||
|
// If `makelimit` is not in entity, ignore for now or check meta.
|
||||||
|
// officerLevel is already 12.
|
||||||
|
},
|
||||||
|
general.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Update Other Generals
|
||||||
|
if (context.nationGenerals) {
|
||||||
|
for (const other of context.nationGenerals) {
|
||||||
|
if (other.nationId !== nation.id) continue;
|
||||||
|
if (other.id === general.id) continue;
|
||||||
|
|
||||||
|
// legacy: officer_level=1 (if < 12), officer_city=0
|
||||||
|
// leader is handled above (level 12)
|
||||||
|
|
||||||
|
if (other.officerLevel < 12) {
|
||||||
|
effects.push(
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...other,
|
||||||
|
officerLevel: 1,
|
||||||
|
// officer_city? logic probably means unassigned.
|
||||||
|
},
|
||||||
|
other.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update Cities
|
||||||
|
if (context.nationCities) {
|
||||||
|
for (const city of context.nationCities) {
|
||||||
|
if (city.nationId !== nation.id) continue;
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createCityPatchEffect(
|
||||||
|
{
|
||||||
|
...city,
|
||||||
|
nationId: 0,
|
||||||
|
frontState: 0,
|
||||||
|
// conflict: {} // Legacy clears conflict.
|
||||||
|
// Assuming conflict is stored in meta or handled separately.
|
||||||
|
// If conflict is part of state, reset it.
|
||||||
|
},
|
||||||
|
city.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Diplomacy (Reset treaties)
|
||||||
|
if (context.diplomacyList) {
|
||||||
|
for (const rel of context.diplomacyList) {
|
||||||
|
if (rel.fromNationId === nation.id || rel.toNationId === nation.id) {
|
||||||
|
// State 2 (Neutral?) Legacy: 'state'=>2, 'term'=>0.
|
||||||
|
effects.push(
|
||||||
|
createDiplomacyPatchEffect(
|
||||||
|
rel.fromNationId,
|
||||||
|
rel.toNationId,
|
||||||
|
{ state: 2, term: 0 } // Assuming patch accepts full object or fields
|
||||||
|
// Actually `createDiplomacyPatchEffect` 3rd arg is partial.
|
||||||
|
// But we need to verify `DiplomacyState` enum.
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, WanderArgs, WanderResolveContext<TriggerState>> {
|
||||||
|
public readonly key = ACTION_KEY;
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.resolver = new ActionResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(_raw: unknown): WanderArgs | null {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(_ctx: ConstraintContext, _args: WanderArgs): Constraint[] {
|
||||||
|
return [beLord(), notWanderingNation()];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: WanderResolveContext<TriggerState>, args: WanderArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
nationCities: options.worldRef?.listCities() ?? [], // Filter in resolver or use optimized getter
|
||||||
|
nationGenerals: options.worldRef?.listGenerals() ?? [],
|
||||||
|
diplomacyList: options.worldRef?.listDiplomacy() ?? [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
key: 'che_방랑',
|
||||||
|
category: '군사',
|
||||||
|
reqArg: false,
|
||||||
|
args: {},
|
||||||
|
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import {
|
||||||
|
notBeNeutral,
|
||||||
|
notOccupiedDestCity,
|
||||||
|
reqGeneralGold,
|
||||||
|
reqGeneralRice,
|
||||||
|
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, createCityPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
|
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
export interface AgitateArgs {
|
||||||
|
destCityId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgitateResolveContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
destCity?: City;
|
||||||
|
env?: TurnCommandEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_NAME = '선동';
|
||||||
|
const ACTION_KEY = 'che_선동';
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, AgitateArgs> {
|
||||||
|
readonly key = ACTION_KEY;
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: AgitateArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
const ctx = context as AgitateResolveContext<TriggerState>;
|
||||||
|
const general = ctx.general;
|
||||||
|
const { destCityId } = args;
|
||||||
|
const destCity = ctx.destCity;
|
||||||
|
if (!destCity) throw new Error('Target city missing');
|
||||||
|
|
||||||
|
const env = ctx.env;
|
||||||
|
const cost = env?.develCost ?? 100;
|
||||||
|
|
||||||
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
|
||||||
|
// Damage calc
|
||||||
|
const min = env?.sabotageDamageMin ?? 10;
|
||||||
|
const max = env?.sabotageDamageMax ?? 30;
|
||||||
|
const rng = ctx.rng;
|
||||||
|
if (!rng) throw new Error('RNG missing');
|
||||||
|
|
||||||
|
const secuDmg = rng.nextInt(min, max + 1);
|
||||||
|
const trustDmg = rng.nextInt(min, max + 1) / 50;
|
||||||
|
|
||||||
|
const newSecu = Math.max(0, destCity.security - secuDmg);
|
||||||
|
const currentTrust = typeof destCity.meta.trust === 'number' ? destCity.meta.trust : 50;
|
||||||
|
const newTrust = Math.max(0, currentTrust - trustDmg);
|
||||||
|
|
||||||
|
const actualSecuDmg = destCity.security - newSecu;
|
||||||
|
const actualTrustDmg = currentTrust - newTrust;
|
||||||
|
|
||||||
|
// Log
|
||||||
|
const destCityName = destCity.name;
|
||||||
|
ctx.addLog(`<G>${destCityName}</>에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 성공했습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
ctx.addLog(`치안이 ${actualSecuDmg}, 민심이 ${actualTrustDmg.toFixed(1)} 만큼 감소했습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
// City Update
|
||||||
|
effects.push(
|
||||||
|
createCityPatchEffect(
|
||||||
|
{
|
||||||
|
...destCity,
|
||||||
|
security: newSecu,
|
||||||
|
state: 32,
|
||||||
|
meta: {
|
||||||
|
...destCity.meta,
|
||||||
|
trust: newTrust,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
destCityId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// General Update (Cost + Exp + LeaderExp)
|
||||||
|
effects.push(
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...general,
|
||||||
|
gold: Math.max(0, general.gold - cost),
|
||||||
|
rice: Math.max(0, general.rice - cost),
|
||||||
|
experience: general.experience + 50,
|
||||||
|
dedication: general.dedication + 30,
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
leadership_exp:
|
||||||
|
(typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
general.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, AgitateArgs, GeneralActionResolveContext<TriggerState>> {
|
||||||
|
public readonly key = ACTION_KEY;
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor(_env: TurnCommandEnv) {
|
||||||
|
this.resolver = new ActionResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(raw: unknown): AgitateArgs | null {
|
||||||
|
const data = raw as Partial<AgitateArgs>;
|
||||||
|
if (typeof data.destCityId !== 'number') return null;
|
||||||
|
return { destCityId: data.destCityId };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] {
|
||||||
|
const env = ctx.env;
|
||||||
|
const cost = (env.develCost as number) ?? 100;
|
||||||
|
return [
|
||||||
|
notBeNeutral(),
|
||||||
|
existsDestCity(),
|
||||||
|
reqGeneralGold(() => cost),
|
||||||
|
reqGeneralRice(() => cost),
|
||||||
|
notOccupiedDestCity(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: AgitateArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
||||||
|
const destCityId = options.actionArgs?.destCityId;
|
||||||
|
let destCity = null;
|
||||||
|
if (typeof destCityId === 'number' && options.worldRef) {
|
||||||
|
destCity = options.worldRef.getCityById(destCityId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
destCity,
|
||||||
|
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
key: 'che_선동',
|
||||||
|
category: '군사',
|
||||||
|
reqArg: true,
|
||||||
|
args: { destCityId: 0 },
|
||||||
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import { allow, 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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
|
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
|
export interface RetireArgs {}
|
||||||
|
|
||||||
|
const ACTION_NAME = '은퇴';
|
||||||
|
const ACTION_KEY = 'che_은퇴';
|
||||||
|
|
||||||
|
const REQ_AGE = 60;
|
||||||
|
|
||||||
|
const reqAge = (): Constraint => ({
|
||||||
|
name: 'ReqAge',
|
||||||
|
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||||
|
test: (ctx, view) => {
|
||||||
|
const generalKey: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||||
|
const general = view.get(generalKey) as General | null; // Cast to access age
|
||||||
|
if (!general) return unknownOrDeny(ctx, [generalKey], '장수 정보가 없습니다.');
|
||||||
|
if (general.age >= REQ_AGE) return allow();
|
||||||
|
return { kind: 'deny', reason: `나이가 ${REQ_AGE}세 이상이어야 합니다.` };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, RetireArgs> {
|
||||||
|
readonly key = ACTION_KEY;
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, _args: RetireArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
const general = context.general;
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
context.addLog(`은퇴하였습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rebirth Logic (Simulated)
|
||||||
|
// 1. Reset Stats (Randomize slightly?)
|
||||||
|
// Let's keep total stat points but re-distribute or small variation?
|
||||||
|
// Legacy: complete re-roll usually.
|
||||||
|
// Simple implementation: Random re-roll around average 70?
|
||||||
|
|
||||||
|
const rng = context.rng;
|
||||||
|
const newLead = rng.nextInt(30, 90);
|
||||||
|
const newStr = rng.nextInt(30, 90);
|
||||||
|
const newIntel = rng.nextInt(30, 90);
|
||||||
|
|
||||||
|
// 2. Reset Age
|
||||||
|
const newAge = 20;
|
||||||
|
|
||||||
|
// 3. Reset Exp/Ded
|
||||||
|
const newExp = 0;
|
||||||
|
const newDed = 0;
|
||||||
|
|
||||||
|
// 4. Reset Meta (Inheritance points?)
|
||||||
|
// Legacy: increases inheritance point.
|
||||||
|
// We'll increment inheritance point in meta.
|
||||||
|
const inheritance =
|
||||||
|
(typeof general.meta.inheritance_point === 'number' ? general.meta.inheritance_point : 0) + 1;
|
||||||
|
|
||||||
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...general,
|
||||||
|
age: newAge,
|
||||||
|
stats: {
|
||||||
|
leadership: newLead,
|
||||||
|
strength: newStr,
|
||||||
|
intelligence: newIntel,
|
||||||
|
},
|
||||||
|
experience: newExp,
|
||||||
|
dedication: newDed,
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
inheritance_point: inheritance,
|
||||||
|
// Clear other temp vars?
|
||||||
|
},
|
||||||
|
},
|
||||||
|
general.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, RetireArgs, GeneralActionResolveContext<TriggerState>> {
|
||||||
|
public readonly key = ACTION_KEY;
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.resolver = new ActionResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(_raw: unknown): RetireArgs | null {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(_ctx: ConstraintContext, _args: RetireArgs): Constraint[] {
|
||||||
|
return [reqAge()];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: RetireArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionContextBuilder = defaultActionContextBuilder;
|
||||||
|
|
||||||
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
key: 'che_은퇴',
|
||||||
|
category: '개인',
|
||||||
|
reqArg: false,
|
||||||
|
args: {},
|
||||||
|
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import {
|
||||||
|
notSameDestCity,
|
||||||
|
existsDestCity,
|
||||||
|
reqGeneralGold,
|
||||||
|
unknownOrDeny,
|
||||||
|
resolveDestCityId,
|
||||||
|
} 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';
|
||||||
|
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||||
|
|
||||||
|
export interface MoveArgs {
|
||||||
|
destCityId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveResolveContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
moveGenerals?: General<TriggerState>[]; // For roaming move
|
||||||
|
map?: MapDefinition; // For connectivity check
|
||||||
|
develCost?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_NAME = '이동';
|
||||||
|
const ACTION_KEY = 'che_이동';
|
||||||
|
|
||||||
|
// Helper for map connectivity
|
||||||
|
const connectedCity = (): Constraint => ({
|
||||||
|
name: 'ConnectedCity',
|
||||||
|
requires: (ctx) => {
|
||||||
|
const reqs: RequirementKey[] = [];
|
||||||
|
if (ctx.cityId !== undefined) reqs.push({ kind: 'city', id: ctx.cityId });
|
||||||
|
const destCityId = resolveDestCityId(ctx);
|
||||||
|
if (destCityId !== undefined) reqs.push({ kind: 'destCity', id: destCityId });
|
||||||
|
reqs.push({ kind: 'env', key: 'map' });
|
||||||
|
return reqs;
|
||||||
|
},
|
||||||
|
test: (ctx, view) => {
|
||||||
|
const map = view.get({ kind: 'env', key: 'map' }) as MapDefinition | null;
|
||||||
|
if (!map) return unknownOrDeny(ctx, [], '지도 정보가 없습니다.');
|
||||||
|
|
||||||
|
const currentCityId = ctx.cityId;
|
||||||
|
const destCityId = resolveDestCityId(ctx);
|
||||||
|
|
||||||
|
if (currentCityId === undefined || destCityId === undefined) {
|
||||||
|
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCityDef = map.cities.find((c) => c.id === currentCityId);
|
||||||
|
if (!currentCityDef) return unknownOrDeny(ctx, [], '출발 도시 정보가 없습니다.');
|
||||||
|
|
||||||
|
if (currentCityDef.connections.includes(destCityId)) {
|
||||||
|
return { kind: 'allow' };
|
||||||
|
}
|
||||||
|
return { kind: 'deny', reason: '인접하지 않은 도시입니다.' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, MoveArgs> {
|
||||||
|
readonly key = ACTION_KEY;
|
||||||
|
|
||||||
|
resolve(context: MoveResolveContext<TriggerState>, args: MoveArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
const general = context.general;
|
||||||
|
const nation = context.nation;
|
||||||
|
const { destCityId } = args;
|
||||||
|
|
||||||
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
moveTargets = [general, ...others];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cost = context.develCost ?? 0;
|
||||||
|
|
||||||
|
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, '로');
|
||||||
|
|
||||||
|
context.addLog(`<G><b>${destCityName}</b></>${josaRo} 이동했습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const target of moveTargets) {
|
||||||
|
const isSelf = target.id === general.id;
|
||||||
|
|
||||||
|
let nextGold = target.gold;
|
||||||
|
let nextAtmos = target.atmos;
|
||||||
|
let nextExp = target.experience;
|
||||||
|
const nextDed = target.dedication;
|
||||||
|
let nextLeadershipExp = typeof target.meta.leadership_exp === 'number' ? target.meta.leadership_exp : 0;
|
||||||
|
|
||||||
|
if (isSelf) {
|
||||||
|
nextGold = Math.max(0, nextGold - cost);
|
||||||
|
nextAtmos = Math.max(20, nextAtmos - 5);
|
||||||
|
nextExp += 50;
|
||||||
|
nextLeadershipExp += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...target,
|
||||||
|
cityId: destCityId,
|
||||||
|
gold: nextGold,
|
||||||
|
atmos: nextAtmos,
|
||||||
|
experience: nextExp,
|
||||||
|
dedication: nextDed,
|
||||||
|
meta: {
|
||||||
|
...target.meta,
|
||||||
|
leadership_exp: nextLeadershipExp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
target.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, MoveArgs, MoveResolveContext<TriggerState>> {
|
||||||
|
public readonly key = ACTION_KEY;
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.resolver = new ActionResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(raw: unknown): MoveArgs | null {
|
||||||
|
const data = raw as Partial<MoveArgs>;
|
||||||
|
if (typeof data.destCityId !== 'number') return null;
|
||||||
|
return { destCityId: data.destCityId };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(ctx: ConstraintContext, _args: MoveArgs): Constraint[] {
|
||||||
|
const constraints = [
|
||||||
|
existsDestCity(),
|
||||||
|
notSameDestCity(),
|
||||||
|
connectedCity(),
|
||||||
|
reqGeneralGold(() => {
|
||||||
|
const develCost = ctx.env.develCost as number;
|
||||||
|
return develCost ?? 0;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
return constraints;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: MoveResolveContext<TriggerState>, args: MoveArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
map: options.map,
|
||||||
|
develCost: options.scenarioConfig.const.develCost as number | undefined,
|
||||||
|
moveGenerals: options.worldRef?.listGenerals() ?? [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
key: 'che_이동',
|
||||||
|
category: '군사',
|
||||||
|
reqArg: true,
|
||||||
|
args: { destCityId: 0 },
|
||||||
|
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import {
|
||||||
|
notOccupiedDestCity,
|
||||||
|
reqGeneralGold,
|
||||||
|
reqGeneralRice,
|
||||||
|
existsDestCity,
|
||||||
|
notBeNeutral,
|
||||||
|
} 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, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
|
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||||
|
|
||||||
|
export interface SpyArgs {
|
||||||
|
destCityId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpyResolveContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
destCity?: City;
|
||||||
|
env?: TurnCommandEnv;
|
||||||
|
map?: MapDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_NAME = '첩보';
|
||||||
|
const ACTION_KEY = 'che_첩보';
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, SpyArgs> {
|
||||||
|
readonly key = ACTION_KEY;
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: SpyArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
const ctx = context as SpyResolveContext<TriggerState>;
|
||||||
|
const general = ctx.general;
|
||||||
|
const nation = ctx.nation;
|
||||||
|
const { destCityId } = args;
|
||||||
|
|
||||||
|
const destCity = ctx.destCity;
|
||||||
|
if (!destCity) throw new Error('Target city missing');
|
||||||
|
|
||||||
|
const env = ctx.env;
|
||||||
|
const cost = (env?.develCost ?? 100) * 3;
|
||||||
|
|
||||||
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
|
||||||
|
const currentCityId = general.cityId;
|
||||||
|
let isAdjacent = false;
|
||||||
|
if (ctx.map) {
|
||||||
|
const cityDef = ctx.map.cities.find((c) => c.id === currentCityId);
|
||||||
|
if (cityDef && (cityDef.connections.includes(destCityId) || currentCityId === destCityId)) {
|
||||||
|
isAdjacent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const destCityName = destCity.name;
|
||||||
|
ctx.addLog(`<G>${destCityName}</>의 정보를 ${isAdjacent ? '많이' : '어느 정도'} 얻었습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trust = destCity.meta.trust ?? '?';
|
||||||
|
ctx.addLog(`주민:${destCity.population}, 민심:${trust}, ...`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nation) {
|
||||||
|
const spyInfo = (nation.meta.spy as unknown as Record<string, number>) || {};
|
||||||
|
const newSpyInfo = { ...spyInfo, [destCityId]: 3 };
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
...nation,
|
||||||
|
meta: { ...nation.meta, spy: newSpyInfo as unknown as string }, // Workaround for TriggerValue mismatch, engine handles nested objects in meta sometimes or we should JSON stringify.
|
||||||
|
},
|
||||||
|
nation.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...general,
|
||||||
|
gold: Math.max(0, general.gold - cost),
|
||||||
|
rice: Math.max(0, general.rice - cost),
|
||||||
|
experience: general.experience + 50,
|
||||||
|
dedication: general.dedication + 30,
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
leadership_exp:
|
||||||
|
(typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
general.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, SpyArgs, GeneralActionResolveContext<TriggerState>> {
|
||||||
|
public readonly key = ACTION_KEY;
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.resolver = new ActionResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(raw: unknown): SpyArgs | null {
|
||||||
|
const data = raw as Partial<SpyArgs>;
|
||||||
|
if (typeof data.destCityId !== 'number') return null;
|
||||||
|
return { destCityId: data.destCityId };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(ctx: ConstraintContext, _args: SpyArgs): Constraint[] {
|
||||||
|
const env = ctx.env;
|
||||||
|
const cost = ((env.develCost as number) ?? 100) * 3;
|
||||||
|
return [
|
||||||
|
notBeNeutral(),
|
||||||
|
existsDestCity(),
|
||||||
|
reqGeneralGold(() => cost),
|
||||||
|
reqGeneralRice(() => cost),
|
||||||
|
notOccupiedDestCity(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: SpyArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
||||||
|
const destCityId = options.actionArgs?.destCityId;
|
||||||
|
let destCity = null;
|
||||||
|
if (typeof destCityId === 'number' && options.worldRef) {
|
||||||
|
destCity = options.worldRef.getCityById(destCityId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
destCity,
|
||||||
|
map: options.map,
|
||||||
|
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
key: 'che_첩보',
|
||||||
|
category: '군사',
|
||||||
|
reqArg: true,
|
||||||
|
args: { destCityId: 0 },
|
||||||
|
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import type { City, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import {
|
||||||
|
notBeNeutral,
|
||||||
|
notOccupiedDestCity,
|
||||||
|
reqGeneralGold,
|
||||||
|
reqGeneralRice,
|
||||||
|
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,
|
||||||
|
createCityPatchEffect,
|
||||||
|
createNationPatchEffect,
|
||||||
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
|
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
export interface SeizeArgs {
|
||||||
|
destCityId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeizeResolveContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
destCity?: City;
|
||||||
|
destNation?: Nation | null;
|
||||||
|
env?: TurnCommandEnv;
|
||||||
|
year?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_NAME = '탈취';
|
||||||
|
const ACTION_KEY = 'che_탈취';
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, SeizeArgs> {
|
||||||
|
readonly key = ACTION_KEY;
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: SeizeArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
const ctx = context as SeizeResolveContext<TriggerState>;
|
||||||
|
const general = ctx.general;
|
||||||
|
const nation = ctx.nation; // Own nation
|
||||||
|
const { destCityId } = args;
|
||||||
|
const destCity = ctx.destCity;
|
||||||
|
const destNation = ctx.destNation;
|
||||||
|
|
||||||
|
if (!destCity) throw new Error('Target city missing');
|
||||||
|
|
||||||
|
const env = ctx.env;
|
||||||
|
const cost = env?.develCost ?? 100;
|
||||||
|
|
||||||
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
|
||||||
|
// Calculation
|
||||||
|
const min = env?.sabotageDamageMin ?? 10;
|
||||||
|
const max = env?.sabotageDamageMax ?? 30;
|
||||||
|
const rng = ctx.rng;
|
||||||
|
if (!rng) throw new Error('RNG missing');
|
||||||
|
|
||||||
|
const currentYear = ctx.year ?? 200;
|
||||||
|
const startYear = env?.openingPartYear ?? currentYear;
|
||||||
|
const yearCoef = Math.sqrt(1 + Math.max(0, currentYear - startYear) / 4) / 2;
|
||||||
|
|
||||||
|
const commRatio = destCity.commerce / destCity.commerceMax;
|
||||||
|
const agriRatio = destCity.agriculture / destCity.agricultureMax;
|
||||||
|
|
||||||
|
const rawGold = rng.nextInt(min, max + 1) * destCity.level * yearCoef * (0.25 + commRatio / 4);
|
||||||
|
const rawRice = rng.nextInt(min, max + 1) * destCity.level * yearCoef * (0.25 + agriRatio / 4);
|
||||||
|
|
||||||
|
let stolenGold = Math.floor(rawGold);
|
||||||
|
let stolenRice = Math.floor(rawRice);
|
||||||
|
|
||||||
|
const isSupplied = destCity.supplyState === 1;
|
||||||
|
|
||||||
|
if (isSupplied && destNation) {
|
||||||
|
const minGold = 1000;
|
||||||
|
const minRice = 1000;
|
||||||
|
|
||||||
|
const availableGold = Math.max(0, destNation.gold - minGold);
|
||||||
|
const availableRice = Math.max(0, destNation.rice - minRice);
|
||||||
|
|
||||||
|
stolenGold = Math.min(stolenGold, availableGold);
|
||||||
|
stolenRice = Math.min(stolenRice, availableRice);
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
...destNation,
|
||||||
|
gold: destNation.gold - stolenGold,
|
||||||
|
rice: destNation.rice - stolenRice,
|
||||||
|
},
|
||||||
|
destNation.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createCityPatchEffect(
|
||||||
|
{
|
||||||
|
...destCity,
|
||||||
|
state: 34,
|
||||||
|
},
|
||||||
|
destCityId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const commDmg = Math.floor(stolenGold / 12);
|
||||||
|
const agriDmg = Math.floor(stolenRice / 12);
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createCityPatchEffect(
|
||||||
|
{
|
||||||
|
...destCity,
|
||||||
|
commerce: Math.max(0, destCity.commerce - commDmg),
|
||||||
|
agriculture: Math.max(0, destCity.agriculture - agriDmg),
|
||||||
|
state: 34,
|
||||||
|
},
|
||||||
|
destCityId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let myShareGold = stolenGold;
|
||||||
|
let myShareRice = stolenRice;
|
||||||
|
|
||||||
|
if (nation && nation.id !== 0) {
|
||||||
|
const nationShareGold = Math.floor(stolenGold * 0.7);
|
||||||
|
const nationShareRice = Math.floor(stolenRice * 0.7);
|
||||||
|
myShareGold -= nationShareGold;
|
||||||
|
myShareRice -= nationShareRice;
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
...nation,
|
||||||
|
gold: nation.gold + nationShareGold,
|
||||||
|
rice: nation.rice + nationShareRice,
|
||||||
|
},
|
||||||
|
nation.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destCityName = destCity.name;
|
||||||
|
ctx.addLog(`<G>${destCityName}</>에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 성공했습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
ctx.addLog(`금 ${stolenGold}, 쌀 ${stolenRice} 을 획득했습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...general,
|
||||||
|
gold: Math.max(0, general.gold - cost + myShareGold),
|
||||||
|
rice: Math.max(0, general.rice - cost + myShareRice),
|
||||||
|
experience: general.experience + 50,
|
||||||
|
dedication: general.dedication + 30,
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
strength_exp:
|
||||||
|
(typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
general.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, SeizeArgs, GeneralActionResolveContext<TriggerState>> {
|
||||||
|
public readonly key = ACTION_KEY;
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor(_env: TurnCommandEnv) {
|
||||||
|
this.resolver = new ActionResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(raw: unknown): SeizeArgs | null {
|
||||||
|
const data = raw as Partial<SeizeArgs>;
|
||||||
|
if (typeof data.destCityId !== 'number') return null;
|
||||||
|
return { destCityId: data.destCityId };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] {
|
||||||
|
const env = ctx.env;
|
||||||
|
const cost = (env.develCost as number) ?? 100;
|
||||||
|
return [
|
||||||
|
notBeNeutral(),
|
||||||
|
existsDestCity(),
|
||||||
|
reqGeneralGold(() => cost),
|
||||||
|
reqGeneralRice(() => cost),
|
||||||
|
notOccupiedDestCity(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: SeizeArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
||||||
|
const destCityId = options.actionArgs?.destCityId;
|
||||||
|
let destCity = null;
|
||||||
|
let destNation = null;
|
||||||
|
if (typeof destCityId === 'number' && options.worldRef) {
|
||||||
|
destCity = options.worldRef.getCityById(destCityId);
|
||||||
|
if (destCity && destCity.nationId) {
|
||||||
|
destNation = options.worldRef.getNationById(destCity.nationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
destCity,
|
||||||
|
destNation,
|
||||||
|
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
||||||
|
year: options.world.currentYear,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
key: 'che_탈취',
|
||||||
|
category: '군사',
|
||||||
|
reqArg: true,
|
||||||
|
args: { destCityId: 0 },
|
||||||
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import {
|
||||||
|
notBeNeutral,
|
||||||
|
notOccupiedDestCity,
|
||||||
|
reqGeneralGold,
|
||||||
|
reqGeneralRice,
|
||||||
|
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, createCityPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
|
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
export interface DestroyArgs {
|
||||||
|
destCityId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DestroyResolveContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
destCity?: City;
|
||||||
|
env?: TurnCommandEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_NAME = '파괴';
|
||||||
|
const ACTION_KEY = 'che_파괴';
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, DestroyArgs> {
|
||||||
|
readonly key = ACTION_KEY;
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: DestroyArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
const ctx = context as DestroyResolveContext<TriggerState>;
|
||||||
|
const general = ctx.general;
|
||||||
|
const { destCityId } = args;
|
||||||
|
const destCity = ctx.destCity;
|
||||||
|
if (!destCity) throw new Error('Target city missing');
|
||||||
|
|
||||||
|
const env = ctx.env;
|
||||||
|
const cost = env?.develCost ?? 100; // 1x develCost
|
||||||
|
|
||||||
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
|
||||||
|
// Damage calc
|
||||||
|
const min = env?.sabotageDamageMin ?? 10;
|
||||||
|
const max = env?.sabotageDamageMax ?? 30;
|
||||||
|
const rng = ctx.rng;
|
||||||
|
|
||||||
|
if (!rng) throw new Error('RNG missing');
|
||||||
|
|
||||||
|
const defDmg = rng.nextInt(min, max + 1);
|
||||||
|
const wallDmg = rng.nextInt(min, max + 1);
|
||||||
|
|
||||||
|
const newDef = Math.max(0, destCity.defence - defDmg);
|
||||||
|
const newWall = Math.max(0, destCity.wall - wallDmg);
|
||||||
|
|
||||||
|
const actualDefDmg = destCity.defence - newDef;
|
||||||
|
const actualWallDmg = destCity.wall - newWall;
|
||||||
|
|
||||||
|
// Log
|
||||||
|
const destCityName = destCity.name;
|
||||||
|
ctx.addLog(`<G>${destCityName}</>에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 성공했습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
ctx.addLog(`수비가 ${actualDefDmg}, 성벽이 ${actualWallDmg} 만큼 감소했습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
// City Update
|
||||||
|
effects.push(
|
||||||
|
createCityPatchEffect(
|
||||||
|
{
|
||||||
|
...destCity,
|
||||||
|
defence: newDef,
|
||||||
|
wall: newWall,
|
||||||
|
state: 32, // Legacy sabotage state
|
||||||
|
},
|
||||||
|
destCityId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// General Update (Cost + Exp)
|
||||||
|
effects.push(
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...general,
|
||||||
|
gold: Math.max(0, general.gold - cost),
|
||||||
|
rice: Math.max(0, general.rice - cost),
|
||||||
|
experience: general.experience + 50,
|
||||||
|
dedication: general.dedication + 30,
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
strength_exp:
|
||||||
|
(typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
general.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, DestroyArgs, GeneralActionResolveContext<TriggerState>> {
|
||||||
|
public readonly key = ACTION_KEY;
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor(_env: TurnCommandEnv) {
|
||||||
|
this.resolver = new ActionResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(raw: unknown): DestroyArgs | null {
|
||||||
|
const data = raw as Partial<DestroyArgs>;
|
||||||
|
if (typeof data.destCityId !== 'number') return null;
|
||||||
|
return { destCityId: data.destCityId };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] {
|
||||||
|
const env = ctx.env;
|
||||||
|
const cost = (env.develCost as number) ?? 100;
|
||||||
|
return [
|
||||||
|
notBeNeutral(),
|
||||||
|
existsDestCity(),
|
||||||
|
reqGeneralGold(() => cost),
|
||||||
|
reqGeneralRice(() => cost),
|
||||||
|
notOccupiedDestCity(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: DestroyArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
||||||
|
const destCityId = options.actionArgs?.destCityId;
|
||||||
|
let destCity = null;
|
||||||
|
if (typeof destCityId === 'number' && options.worldRef) {
|
||||||
|
destCity = options.worldRef.getCityById(destCityId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
destCity,
|
||||||
|
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
key: 'che_파괴',
|
||||||
|
category: '군사',
|
||||||
|
reqArg: true,
|
||||||
|
args: { destCityId: 0 },
|
||||||
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import { notBeNeutral } from '@sammo-ts/logic/constraints/presets.js';
|
||||||
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
|
import type {
|
||||||
|
GeneralActionOutcome,
|
||||||
|
GeneralActionResolveContext,
|
||||||
|
GeneralActionResolver,
|
||||||
|
} 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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
|
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
|
export interface ResignArgs {}
|
||||||
|
|
||||||
|
const ACTION_NAME = '하야';
|
||||||
|
const ACTION_KEY = 'che_하야';
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, ResignArgs> {
|
||||||
|
readonly key = ACTION_KEY;
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, _args: ResignArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
const general = context.general;
|
||||||
|
const nation = context.nation;
|
||||||
|
|
||||||
|
if (!nation) {
|
||||||
|
throw new Error('Resign requires a nation context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const effects = [];
|
||||||
|
|
||||||
|
// Return resources
|
||||||
|
const maxKeepGold = 1000;
|
||||||
|
const maxKeepRice = 1000;
|
||||||
|
|
||||||
|
const newGold = Math.min(general.gold, maxKeepGold);
|
||||||
|
const newRice = Math.min(general.rice, maxKeepRice);
|
||||||
|
|
||||||
|
const returnedGold = general.gold - newGold;
|
||||||
|
const returnedRice = general.rice - newRice;
|
||||||
|
|
||||||
|
if (returnedGold > 0 || returnedRice > 0) {
|
||||||
|
effects.push(
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
...nation,
|
||||||
|
gold: nation.gold + returnedGold,
|
||||||
|
rice: nation.rice + returnedRice,
|
||||||
|
},
|
||||||
|
nation.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalty
|
||||||
|
const betrayal = (general.meta.betrayal as number) ?? 0;
|
||||||
|
const penaltyRatio = 0.1 + betrayal * 0.05;
|
||||||
|
const nextExp = Math.floor(general.experience * (1 - penaltyRatio));
|
||||||
|
const nextDed = Math.floor(general.dedication * (1 - penaltyRatio));
|
||||||
|
|
||||||
|
context.addLog(`하야하여 재야로 돌아갑니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
effects.push(
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...general,
|
||||||
|
nationId: 0,
|
||||||
|
officerLevel: 0,
|
||||||
|
gold: newGold,
|
||||||
|
rice: newRice,
|
||||||
|
experience: nextExp,
|
||||||
|
dedication: nextDed,
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
betrayal: betrayal + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
general.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, ResignArgs, GeneralActionResolveContext<TriggerState>> {
|
||||||
|
public readonly key = ACTION_KEY;
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.resolver = new ActionResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(_raw: unknown): ResignArgs | null {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(_ctx: ConstraintContext, _args: ResignArgs): Constraint[] {
|
||||||
|
return [notBeNeutral()];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: ResignArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionContextBuilder = defaultActionContextBuilder;
|
||||||
|
|
||||||
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
key: ACTION_KEY,
|
||||||
|
category: '인사',
|
||||||
|
reqArg: false,
|
||||||
|
args: {},
|
||||||
|
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import {
|
||||||
|
notBeNeutral,
|
||||||
|
notWanderingNation,
|
||||||
|
reqGeneralGold,
|
||||||
|
reqGeneralRice,
|
||||||
|
} from '@sammo-ts/logic/constraints/presets.js';
|
||||||
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
|
import type {
|
||||||
|
GeneralActionOutcome,
|
||||||
|
GeneralActionResolveContext,
|
||||||
|
GeneralActionResolver,
|
||||||
|
} 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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
|
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
|
export interface DonateArgs {
|
||||||
|
isGold: boolean;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_NAME = '헌납';
|
||||||
|
const ACTION_KEY = 'che_헌납';
|
||||||
|
|
||||||
|
export class ActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, DonateArgs> {
|
||||||
|
readonly key = ACTION_KEY;
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: DonateArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
const general = context.general;
|
||||||
|
const nation = context.nation;
|
||||||
|
const { isGold, amount } = args;
|
||||||
|
|
||||||
|
if (!nation) {
|
||||||
|
throw new Error('Donate requires a nation context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resKey = isGold ? 'gold' : 'rice';
|
||||||
|
const resName = isGold ? '금' : '쌀';
|
||||||
|
const currentRes = general[resKey] ?? 0;
|
||||||
|
|
||||||
|
const realAmount = Math.max(0, Math.min(amount, currentRes));
|
||||||
|
|
||||||
|
// Exp/Ded calculation
|
||||||
|
const exp = Math.floor(realAmount / 100);
|
||||||
|
const ded = Math.floor(realAmount / 50);
|
||||||
|
|
||||||
|
const amountText = realAmount.toLocaleString();
|
||||||
|
context.addLog(`${resName} <C>${amountText}</>을 헌납했습니다.`, {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
effects: [
|
||||||
|
createGeneralPatchEffect(
|
||||||
|
{
|
||||||
|
...general,
|
||||||
|
[resKey]: currentRes - realAmount,
|
||||||
|
experience: general.experience + exp,
|
||||||
|
dedication: general.dedication + ded,
|
||||||
|
},
|
||||||
|
general.id
|
||||||
|
),
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
...nation,
|
||||||
|
[resKey]: (nation[resKey] ?? 0) + realAmount,
|
||||||
|
},
|
||||||
|
nation.id
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, DonateArgs, GeneralActionResolveContext<TriggerState>> {
|
||||||
|
public readonly key = ACTION_KEY;
|
||||||
|
public readonly name = ACTION_NAME;
|
||||||
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.resolver = new ActionResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(raw: unknown): DonateArgs | null {
|
||||||
|
const data = raw as Partial<DonateArgs>;
|
||||||
|
if (typeof data.isGold !== 'boolean' || typeof data.amount !== 'number') return null;
|
||||||
|
return { isGold: data.isGold, amount: data.amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(_ctx: ConstraintContext, args: DonateArgs): Constraint[] {
|
||||||
|
if (args.isGold) {
|
||||||
|
return [notBeNeutral(), notWanderingNation(), reqGeneralGold(() => args.amount)];
|
||||||
|
}
|
||||||
|
return [notBeNeutral(), notWanderingNation(), reqGeneralRice(() => args.amount)];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: DonateArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionContextBuilder = defaultActionContextBuilder;
|
||||||
|
|
||||||
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
key: 'che_헌납',
|
||||||
|
category: '내정',
|
||||||
|
reqArg: true,
|
||||||
|
args: { isGold: true, amount: 0 },
|
||||||
|
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||||
|
};
|
||||||
@@ -28,6 +28,17 @@ export const GENERAL_TURN_COMMAND_KEYS = [
|
|||||||
'che_모병',
|
'che_모병',
|
||||||
'che_소집해제',
|
'che_소집해제',
|
||||||
'che_군량매매',
|
'che_군량매매',
|
||||||
|
'che_물자조달',
|
||||||
|
'che_헌납',
|
||||||
|
'che_이동',
|
||||||
|
'che_방랑',
|
||||||
|
'che_하야',
|
||||||
|
'che_은퇴',
|
||||||
|
'che_등용',
|
||||||
|
'che_첩보',
|
||||||
|
'che_파괴',
|
||||||
|
'che_선동',
|
||||||
|
'che_탈취',
|
||||||
'휴식',
|
'휴식',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -67,6 +78,17 @@ const defaultImporters: Record<GeneralTurnCommandKey, GeneralTurnCommandImporter
|
|||||||
che_모병: async () => import('./che_모병.js'),
|
che_모병: async () => import('./che_모병.js'),
|
||||||
che_소집해제: async () => import('./che_소집해제.js'),
|
che_소집해제: async () => import('./che_소집해제.js'),
|
||||||
che_군량매매: async () => import('./che_군량매매.js'),
|
che_군량매매: async () => import('./che_군량매매.js'),
|
||||||
|
che_물자조달: async () => import('./che_물자조달.js'),
|
||||||
|
che_헌납: async () => import('./che_헌납.js'),
|
||||||
|
che_이동: async () => import('./che_이동.js'),
|
||||||
|
che_방랑: async () => import('./che_방랑.js'),
|
||||||
|
che_하야: async () => import('./che_하야.js'),
|
||||||
|
che_은퇴: async () => import('./che_은퇴.js'),
|
||||||
|
che_등용: async () => import('./che_등용.js'),
|
||||||
|
che_첩보: async () => import('./che_첩보.js'),
|
||||||
|
che_파괴: async () => import('./che_파괴.js'),
|
||||||
|
che_선동: async () => import('./che_선동.js'),
|
||||||
|
che_탈취: async () => import('./che_탈취.js'),
|
||||||
휴식: async () => import('./휴식.js'),
|
휴식: async () => import('./휴식.js'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { MINIMAL_MAP } from '../fixtures/minimalMap.js';
|
||||||
|
import { InMemoryWorld, TestGameRunner } from '../testEnv.js';
|
||||||
|
import type { City, General, Nation } from '../../src/domain/entities.js';
|
||||||
|
import type { WorldSnapshot } from '../../src/world/types.js';
|
||||||
|
import { commandSpec as procureSpec } from '../../src/actions/turn/general/che_물자조달.js';
|
||||||
|
import { commandSpec as donateSpec } from '../../src/actions/turn/general/che_헌납.js';
|
||||||
|
import { commandSpec as moveSpec } from '../../src/actions/turn/general/che_이동.js';
|
||||||
|
import { commandSpec as wanderSpec } from '../../src/actions/turn/general/che_방랑.js';
|
||||||
|
import { commandSpec as resignSpec } from '../../src/actions/turn/general/che_하야.js';
|
||||||
|
import { commandSpec as retireSpec } from '../../src/actions/turn/general/che_은퇴.js';
|
||||||
|
import { commandSpec as employSpec } from '../../src/actions/turn/general/che_등용.js';
|
||||||
|
import { commandSpec as spySpec } from '../../src/actions/turn/general/che_첩보.js';
|
||||||
|
import { commandSpec as destroySpec } from '../../src/actions/turn/general/che_파괴.js';
|
||||||
|
import { commandSpec as agitateSpec } from '../../src/actions/turn/general/che_선동.js';
|
||||||
|
import { commandSpec as seizeSpec } from '../../src/actions/turn/general/che_탈취.js';
|
||||||
|
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
||||||
|
|
||||||
|
describe('General Commands New Scenario', () => {
|
||||||
|
// 1. Setup Environment
|
||||||
|
const systemEnv: TurnCommandEnv = {
|
||||||
|
develCost: 100,
|
||||||
|
trainDelta: 35,
|
||||||
|
atmosDelta: 35,
|
||||||
|
maxTrainByCommand: 100,
|
||||||
|
maxAtmosByCommand: 100,
|
||||||
|
sabotageDefaultProb: 0.5,
|
||||||
|
sabotageProbCoefByStat: 0.1,
|
||||||
|
sabotageDefenceCoefByGeneralCount: 0.1,
|
||||||
|
sabotageDamageMin: 10,
|
||||||
|
sabotageDamageMax: 30,
|
||||||
|
openingPartYear: 200,
|
||||||
|
maxGeneral: 10,
|
||||||
|
defaultNpcGold: 1000,
|
||||||
|
defaultNpcRice: 1000,
|
||||||
|
defaultCrewTypeId: 1,
|
||||||
|
defaultSpecialDomestic: null,
|
||||||
|
defaultSpecialWar: null,
|
||||||
|
initialNationGenLimit: 10,
|
||||||
|
maxTechLevel: 10,
|
||||||
|
baseGold: 1000,
|
||||||
|
baseRice: 1000,
|
||||||
|
maxResourceActionAmount: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should execute procure, donate, move, and status changes', async () => {
|
||||||
|
const mockNation: Nation = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Nation A',
|
||||||
|
color: '#FF0000',
|
||||||
|
capitalCityId: 1,
|
||||||
|
chiefGeneralId: 1,
|
||||||
|
gold: 10000,
|
||||||
|
rice: 10000,
|
||||||
|
power: 0,
|
||||||
|
level: 5,
|
||||||
|
typeCode: 'test',
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const city1: City = {
|
||||||
|
id: 1,
|
||||||
|
name: 'City 1',
|
||||||
|
nationId: 1,
|
||||||
|
level: 1,
|
||||||
|
state: 0,
|
||||||
|
population: 50000,
|
||||||
|
populationMax: 50000,
|
||||||
|
agriculture: 500,
|
||||||
|
agricultureMax: 1000,
|
||||||
|
commerce: 500,
|
||||||
|
commerceMax: 1000,
|
||||||
|
security: 500,
|
||||||
|
securityMax: 1000,
|
||||||
|
defence: 500,
|
||||||
|
defenceMax: 1000,
|
||||||
|
wall: 500,
|
||||||
|
wallMax: 1000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
meta: { trust: 50 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// City 2 (Neighbor)
|
||||||
|
const city2: City = {
|
||||||
|
id: 2,
|
||||||
|
name: 'City 2',
|
||||||
|
nationId: 1,
|
||||||
|
level: 1,
|
||||||
|
state: 0,
|
||||||
|
population: 50000,
|
||||||
|
populationMax: 50000,
|
||||||
|
agriculture: 500,
|
||||||
|
agricultureMax: 1000,
|
||||||
|
commerce: 500,
|
||||||
|
commerceMax: 1000,
|
||||||
|
security: 500,
|
||||||
|
securityMax: 1000,
|
||||||
|
defence: 500,
|
||||||
|
defenceMax: 1000,
|
||||||
|
wall: 500,
|
||||||
|
wallMax: 1000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
meta: { trust: 50 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const general1: General = {
|
||||||
|
id: 1,
|
||||||
|
name: 'General 1',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
npcState: 0,
|
||||||
|
experience: 100,
|
||||||
|
dedication: 100,
|
||||||
|
officerLevel: 12, // Lord
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 1,
|
||||||
|
train: 10,
|
||||||
|
atmos: 10,
|
||||||
|
injury: 0,
|
||||||
|
age: 30,
|
||||||
|
stats: { leadership: 80, strength: 80, intelligence: 80 },
|
||||||
|
role: {
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot: WorldSnapshot = {
|
||||||
|
scenarioConfig: { environment: { mapName: 'minimal_map', unitSet: 'default' }, options: {} } as any,
|
||||||
|
scenarioMeta: {
|
||||||
|
title: 'Test',
|
||||||
|
startYear: 200,
|
||||||
|
life: 0,
|
||||||
|
fiction: 0,
|
||||||
|
history: [],
|
||||||
|
ignoreDefaultEvents: false,
|
||||||
|
},
|
||||||
|
map: MINIMAL_MAP,
|
||||||
|
unitSet: { id: 'default', name: 'default', crewTypes: [] } as any,
|
||||||
|
nations: [mockNation],
|
||||||
|
cities: [city1, city2],
|
||||||
|
generals: [general1],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const world = new InMemoryWorld(snapshot);
|
||||||
|
const runner = new TestGameRunner(world, 200, 1);
|
||||||
|
|
||||||
|
// 1. Procure
|
||||||
|
const procureDef = procureSpec.createDefinition(systemEnv);
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_물자조달',
|
||||||
|
resolver: procureDef,
|
||||||
|
args: { isGold: true },
|
||||||
|
context: {
|
||||||
|
rng: {
|
||||||
|
real: () => 0.9,
|
||||||
|
int: (min: number, _max: number) => min,
|
||||||
|
nextInt: (min: number, _max: number) => min,
|
||||||
|
next: () => 0.9,
|
||||||
|
nextBool: () => true,
|
||||||
|
nextRange: (_min: number, max: number) => max,
|
||||||
|
nextRangeInt: (_min: number, max: number) => max,
|
||||||
|
nextFloat: () => 0.9, // Ensure success
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const n1_after_procure = world.getNation(1)!;
|
||||||
|
// Nation gains gold
|
||||||
|
expect(n1_after_procure.gold).toBeGreaterThan(10000);
|
||||||
|
|
||||||
|
// 2. Donate
|
||||||
|
const donateDef = donateSpec.createDefinition(systemEnv);
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_헌납',
|
||||||
|
resolver: donateDef,
|
||||||
|
args: { isGold: true, amount: 100 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const g1_after_donate = world.getGeneral(1)!;
|
||||||
|
const n1_after_donate = world.getNation(1)!;
|
||||||
|
expect(g1_after_donate.gold).toBe(900); // 1000 - 100 (Procure cost 0)
|
||||||
|
expect(n1_after_donate.gold).toBeGreaterThan(10100); // 10000 + Procure + 100
|
||||||
|
|
||||||
|
// 3. Move
|
||||||
|
const moveDef = moveSpec.createDefinition(systemEnv);
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_이동',
|
||||||
|
resolver: moveDef,
|
||||||
|
args: { destCityId: 2 },
|
||||||
|
context: { map: MINIMAL_MAP }, // Needed for ConnectedCity check
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const g1_after_move = world.getGeneral(1)!;
|
||||||
|
expect(g1_after_move.cityId).toBe(2);
|
||||||
|
|
||||||
|
// 4. Wander (Must be Lord)
|
||||||
|
const wanderDef = wanderSpec.createDefinition(systemEnv);
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_방랑',
|
||||||
|
resolver: wanderDef,
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const n1_after_wander = world.getNation(1)!;
|
||||||
|
expect(n1_after_wander.level).toBe(0);
|
||||||
|
expect(n1_after_wander.typeCode).toBe('None');
|
||||||
|
|
||||||
|
// 5. Resign
|
||||||
|
const resignDef = resignSpec.createDefinition(systemEnv);
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_하야',
|
||||||
|
resolver: resignDef,
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const g1_after_resign = world.getGeneral(1)!;
|
||||||
|
expect(g1_after_resign.nationId).toBe(0);
|
||||||
|
|
||||||
|
// 6. Retire (Needs age >= 60)
|
||||||
|
// Manually set age
|
||||||
|
// Manually set age
|
||||||
|
const gToRetire = { ...g1_after_resign, age: 65 };
|
||||||
|
world.snapshot.generals = world.snapshot.generals.map((g) => (g.id === 1 ? gToRetire : g));
|
||||||
|
const retireDef = retireSpec.createDefinition(systemEnv);
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_은퇴',
|
||||||
|
resolver: retireDef,
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const g1_after_retire = world.getGeneral(1)!;
|
||||||
|
expect(g1_after_retire.age).toBe(20);
|
||||||
|
expect(g1_after_retire.experience).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute employ and sabotage commands', async () => {
|
||||||
|
// Setup: General 1 (Nation 1) -> General 2 (Nation 2) / City 2 (Nation 2)
|
||||||
|
const nation1: Nation = {
|
||||||
|
id: 1,
|
||||||
|
name: 'N1',
|
||||||
|
color: 'red',
|
||||||
|
capitalCityId: 1,
|
||||||
|
chiefGeneralId: 1,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'test',
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
const nation2: Nation = {
|
||||||
|
id: 2,
|
||||||
|
name: 'N2',
|
||||||
|
color: 'blue',
|
||||||
|
capitalCityId: 2,
|
||||||
|
chiefGeneralId: 2,
|
||||||
|
gold: 5000,
|
||||||
|
rice: 5000,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'test',
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const city1: City = {
|
||||||
|
id: 1,
|
||||||
|
name: 'C1',
|
||||||
|
nationId: 1,
|
||||||
|
level: 1,
|
||||||
|
state: 0,
|
||||||
|
population: 1000,
|
||||||
|
populationMax: 1000,
|
||||||
|
agriculture: 1000,
|
||||||
|
agricultureMax: 1000,
|
||||||
|
commerce: 1000,
|
||||||
|
commerceMax: 1000,
|
||||||
|
security: 1000,
|
||||||
|
securityMax: 1000,
|
||||||
|
defence: 1000,
|
||||||
|
defenceMax: 1000,
|
||||||
|
wall: 1000,
|
||||||
|
wallMax: 1000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
meta: { trust: 100 },
|
||||||
|
};
|
||||||
|
// City 2 is target
|
||||||
|
const city2: City = {
|
||||||
|
id: 2,
|
||||||
|
name: 'C2',
|
||||||
|
nationId: 2,
|
||||||
|
level: 1,
|
||||||
|
state: 0,
|
||||||
|
population: 1000,
|
||||||
|
populationMax: 1000,
|
||||||
|
agriculture: 1000,
|
||||||
|
agricultureMax: 1000,
|
||||||
|
commerce: 1000,
|
||||||
|
commerceMax: 1000,
|
||||||
|
security: 1000,
|
||||||
|
securityMax: 1000,
|
||||||
|
defence: 1000,
|
||||||
|
defenceMax: 1000,
|
||||||
|
wall: 1000,
|
||||||
|
wallMax: 1000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
meta: { trust: 100 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const gen1: General = { id: 1, name: 'G1', nationId: 1, cityId: 1, troopId: 0, npcState: 0, ticket: 0 } as any;
|
||||||
|
Object.assign(gen1, {
|
||||||
|
experience: 100,
|
||||||
|
dedication: 100,
|
||||||
|
officerLevel: 5,
|
||||||
|
gold: 5000,
|
||||||
|
rice: 5000,
|
||||||
|
crew: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
injury: 0,
|
||||||
|
age: 20,
|
||||||
|
stats: { leadership: 80, strength: 80, intelligence: 80 },
|
||||||
|
role: { personality: null, items: {} },
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const gen2: General = { id: 2, name: 'G2', nationId: 2, cityId: 2, troopId: 0, npcState: 0, ticket: 0 } as any;
|
||||||
|
Object.assign(gen2, {
|
||||||
|
experience: 100,
|
||||||
|
dedication: 100,
|
||||||
|
officerLevel: 5,
|
||||||
|
gold: 5000,
|
||||||
|
rice: 5000,
|
||||||
|
crew: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
injury: 0,
|
||||||
|
age: 20,
|
||||||
|
stats: { leadership: 80, strength: 80, intelligence: 80 },
|
||||||
|
role: { personality: null, items: {} },
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot: WorldSnapshot = {
|
||||||
|
scenarioConfig: {} as any,
|
||||||
|
scenarioMeta: {} as any,
|
||||||
|
map: MINIMAL_MAP,
|
||||||
|
unitSet: {} as any,
|
||||||
|
nations: [nation1, nation2],
|
||||||
|
cities: [city1, city2],
|
||||||
|
generals: [gen1, gen2],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const world = new InMemoryWorld(snapshot);
|
||||||
|
const runner = new TestGameRunner(world, 200, 1);
|
||||||
|
|
||||||
|
// 1. Employ (G1 -> G2)
|
||||||
|
const employDef = employSpec.createDefinition(systemEnv);
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_등용',
|
||||||
|
resolver: employDef,
|
||||||
|
args: { destGeneralId: 2 },
|
||||||
|
context: { destGeneral: gen2, env: systemEnv }, // Manual inject for resolver context
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify Logs? (Runner doesn't expose logs easily, but we checks no throw)
|
||||||
|
|
||||||
|
// 2. Spy (G1 -> C2)
|
||||||
|
const spyDef = spySpec.createDefinition(systemEnv);
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_첩보',
|
||||||
|
resolver: spyDef,
|
||||||
|
args: { destCityId: 2 },
|
||||||
|
context: { destCity: city2, env: systemEnv, map: MINIMAL_MAP },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const n1_after_spy = world.getNation(1)!;
|
||||||
|
const spyInfo = n1_after_spy.meta.spy as any;
|
||||||
|
expect(spyInfo['2']).toBe(3); // City 2 spied level 3
|
||||||
|
|
||||||
|
// 3. Destroy (G1 -> C2)
|
||||||
|
const destroyDef = destroySpec.createDefinition(systemEnv);
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_파괴',
|
||||||
|
resolver: destroyDef,
|
||||||
|
args: { destCityId: 2 },
|
||||||
|
context: { destCity: city2, env: systemEnv },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const c2_after_destroy = world.getCity(2)!;
|
||||||
|
expect(c2_after_destroy.defence).toBeLessThan(1000);
|
||||||
|
expect(c2_after_destroy.state).toBe(32);
|
||||||
|
|
||||||
|
// 4. Agitate (G1 -> C2)
|
||||||
|
const agitateDef = agitateSpec.createDefinition(systemEnv);
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_선동',
|
||||||
|
resolver: agitateDef,
|
||||||
|
args: { destCityId: 2 },
|
||||||
|
context: { destCity: city2, env: systemEnv },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const c2_after_agitate = world.getCity(2)!;
|
||||||
|
const trust = c2_after_agitate.meta.trust as number;
|
||||||
|
expect(trust).toBeLessThan(100);
|
||||||
|
expect(c2_after_agitate.security).toBeLessThan(1000);
|
||||||
|
|
||||||
|
// 5. Seize (G1 -> C2)
|
||||||
|
const seizeDef = seizeSpec.createDefinition(systemEnv);
|
||||||
|
// Ensure C2 is supplied (nation has gold/rice)
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: 1,
|
||||||
|
commandKey: 'che_탈취',
|
||||||
|
resolver: seizeDef,
|
||||||
|
args: { destCityId: 2 },
|
||||||
|
context: { destCity: city2, destNation: nation2, env: systemEnv },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const n2_after_seize = world.getNation(2)!;
|
||||||
|
expect(n2_after_seize.gold).toBeLessThan(5000); // Stolen from nation
|
||||||
|
|
||||||
|
const g1_after_seize = world.getGeneral(1)!;
|
||||||
|
|
||||||
|
expect(g1_after_seize.gold).toBeGreaterThan(4000); // Cost deducted multiple times
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -200,6 +200,10 @@ export class TestGameRunner {
|
|||||||
int: (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min,
|
int: (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min,
|
||||||
nextInt: (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min,
|
nextInt: (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min,
|
||||||
next: () => Math.random(),
|
next: () => Math.random(),
|
||||||
|
nextBool: () => Math.random() < 0.5,
|
||||||
|
nextRange: (min: number, max: number) => Math.random() * (max - min) + min,
|
||||||
|
nextRangeInt: (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min,
|
||||||
|
nextFloat: () => Math.random(),
|
||||||
} as any,
|
} as any,
|
||||||
|
|
||||||
year: this.currentDate.getFullYear(),
|
year: this.currentDate.getFullYear(),
|
||||||
|
|||||||
Reference in New Issue
Block a user