커맨드 보정
This commit is contained in:
@@ -290,6 +290,8 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
||||
id: effect.targetId,
|
||||
patch: effect.patch as Partial<General>,
|
||||
});
|
||||
} else if (effect.type === 'general:patch') {
|
||||
Object.assign(draft.general, effect.patch);
|
||||
} else if (
|
||||
effect.type === 'city:patch' &&
|
||||
effect.targetId !== undefined &&
|
||||
@@ -299,6 +301,8 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
||||
id: effect.targetId,
|
||||
patch: effect.patch,
|
||||
});
|
||||
} else if (effect.type === 'city:patch' && draft.city) {
|
||||
Object.assign(draft.city, effect.patch);
|
||||
} else if (
|
||||
effect.type === 'nation:patch' &&
|
||||
effect.targetId !== undefined &&
|
||||
@@ -308,6 +312,8 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
||||
id: effect.targetId,
|
||||
patch: effect.patch,
|
||||
});
|
||||
} else if (effect.type === 'nation:patch' && draft.nation) {
|
||||
Object.assign(draft.nation, effect.patch);
|
||||
}
|
||||
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_탈취',
|
||||
'휴식',
|
||||
] 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'),
|
||||
휴식: 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,
|
||||
nextInt: (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min,
|
||||
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,
|
||||
|
||||
year: this.currentDate.getFullYear(),
|
||||
|
||||
Reference in New Issue
Block a user