커맨드 보정

This commit is contained in:
2026-01-08 13:59:59 +00:00
parent f62bbb7927
commit 3e649f75fa
15 changed files with 2457 additions and 0 deletions
+6
View File
@@ -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
});
});
+4
View File
@@ -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(),