diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index a68993d..675d983 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -290,6 +290,8 @@ export const resolveGeneralAction = , }); + } 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 = extends GeneralActionResolveContext { + 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 { + readonly key = ACTION_KEY; + + resolve(context: GeneralActionResolveContext, args: EmployArgs): GeneralActionOutcome { + const ctx = context as EmployResolveContext; + 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[] = []; + + 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(`${destGeneral.name}에게 등용 권유 서신을 보냈습니다.`, { + category: LogCategory.ACTION, + }); + + effects.push( + createLogEffect( + `${general.name}(${ctx.nation?.name ?? '재야'})로 부터 등용 권유 서신이 도착했습니다.`, + { + scope: LogScope.GENERAL, + generalId: destGeneralId, + category: LogCategory.ACTION, + } + ) + ); + + return { effects }; + } +} + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(private env: TurnCommandEnv) { + this.resolver = new ActionResolver(); + } + + parseArgs(raw: unknown): EmployArgs | null { + const data = raw as Partial; + 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, args: EmployArgs): GeneralActionOutcome { + 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), +}; diff --git a/packages/logic/src/actions/turn/general/che_물자조달.ts b/packages/logic/src/actions/turn/general/che_물자조달.ts new file mode 100644 index 0000000..ba14eae --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_물자조달.ts @@ -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 { + readonly key = ACTION_KEY; + private readonly pipeline: GeneralActionPipeline; + + constructor(modules: Array | null | undefined>) { + this.pipeline = new GeneralActionPipeline(modules); + } + + resolve( + context: GeneralActionResolveContext, + _args: ProcureArgs + ): GeneralActionOutcome { + 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( + `조달을 실패하여 ${resName}을 ${scoreText} 조달했습니다.`, + { + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } + ); + } else if (outcome === 'success') { + context.addLog(`조달을 성공하여 ${resName}을 ${scoreText} 조달했습니다.`, { + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + } else { + context.addLog(`${resName}을 ${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> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(modules: Array | 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, args: ProcureArgs): GeneralActionOutcome { + 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 ?? []), +}; diff --git a/packages/logic/src/actions/turn/general/che_방랑.ts b/packages/logic/src/actions/turn/general/che_방랑.ts new file mode 100644 index 0000000..bfae457 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_방랑.ts @@ -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 { + nationCities?: City[]; + nationGenerals?: General[]; + 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 { + readonly key = ACTION_KEY; + + resolve(context: WanderResolveContext, _args: WanderArgs): GeneralActionOutcome { + const general = context.general; + const nation = context.nation; + const effects: GeneralActionEffect[] = []; + + 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> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor() { + this.resolver = new ActionResolver(); + } + + parseArgs(_raw: unknown): WanderArgs | null { + return {}; + } + + buildConstraints(_ctx: ConstraintContext, _args: WanderArgs): Constraint[] { + return [beLord(), notWanderingNation()]; + } + + resolve(context: WanderResolveContext, args: WanderArgs): GeneralActionOutcome { + 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(), +}; diff --git a/packages/logic/src/actions/turn/general/che_선동.ts b/packages/logic/src/actions/turn/general/che_선동.ts new file mode 100644 index 0000000..3598b53 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_선동.ts @@ -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 { + destCity?: City; + env?: TurnCommandEnv; +} + +const ACTION_NAME = '선동'; +const ACTION_KEY = 'che_선동'; + +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionResolver { + readonly key = ACTION_KEY; + + resolve(context: GeneralActionResolveContext, args: AgitateArgs): GeneralActionOutcome { + const ctx = context as AgitateResolveContext; + 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[] = []; + + // 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(`${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> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(_env: TurnCommandEnv) { + this.resolver = new ActionResolver(); + } + + parseArgs(raw: unknown): AgitateArgs | null { + const data = raw as Partial; + 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, args: AgitateArgs): GeneralActionOutcome { + 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), +}; diff --git a/packages/logic/src/actions/turn/general/che_은퇴.ts b/packages/logic/src/actions/turn/general/che_은퇴.ts new file mode 100644 index 0000000..b70ab60 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_은퇴.ts @@ -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 { + readonly key = ACTION_KEY; + + resolve(context: GeneralActionResolveContext, _args: RetireArgs): GeneralActionOutcome { + 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[] = []; + + 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> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor() { + this.resolver = new ActionResolver(); + } + + parseArgs(_raw: unknown): RetireArgs | null { + return {}; + } + + buildConstraints(_ctx: ConstraintContext, _args: RetireArgs): Constraint[] { + return [reqAge()]; + } + + resolve(context: GeneralActionResolveContext, args: RetireArgs): GeneralActionOutcome { + 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(), +}; diff --git a/packages/logic/src/actions/turn/general/che_이동.ts b/packages/logic/src/actions/turn/general/che_이동.ts new file mode 100644 index 0000000..fabd2b4 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_이동.ts @@ -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 { + moveGenerals?: General[]; // 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 { + readonly key = ACTION_KEY; + + resolve(context: MoveResolveContext, args: MoveArgs): GeneralActionOutcome { + const general = context.general; + const nation = context.nation; + const { destCityId } = args; + + const effects: GeneralActionEffect[] = []; + + const isRoamingLeader = general.officerLevel === 12 && nation && nation.level === 0; + let moveTargets: General[] = [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(`${destCityName}${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> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor() { + this.resolver = new ActionResolver(); + } + + parseArgs(raw: unknown): MoveArgs | null { + const data = raw as Partial; + 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, args: MoveArgs): GeneralActionOutcome { + 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(), +}; diff --git a/packages/logic/src/actions/turn/general/che_첩보.ts b/packages/logic/src/actions/turn/general/che_첩보.ts new file mode 100644 index 0000000..8fa05c7 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_첩보.ts @@ -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 { + destCity?: City; + env?: TurnCommandEnv; + map?: MapDefinition; +} + +const ACTION_NAME = '첩보'; +const ACTION_KEY = 'che_첩보'; + +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionResolver { + readonly key = ACTION_KEY; + + resolve(context: GeneralActionResolveContext, args: SpyArgs): GeneralActionOutcome { + const ctx = context as SpyResolveContext; + 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[] = []; + + 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(`${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) || {}; + 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> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor() { + this.resolver = new ActionResolver(); + } + + parseArgs(raw: unknown): SpyArgs | null { + const data = raw as Partial; + 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, args: SpyArgs): GeneralActionOutcome { + 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(), +}; diff --git a/packages/logic/src/actions/turn/general/che_탈취.ts b/packages/logic/src/actions/turn/general/che_탈취.ts new file mode 100644 index 0000000..5f2efb7 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_탈취.ts @@ -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 { + 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 { + readonly key = ACTION_KEY; + + resolve(context: GeneralActionResolveContext, args: SeizeArgs): GeneralActionOutcome { + const ctx = context as SeizeResolveContext; + 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[] = []; + + // 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(`${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> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(_env: TurnCommandEnv) { + this.resolver = new ActionResolver(); + } + + parseArgs(raw: unknown): SeizeArgs | null { + const data = raw as Partial; + 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, args: SeizeArgs): GeneralActionOutcome { + 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), +}; diff --git a/packages/logic/src/actions/turn/general/che_파괴.ts b/packages/logic/src/actions/turn/general/che_파괴.ts new file mode 100644 index 0000000..a9af013 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_파괴.ts @@ -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 { + destCity?: City; + env?: TurnCommandEnv; +} + +const ACTION_NAME = '파괴'; +const ACTION_KEY = 'che_파괴'; + +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionResolver { + readonly key = ACTION_KEY; + + resolve(context: GeneralActionResolveContext, args: DestroyArgs): GeneralActionOutcome { + const ctx = context as DestroyResolveContext; + 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[] = []; + + // 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(`${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> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(_env: TurnCommandEnv) { + this.resolver = new ActionResolver(); + } + + parseArgs(raw: unknown): DestroyArgs | null { + const data = raw as Partial; + 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, args: DestroyArgs): GeneralActionOutcome { + 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), +}; diff --git a/packages/logic/src/actions/turn/general/che_하야.ts b/packages/logic/src/actions/turn/general/che_하야.ts new file mode 100644 index 0000000..2f41b8a --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_하야.ts @@ -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 { + readonly key = ACTION_KEY; + + resolve(context: GeneralActionResolveContext, _args: ResignArgs): GeneralActionOutcome { + 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> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor() { + this.resolver = new ActionResolver(); + } + + parseArgs(_raw: unknown): ResignArgs | null { + return {}; + } + + buildConstraints(_ctx: ConstraintContext, _args: ResignArgs): Constraint[] { + return [notBeNeutral()]; + } + + resolve(context: GeneralActionResolveContext, args: ResignArgs): GeneralActionOutcome { + 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(), +}; diff --git a/packages/logic/src/actions/turn/general/che_헌납.ts b/packages/logic/src/actions/turn/general/che_헌납.ts new file mode 100644 index 0000000..c763676 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_헌납.ts @@ -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 { + readonly key = ACTION_KEY; + + resolve(context: GeneralActionResolveContext, args: DonateArgs): GeneralActionOutcome { + 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} ${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> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor() { + this.resolver = new ActionResolver(); + } + + parseArgs(raw: unknown): DonateArgs | null { + const data = raw as Partial; + 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, args: DonateArgs): GeneralActionOutcome { + 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(), +}; diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index bab29a4..373b1f2 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -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 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'), }; diff --git a/packages/logic/test/scenarios/general_commands_new.test.ts b/packages/logic/test/scenarios/general_commands_new.test.ts new file mode 100644 index 0000000..31d679d --- /dev/null +++ b/packages/logic/test/scenarios/general_commands_new.test.ts @@ -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 + }); +}); diff --git a/packages/logic/test/testEnv.ts b/packages/logic/test/testEnv.ts index a030b89..b91b91f 100644 --- a/packages/logic/test/testEnv.ts +++ b/packages/logic/test/testEnv.ts @@ -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(),