커맨드 보정
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'),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user