diff --git a/packages/logic/src/actions/admin/index.ts b/packages/logic/src/actions/admin/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/logic/src/actions/admin/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/logic/src/actions/definition.ts b/packages/logic/src/actions/definition.ts new file mode 100644 index 0000000..2b617eb --- /dev/null +++ b/packages/logic/src/actions/definition.ts @@ -0,0 +1,18 @@ +import type { Constraint, ConstraintContext } from '../constraints/types.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from './engine.js'; +import type { GeneralTriggerState } from '../domain/entities.js'; + +export interface GeneralActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, + Args = unknown, + Context extends GeneralActionResolveContext = GeneralActionResolveContext +> { + key: string; + name: string; + parseArgs(raw: unknown): Args | null; + buildConstraints(ctx: ConstraintContext, args: Args): Constraint[]; + resolve(context: Context, args: Args): GeneralActionOutcome; +} diff --git a/packages/logic/src/actions/domestic/index.ts b/packages/logic/src/actions/domestic/index.ts deleted file mode 100644 index 444e534..0000000 --- a/packages/logic/src/actions/domestic/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { CommandResolver as CommandResolverType } from './che_상업투자.js'; - -export type DomesticCommandKey = 'che_상업투자'; -type CommandResolverCtor = typeof CommandResolverType; -type CommandResolverArgs = ConstructorParameters; -type CommandResolverInstance = InstanceType; - -export type DomesticCommandImporter = () => Promise<{ - CommandResolver: typeof CommandResolverType; -}>; - -const defaultImporters: Record = { - che_상업투자: async () => import('./che_상업투자.js'), -}; - -export class DomesticCommandLoader { - constructor( - private readonly importers: Record< - DomesticCommandKey, - DomesticCommandImporter - > = defaultImporters - ) {} - - async load( - key: DomesticCommandKey - ): Promise<{ CommandResolver: typeof CommandResolverType }> { - const importer = this.importers[key]; - if (!importer) { - throw new Error(`Unknown domestic command key: ${key}`); - } - return importer(); - } - - async create( - key: DomesticCommandKey, - ...args: CommandResolverArgs - ): Promise { - const { CommandResolver } = await this.load(key); - return new CommandResolver(...args); - } -} diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index d8290b9..6d6b159 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -37,16 +37,19 @@ export interface GeneralPatchEffect< > { type: 'general:patch'; patch: Partial>; + targetId?: GeneralId; } export interface CityPatchEffect { type: 'city:patch'; patch: Partial; + targetId?: CityId; } export interface NationPatchEffect { type: 'nation:patch'; patch: Partial; + targetId?: NationId; } export interface LogEffect { @@ -75,11 +78,13 @@ export interface GeneralActionOutcome< } export interface GeneralActionResolver< - TriggerState extends GeneralTriggerState = GeneralTriggerState + TriggerState extends GeneralTriggerState = GeneralTriggerState, + Args = unknown > { key: string; resolve( - context: GeneralActionResolveContext + context: GeneralActionResolveContext, + args: Args ): GeneralActionOutcome; } @@ -90,6 +95,11 @@ export interface GeneralActionResolution { nextTurnAt: Date; logs: LogEntryDraft[]; effects: GeneralActionEffect[]; + patches?: { + generals: Array<{ id: GeneralId; patch: Partial }>; + cities: Array<{ id: CityId; patch: Partial }>; + nations: Array<{ id: NationId; patch: Partial }>; + }; dirty?: { general: boolean; city: boolean; @@ -159,22 +169,30 @@ const applyNationPatch = (base: Nation, patch: Partial): Nation => ({ export const createGeneralPatchEffect = < TriggerState extends GeneralTriggerState = GeneralTriggerState >( - patch: Partial> + patch: Partial>, + targetId?: GeneralId ): GeneralPatchEffect => ({ type: 'general:patch', patch, + ...(targetId !== undefined ? { targetId } : {}), }); -export const createCityPatchEffect = (patch: Partial): CityPatchEffect => ({ +export const createCityPatchEffect = ( + patch: Partial, + targetId?: CityId +): CityPatchEffect => ({ type: 'city:patch', patch, + ...(targetId !== undefined ? { targetId } : {}), }); export const createNationPatchEffect = ( - patch: Partial + patch: Partial, + targetId?: NationId ): NationPatchEffect => ({ type: 'nation:patch', patch, + ...(targetId !== undefined ? { targetId } : {}), }); export const createLogEffect = ( @@ -208,18 +226,25 @@ export const createNextTurnOverrideEffect = ( // 행동 결과를 Effect로 모아 상태/턴 계산을 수행한다. export const resolveGeneralAction = < - TriggerState extends GeneralTriggerState = GeneralTriggerState + TriggerState extends GeneralTriggerState = GeneralTriggerState, + Args = unknown >( - resolver: GeneralActionResolver, + resolver: GeneralActionResolver, context: GeneralActionResolveContext, - scheduleContext: TurnScheduleContext + scheduleContext: TurnScheduleContext, + args: Args ): GeneralActionResolution => { - const outcome = resolver.resolve(context); + const outcome = resolver.resolve(context, args); const logs: LogEntryDraft[] = []; let nextGeneral = context.general; let nextCity = context.city; let nextNation = context.nation ?? null; let nextTurnAtOverride: Date | null = null; + const patches: NonNullable = { + generals: [], + cities: [], + nations: [], + }; const dirty: NonNullable = { general: false, city: false, @@ -236,19 +261,49 @@ export const resolveGeneralAction = < for (const effect of outcome.effects) { switch (effect.type) { case 'general:patch': - nextGeneral = applyGeneralPatch(nextGeneral, effect.patch); - dirty.general = true; + if ( + effect.targetId === undefined || + effect.targetId === context.general.id + ) { + nextGeneral = applyGeneralPatch(nextGeneral, effect.patch); + dirty.general = true; + } else { + patches.generals.push({ + id: effect.targetId, + patch: effect.patch as Partial, + }); + } break; case 'city:patch': - if (nextCity) { - nextCity = applyCityPatch(nextCity, effect.patch); - dirty.city = true; + if ( + effect.targetId === undefined || + effect.targetId === nextCity?.id + ) { + if (nextCity) { + nextCity = applyCityPatch(nextCity, effect.patch); + dirty.city = true; + } + } else { + patches.cities.push({ + id: effect.targetId, + patch: effect.patch, + }); } break; case 'nation:patch': - if (nextNation) { - nextNation = applyNationPatch(nextNation, effect.patch); - dirty.nation = true; + if ( + effect.targetId === undefined || + effect.targetId === nextNation?.id + ) { + if (nextNation) { + nextNation = applyNationPatch(nextNation, effect.patch); + dirty.nation = true; + } + } else { + patches.nations.push({ + id: effect.targetId, + patch: effect.patch, + }); } break; case 'log': @@ -309,6 +364,13 @@ export const resolveGeneralAction = < if (dirty.general || dirty.city || dirty.nation) { resolution.dirty = dirty; } + if ( + patches.generals.length > 0 || + patches.cities.length > 0 || + patches.nations.length > 0 + ) { + resolution.patches = patches; + } return resolution; }; diff --git a/packages/logic/src/actions/index.ts b/packages/logic/src/actions/index.ts index 4ee3183..0fef650 100644 --- a/packages/logic/src/actions/index.ts +++ b/packages/logic/src/actions/index.ts @@ -1,2 +1,7 @@ -export * from './domestic/index.js'; +export * from './definition.js'; export * from './engine.js'; +export * from './turn/general/index.js'; +export * from './turn/nation/index.js'; +export * from './instant/general/index.js'; +export * from './instant/nation/index.js'; +export * from './admin/index.js'; diff --git a/packages/logic/src/actions/instant/general/index.ts b/packages/logic/src/actions/instant/general/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/logic/src/actions/instant/general/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/logic/src/actions/instant/nation/index.ts b/packages/logic/src/actions/instant/nation/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/logic/src/actions/instant/nation/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/logic/src/actions/domestic/che_상업투자.ts b/packages/logic/src/actions/turn/general/che_상업투자.ts similarity index 71% rename from packages/logic/src/actions/domestic/che_상업투자.ts rename to packages/logic/src/actions/turn/general/che_상업투자.ts index 956e14c..2e9a4d6 100644 --- a/packages/logic/src/actions/domestic/che_상업투자.ts +++ b/packages/logic/src/actions/turn/general/che_상업투자.ts @@ -4,23 +4,39 @@ import type { General, GeneralTriggerState, Nation, -} from '../../domain/entities.js'; -import type { GeneralActionContext } from '../../triggers/general.js'; +} from '../../../domain/entities.js'; +import type { + Constraint, + ConstraintContext, + RequirementKey, + StateView, +} from '../../../constraints/types.js'; +import { + notBeNeutral, + notWanderingNation, + occupiedCity, + remainCityCapacity, + reqGeneralGold, + reqGeneralRice, + suppliedCity, +} from '../../../constraints/presets.js'; +import type { GeneralActionContext } from '../../../triggers/general.js'; import { GeneralActionPipeline, type GeneralActionModule, -} from '../../triggers/general-action.js'; +} from '../../../triggers/general-action.js'; +import type { GeneralActionDefinition } from '../../definition.js'; import type { GeneralActionOutcome, GeneralActionResolver, GeneralActionResolveContext, GeneralActionEffect, -} from '../engine.js'; +} from '../../engine.js'; import { createCityPatchEffect, createGeneralPatchEffect, createLogEffect, -} from '../engine.js'; +} from '../../engine.js'; export type DomesticCriticalPick = 'fail' | 'normal' | 'success'; @@ -59,6 +75,8 @@ export interface CommerceInvestmentResult { appliedFrontDebuff: boolean; } +export interface CommerceInvestmentArgs {} + const DEFAULT_TRUST = 50; const DEFAULT_FRONT_DEBUFF = 0.5; const DEFAULT_FRONT_STATES = [1, 3]; @@ -108,6 +126,38 @@ const addMetaNumber = ( return { ...meta, [key]: current + delta }; }; +const buildDomesticContextFromView = < + TriggerState extends GeneralTriggerState = GeneralTriggerState +>( + ctx: ConstraintContext, + view: StateView +): DomesticActionContext | null => { + const general = view.get({ + kind: 'general', + id: ctx.actorId, + }) as General | null; + if (!general) { + return null; + } + const cityId = ctx.cityId ?? general.cityId; + const city = view.get({ kind: 'city', id: cityId }) as City | null; + if (!city) { + return null; + } + const nationId = ctx.nationId ?? general.nationId; + const nation = + nationId !== undefined + ? ((view.get({ kind: 'nation', id: nationId }) as Nation | null) ?? + null) + : null; + + return { + general, + city, + nation, + }; +}; + // 상업 투자 결과치를 계산하는 경로를 제공한다. export class CommandResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState @@ -252,7 +302,7 @@ export class CommandResolver< export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState -> implements GeneralActionResolver { +> implements GeneralActionResolver { readonly key = 'che_상업투자'; private readonly command: CommandResolver; @@ -264,8 +314,10 @@ export class ActionResolver< } resolve( - context: GeneralActionResolveContext + context: GeneralActionResolveContext, + _args: CommerceInvestmentArgs ): GeneralActionOutcome { + void _args; const general = context.general; const city = context.city; if (!city) { @@ -323,3 +375,65 @@ export class ActionResolver< return { effects }; } } + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_상업투자'; + public readonly name = ACTION_NAME; + private readonly command: CommandResolver; + private readonly resolver: ActionResolver; + + constructor( + modules: Array | null | undefined>, + env: InvestmentEnvironment + ) { + this.command = new CommandResolver(modules, env); + this.resolver = new ActionResolver(modules, env); + } + + parseArgs(_raw: unknown): CommerceInvestmentArgs | null { + void _raw; + return {}; + } + + buildConstraints( + ctx: ConstraintContext, + _args: CommerceInvestmentArgs + ): Constraint[] { + void _args; + const requirements: RequirementKey[] = []; + if (ctx.cityId !== undefined) { + requirements.push({ kind: 'city', id: ctx.cityId }); + } + if (ctx.nationId !== undefined) { + requirements.push({ kind: 'nation', id: ctx.nationId }); + } + + const getCost = (context: ConstraintContext, view: StateView): number => { + const domesticContext = + buildDomesticContextFromView(context, view); + if (!domesticContext) { + return 0; + } + return this.command.getCost(domesticContext).gold; + }; + + return [ + notBeNeutral(), + notWanderingNation(), + occupiedCity(), + suppliedCity(), + reqGeneralGold(getCost, requirements), + reqGeneralRice(() => 0, requirements), + remainCityCapacity(CITY_KEY, ACTION_NAME), + ]; + } + + resolve( + context: GeneralActionResolveContext, + args: CommerceInvestmentArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} 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..047a993 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_화계.ts @@ -0,0 +1,505 @@ +import type { RandomGenerator } from '@sammo-ts/common'; +import type { + City, + General, + GeneralTriggerState, + Nation, + TriggerValue, +} from '../../../domain/entities.js'; +import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; +import { + disallowDiplomacyBetweenStatus, + existsDestCity, + notBeNeutral, + notNeutralDestCity, + notOccupiedDestCity, + occupiedCity, + reqGeneralGold, + reqGeneralRice, + suppliedCity, +} from '../../../constraints/presets.js'; +import type { GeneralActionContext } from '../../../triggers/general.js'; +import { + GeneralActionPipeline, + type GeneralActionModule, +} from '../../../triggers/general-action.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, + GeneralActionResolver, +} from '../../engine.js'; +import { + createCityPatchEffect, + createGeneralPatchEffect, + createLogEffect, +} from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface FireAttackArgs { + destCityId: number; +} + +export interface FireAttackEnvironment { + develCost: number; + sabotageDefaultProb: number; + sabotageProbCoefByStat: number; + sabotageDefenceCoefByGeneralCount: number; + sabotageDamageMin: number; + sabotageDamageMax: number; + maxSuccessProbability?: number; + statKey?: 'leadership' | 'strength' | 'intelligence'; + getDistance?: (sourceCityId: number, destCityId: number) => number | null; + getDefenceCorrection?: ( + context: FireAttackContext, + defender: General + ) => number; + getInjuryProbability?: ( + context: FireAttackContext, + defender: General + ) => number; +} + +export interface FireAttackContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionContext { + general: General; + city: City; + nation?: Nation | null; + destCity: City; + destNation?: Nation | null; + destGenerals: General[]; +} + +export interface FireAttackResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + destCity: City; + destNation?: Nation | null; + destGenerals: General[]; +} + +export interface FireAttackResult< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + success: boolean; + probability: number; + distance: number; + costGold: number; + costRice: number; + exp: number; + dedication: number; + agriDamage: number; + commDamage: number; + injuryCount: number; + injuredGenerals: Array<{ + id: number; + patch: Partial>; + }>; +} + +const ACTION_NAME = '화계'; +const ACTION_KEY = '계략'; +const STAT_EXP_KEY = 'intel_exp'; +const DEFAULT_MAX_PROB = 0.5; +const INJURY_MAX = 80; +const CITY_STATE_BURNING = 32; + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +const randomRangeInt = ( + rng: RandomGenerator, + min: number, + max: number +): number => rng.nextInt(min, max + 1); + +const getStatValue = ( + general: General, + statKey: 'leadership' | 'strength' | 'intelligence' +): number => { + if (statKey === 'leadership') { + return general.stats.leadership; + } + if (statKey === 'strength') { + return general.stats.strength; + } + return general.stats.intelligence; +}; + +const addMetaNumber = ( + meta: Record, + key: string, + delta: number +): Record => { + const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0; + return { ...meta, [key]: current + delta }; +}; + +// 화계 성공/실패 및 피해량 계산을 담당한다. +export class CommandResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + private readonly pipeline: GeneralActionPipeline; + private readonly env: FireAttackEnvironment; + private readonly statKey: 'leadership' | 'strength' | 'intelligence'; + + constructor( + modules: Array | null | undefined>, + env: FireAttackEnvironment + ) { + this.pipeline = new GeneralActionPipeline(modules); + this.env = env; + this.statKey = env.statKey ?? 'intelligence'; + } + + getCost(): { gold: number; rice: number } { + const cost = this.env.develCost * 5; + return { gold: cost, rice: cost }; + } + + private calcAttackProb( + context: FireAttackContext + ): number { + const stat = getStatValue(context.general, this.statKey); + let prob = stat / this.env.sabotageProbCoefByStat; + return this.pipeline.onCalcDomestic( + context, + ACTION_KEY, + 'success', + prob + ); + } + + private calcDefenceProb( + context: FireAttackContext + ): number { + const destNationId = context.destCity.nationId; + let maxStat = 0; + let probCorrection = 0; + let affectCount = 0; + + for (const defender of context.destGenerals) { + if (defender.nationId !== destNationId) { + continue; + } + affectCount += 1; + maxStat = Math.max( + maxStat, + getStatValue(defender, this.statKey) + ); + probCorrection += + this.env.getDefenceCorrection?.(context, defender) ?? 0; + } + + let prob = maxStat / this.env.sabotageProbCoefByStat; + prob += probCorrection; + prob += + (Math.log2(affectCount + 1) - 1.25) * + this.env.sabotageDefenceCoefByGeneralCount; + + prob += context.destCity.security / context.destCity.securityMax / 5; + prob += context.destCity.supplyState ? 0.1 : 0; + + return prob; + } + + resolve( + context: FireAttackContext, + rng: RandomGenerator + ): FireAttackResult { + const { gold: costGold, rice: costRice } = this.getCost(); + const distance = + this.env.getDistance?.(context.general.cityId, context.destCity.id) ?? + 99; + + const attackProb = this.calcAttackProb(context); + const defenceProb = this.calcDefenceProb(context); + let probability = + this.env.sabotageDefaultProb + attackProb - defenceProb; + probability /= distance; + probability = clamp( + probability, + 0, + this.env.maxSuccessProbability ?? DEFAULT_MAX_PROB + ); + + const success = rng.nextBool(probability); + const expRange: [number, number] = success ? [201, 300] : [1, 100]; + const dedRange: [number, number] = success ? [141, 210] : [1, 70]; + const exp = randomRangeInt(rng, expRange[0], expRange[1]); + const dedication = randomRangeInt(rng, dedRange[0], dedRange[1]); + + if (!success) { + return { + success, + probability, + distance, + costGold, + costRice, + exp, + dedication, + agriDamage: 0, + commDamage: 0, + injuryCount: 0, + injuredGenerals: [], + }; + } + + const agriDamage = clamp( + randomRangeInt( + rng, + this.env.sabotageDamageMin, + this.env.sabotageDamageMax + ), + 0, + context.destCity.agriculture + ); + const commDamage = clamp( + randomRangeInt( + rng, + this.env.sabotageDamageMin, + this.env.sabotageDamageMax + ), + 0, + context.destCity.commerce + ); + + const injuryProbDefault = 0.3; + const injuredGenerals: Array<{ + id: number; + patch: Partial>; + }> = []; + for (const defender of context.destGenerals) { + if (defender.nationId !== context.destCity.nationId) { + continue; + } + const injuryProb = + this.env.getInjuryProbability?.(context, defender) ?? + injuryProbDefault; + if (!rng.nextBool(injuryProb)) { + continue; + } + const injuryAmount = randomRangeInt(rng, 1, 16); + injuredGenerals.push({ + id: defender.id, + patch: { + injury: clamp( + defender.injury + injuryAmount, + 0, + INJURY_MAX + ), + crew: Math.floor(defender.crew * 0.98), + train: Math.floor(defender.train * 0.98), + }, + }); + } + + return { + success, + probability, + distance, + costGold, + costRice, + exp, + dedication, + agriDamage, + commDamage, + injuryCount: injuredGenerals.length, + injuredGenerals, + }; + } +} + +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionResolver { + readonly key = 'che_화계'; + private readonly command: CommandResolver; + + constructor( + modules: Array | null | undefined>, + env: FireAttackEnvironment + ) { + this.command = new CommandResolver(modules, env); + } + + resolve( + context: FireAttackResolveContext, + _args: FireAttackArgs + ): GeneralActionOutcome { + void _args; + const city = context.city; + if (!city) { + throw new Error('Fire attack requires a city context.'); + } + + const result = this.command.resolve( + { + ...context, + city, + nation: context.nation ?? null, + destCity: context.destCity, + destNation: context.destNation ?? null, + destGenerals: context.destGenerals, + }, + context.rng + ); + + const effects: Array> = []; + + const nextGold = Math.max(0, context.general.gold - result.costGold); + const nextRice = Math.max(0, context.general.rice - result.costRice); + const nextExperience = context.general.experience + result.exp; + const nextDedication = context.general.dedication + result.dedication; + + const metaWithStatExp = addMetaNumber( + context.general.meta, + STAT_EXP_KEY, + 1 + ); + const metaUpdated = result.success + ? addMetaNumber(metaWithStatExp, 'firenum', 1) + : metaWithStatExp; + + effects.push( + createGeneralPatchEffect({ + gold: nextGold, + rice: nextRice, + experience: nextExperience, + dedication: nextDedication, + meta: metaUpdated, + }) + ); + + if (!result.success) { + effects.push( + createLogEffect( + `${context.destCity.name}에 ${ACTION_NAME} 실패했습니다.`, + { + format: LogFormat.MONTH, + } + ) + ); + return { effects }; + } + + const updatedCityMeta: Record = { + ...context.destCity.meta, + state: CITY_STATE_BURNING, + }; + + effects.push( + createCityPatchEffect( + { + agriculture: context.destCity.agriculture - result.agriDamage, + commerce: context.destCity.commerce - result.commDamage, + meta: updatedCityMeta, + }, + context.destCity.id + ) + ); + + effects.push( + createLogEffect( + `${context.destCity.name}이 불타고 있습니다.`, + { + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + } + ) + ); + effects.push( + createLogEffect( + `${context.destCity.name}에 ${ACTION_NAME} 성공했습니다.`, + { + format: LogFormat.MONTH, + } + ) + ); + effects.push( + createLogEffect( + `도시의 농업이 ${result.agriDamage}, 상업이 ${result.commDamage}만큼 감소하고, 장수 ${result.injuryCount}명이 부상 당했습니다.`, + { + format: LogFormat.PLAIN, + } + ) + ); + + for (const injured of result.injuredGenerals) { + effects.push( + createGeneralPatchEffect(injured.patch, injured.id) + ); + effects.push( + createLogEffect( + `${ACTION_KEY}로 인해 부상을 당했습니다.`, + { + generalId: injured.id, + format: LogFormat.MONTH, + } + ) + ); + } + + return { effects }; + } +} + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition> { + public readonly key = 'che_화계'; + public readonly name = ACTION_NAME; + private readonly command: CommandResolver; + private readonly resolver: ActionResolver; + + constructor( + modules: Array | null | undefined>, + env: FireAttackEnvironment + ) { + this.command = new CommandResolver(modules, env); + this.resolver = new ActionResolver(modules, env); + } + + parseArgs(raw: unknown): FireAttackArgs | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const destCityId = (raw as { destCityId?: unknown }).destCityId; + if (typeof destCityId !== 'number' || Number.isNaN(destCityId)) { + return null; + } + return { destCityId }; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: FireAttackArgs + ): Constraint[] { + void _ctx; + void _args; + const { gold, rice } = this.command.getCost(); + return [ + notBeNeutral(), + occupiedCity(), + suppliedCity(), + existsDestCity(), + notOccupiedDestCity(), + notNeutralDestCity(), + reqGeneralGold(() => gold), + reqGeneralRice(() => rice), + disallowDiplomacyBetweenStatus({ + 7: '불가침국입니다.', + }), + ]; + } + + resolve( + context: FireAttackResolveContext, + args: FireAttackArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts new file mode 100644 index 0000000..9267377 --- /dev/null +++ b/packages/logic/src/actions/turn/general/index.ts @@ -0,0 +1,45 @@ +export type GeneralTurnCommandKey = 'che_상업투자' | 'che_화계'; + +export type GeneralTurnCommandModule = + | typeof import('./che_상업투자.js') + | typeof import('./che_화계.js'); + +export type GeneralTurnCommandImporter = () => Promise; + +const defaultImporters: Record< + GeneralTurnCommandKey, + GeneralTurnCommandImporter +> = { + che_상업투자: async () => import('./che_상업투자.js'), + che_화계: async () => import('./che_화계.js'), +}; + +export class GeneralTurnCommandLoader { + constructor( + private readonly importers: Record< + GeneralTurnCommandKey, + GeneralTurnCommandImporter + > = defaultImporters + ) {} + + async load( + key: GeneralTurnCommandKey + ): Promise { + const importer = this.importers[key]; + if (!importer) { + throw new Error(`Unknown general turn command key: ${key}`); + } + return importer(); + } +} + +export { + ActionDefinition as CommerceInvestmentActionDefinition, + ActionResolver as CommerceInvestmentActionResolver, + CommandResolver as CommerceInvestmentCommandResolver, +} from './che_상업투자.js'; +export { + ActionDefinition as FireAttackActionDefinition, + ActionResolver as FireAttackActionResolver, + CommandResolver as FireAttackCommandResolver, +} from './che_화계.js'; diff --git a/packages/logic/src/actions/turn/nation/index.ts b/packages/logic/src/actions/turn/nation/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/logic/src/actions/turn/nation/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/logic/src/constraints/evaluate.ts b/packages/logic/src/constraints/evaluate.ts new file mode 100644 index 0000000..b09e8f8 --- /dev/null +++ b/packages/logic/src/constraints/evaluate.ts @@ -0,0 +1,38 @@ +import type { + Constraint, + ConstraintContext, + ConstraintResult, + RequirementKey, + StateView, +} from './types.js'; + +export const evaluateConstraints = ( + constraints: Constraint[], + ctx: ConstraintContext, + view: StateView +): ConstraintResult => { + for (const constraint of constraints) { + const missing = constraint + .requires(ctx) + .filter((req) => !view.has(req)); + if (missing.length > 0 && ctx.mode === 'precheck') { + return { kind: 'unknown', missing }; + } + const result = constraint.test(ctx, view); + if (result.kind !== 'allow') { + return result; + } + } + return { kind: 'allow' }; +}; + +export const collectRequirements = ( + constraints: Constraint[], + ctx: ConstraintContext +): RequirementKey[] => { + const keys: RequirementKey[] = []; + for (const constraint of constraints) { + keys.push(...constraint.requires(ctx)); + } + return keys; +}; diff --git a/packages/logic/src/constraints/index.ts b/packages/logic/src/constraints/index.ts new file mode 100644 index 0000000..24d6471 --- /dev/null +++ b/packages/logic/src/constraints/index.ts @@ -0,0 +1,3 @@ +export * from './evaluate.js'; +export * from './presets.js'; +export * from './types.js'; diff --git a/packages/logic/src/constraints/presets.ts b/packages/logic/src/constraints/presets.ts new file mode 100644 index 0000000..8d0dbf4 --- /dev/null +++ b/packages/logic/src/constraints/presets.ts @@ -0,0 +1,443 @@ +import type { City, General, Nation } from '../domain/entities.js'; +import type { + Constraint, + ConstraintContext, + ConstraintResult, + RequirementKey, + StateView, +} from './types.js'; + +const allow = (): ConstraintResult => ({ kind: 'allow' }); + +const unknownOrDeny = ( + ctx: ConstraintContext, + missing: RequirementKey[], + reason: string +): ConstraintResult => + ctx.mode === 'precheck' + ? { kind: 'unknown', missing } + : { kind: 'deny', reason }; + +const readGeneral = ( + ctx: ConstraintContext, + view: StateView +): General | null => { + const req: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(req)) { + return null; + } + return view.get(req) as General | null; +}; + +const readCity = (view: StateView, id?: number): City | null => { + if (id === undefined) { + return null; + } + const req: RequirementKey = { kind: 'city', id }; + if (!view.has(req)) { + return null; + } + return view.get(req) as City | null; +}; + +const resolveDestCityId = (ctx: ConstraintContext): number | undefined => { + if (ctx.destCityId !== undefined) { + return ctx.destCityId; + } + const raw = ctx.args?.destCityId; + return typeof raw === 'number' ? raw : undefined; +}; + +const resolveDestNationId = (ctx: ConstraintContext): number | undefined => { + if (ctx.destNationId !== undefined) { + return ctx.destNationId; + } + const raw = ctx.args?.destNationId; + return typeof raw === 'number' ? raw : undefined; +}; + +const readDestCity = ( + ctx: ConstraintContext, + view: StateView +): City | null => { + const destCityId = resolveDestCityId(ctx); + return readCity(view, destCityId); +}; + +const readNation = ( + view: StateView, + id?: number +): Nation | null => { + if (id === undefined) { + return null; + } + const req: RequirementKey = { kind: 'nation', id }; + if (!view.has(req)) { + return null; + } + return view.get(req) as Nation | null; +}; + +const readDiplomacyState = ( + view: StateView, + srcNationId: number, + destNationId: number +): number | null => { + const req: RequirementKey = { + kind: 'diplomacy', + srcNationId, + destNationId, + }; + if (!view.has(req)) { + return null; + } + const value = view.get(req); + if (typeof value === 'number') { + return value; + } + if (value && typeof value === 'object') { + const state = (value as { state?: number; stateCode?: number }).state; + const stateCode = (value as { state?: number; stateCode?: number }).stateCode; + if (typeof state === 'number') { + return state; + } + if (typeof stateCode === 'number') { + return stateCode; + } + } + return null; +}; + +export const notBeNeutral = (): Constraint => ({ + name: 'NotBeNeutral', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const req: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + const general = view.get(req) as General | null; + if (!general) { + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + if (general.nationId !== 0) { + return allow(); + } + return { kind: 'deny', reason: '재야입니다.' }; + }, +}); + +export const notWanderingNation = (): Constraint => ({ + name: 'NotWanderingNation', + requires: (ctx) => + ctx.nationId !== undefined + ? [{ kind: 'nation', id: ctx.nationId }] + : [], + test: (ctx, view) => { + const nation = readNation(view, ctx.nationId); + if (!nation) { + if (ctx.nationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'nation', id: ctx.nationId }; + return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); + } + if (nation.level !== 0) { + return allow(); + } + return { kind: 'deny', reason: '방랑군은 불가능합니다.' }; + }, +}); + +export const occupiedCity = ( + options: { allowNeutral?: boolean } = {} +): Constraint => ({ + name: 'OccupiedCity', + requires: (ctx) => { + const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }]; + if (ctx.cityId !== undefined) { + reqs.push({ kind: 'city', id: ctx.cityId }); + } + return reqs; + }, + test: (ctx, view) => { + const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(generalReq)) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const general = view.get(generalReq) as General | null; + if (!general) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + if (options.allowNeutral && general.nationId === 0) { + return allow(); + } + const cityId = ctx.cityId ?? general.cityId; + const cityReq: RequirementKey = { kind: 'city', id: cityId }; + if (!view.has(cityReq)) { + return unknownOrDeny(ctx, [cityReq], '도시 정보가 없습니다.'); + } + const city = view.get(cityReq) as City | null; + if (!city) { + return unknownOrDeny(ctx, [cityReq], '도시 정보가 없습니다.'); + } + if (city.nationId === general.nationId) { + return allow(); + } + return { kind: 'deny', reason: '아국이 아닙니다.' }; + }, +}); + +export const suppliedCity = (): Constraint => ({ + name: 'SuppliedCity', + requires: (ctx) => + ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : [], + test: (ctx, view) => { + const city = readCity(view, ctx.cityId); + if (!city) { + if (ctx.cityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'city', id: ctx.cityId }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + if (city.supplyState) { + return allow(); + } + return { kind: 'deny', reason: '고립된 도시입니다.' }; + }, +}); + +export const reqGeneralGold = ( + getRequiredGold: (ctx: ConstraintContext, view: StateView) => number, + requirements: RequirementKey[] = [] +): Constraint => ({ + name: 'ReqGeneralGold', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }, ...requirements], + test: (ctx, view) => { + const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + const missing = [generalReq, ...requirements].filter( + (req) => !view.has(req) + ); + if (missing.length > 0) { + return unknownOrDeny(ctx, missing, '장수 정보가 없습니다.'); + } + const general = view.get(generalReq) as General | null; + if (!general) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const required = getRequiredGold(ctx, view); + if (general.gold >= required) { + return allow(); + } + return { kind: 'deny', reason: '자금이 모자랍니다.' }; + }, +}); + +export const reqGeneralRice = ( + getRequiredRice: (ctx: ConstraintContext, view: StateView) => number, + requirements: RequirementKey[] = [] +): Constraint => ({ + name: 'ReqGeneralRice', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }, ...requirements], + test: (ctx, view) => { + const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + const missing = [generalReq, ...requirements].filter( + (req) => !view.has(req) + ); + if (missing.length > 0) { + return unknownOrDeny(ctx, missing, '장수 정보가 없습니다.'); + } + const general = view.get(generalReq) as General | null; + if (!general) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const required = getRequiredRice(ctx, view); + if (general.rice >= required) { + return allow(); + } + return { kind: 'deny', reason: '군량이 모자랍니다.' }; + }, +}); + +export const remainCityCapacity = ( + key: string, + label: string +): Constraint => ({ + name: 'RemainCityCapacity', + requires: (ctx) => + ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : [], + test: (ctx, view) => { + const city = readCity(view, ctx.cityId); + if (!city) { + if (ctx.cityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'city', id: ctx.cityId }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + const record = city as unknown as Record; + const maxKey = `${key}_max`; + const current = record[key]; + const max = record[maxKey]; + if (current === undefined || max === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + if (current < max) { + return allow(); + } + return { kind: 'deny', reason: `${label}이 충분합니다.` }; + }, +}); + +export const existsDestCity = (): Constraint => ({ + name: 'ExistsDestCity', + requires: (ctx) => + resolveDestCityId(ctx) !== undefined + ? [ + { + kind: 'destCity', + id: resolveDestCityId(ctx) ?? 0, + }, + ] + : [], + test: (ctx, view) => { + const destCityId = resolveDestCityId(ctx); + if (destCityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'destCity', id: destCityId }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + const city = view.get(req) as City | null; + if (!city) { + return { kind: 'deny', reason: '도시 정보가 없습니다.' }; + } + return allow(); + }, +}); + +export const notOccupiedDestCity = (): Constraint => ({ + name: 'NotOccupiedDestCity', + requires: (ctx) => { + const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }]; + const destCityId = resolveDestCityId(ctx); + if (destCityId !== undefined) { + reqs.push({ kind: 'destCity', id: destCityId }); + } + return reqs; + }, + test: (ctx, view) => { + const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(generalReq)) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const general = view.get(generalReq) as General | null; + if (!general) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const destCity = readDestCity(ctx, view); + if (!destCity) { + const destCityId = resolveDestCityId(ctx); + if (destCityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { + kind: 'destCity', + id: destCityId, + }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + if (destCity.nationId !== general.nationId) { + return allow(); + } + return { kind: 'deny', reason: '아국입니다.' }; + }, +}); + +export const notNeutralDestCity = (): Constraint => ({ + name: 'NotNeutralDestCity', + requires: (ctx) => + resolveDestCityId(ctx) !== undefined + ? [ + { + kind: 'destCity', + id: resolveDestCityId(ctx) ?? 0, + }, + ] + : [], + test: (ctx, view) => { + const destCity = readDestCity(ctx, view); + if (!destCity) { + const destCityId = resolveDestCityId(ctx); + if (destCityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { + kind: 'destCity', + id: destCityId, + }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + if (destCity.nationId !== 0) { + return allow(); + } + return { kind: 'deny', reason: '공백지입니다.' }; + }, +}); + +export const disallowDiplomacyBetweenStatus = ( + disallowList: Record +): Constraint => ({ + name: 'DisallowDiplomacyBetweenStatus', + requires: (ctx) => { + const reqs: RequirementKey[] = []; + if (ctx.nationId !== undefined) { + reqs.push({ kind: 'nation', id: ctx.nationId }); + } + const destNationId = resolveDestNationId(ctx); + if (destNationId !== undefined) { + reqs.push({ kind: 'destNation', id: destNationId }); + if (ctx.nationId !== undefined) { + reqs.push({ + kind: 'diplomacy', + srcNationId: ctx.nationId, + destNationId, + }); + } + } + const destCityId = resolveDestCityId(ctx); + if (destCityId !== undefined) { + reqs.push({ kind: 'destCity', id: destCityId }); + } + return reqs; + }, + test: (ctx, view) => { + const general = readGeneral(ctx, view); + const baseNationId = ctx.nationId ?? general?.nationId; + if (baseNationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const destCity = readDestCity(ctx, view); + const destNationId = + resolveDestNationId(ctx) ?? destCity?.nationId; + if (destNationId === undefined) { + return unknownOrDeny(ctx, [], '상대 국가 정보가 없습니다.'); + } + const state = readDiplomacyState(view, baseNationId, destNationId); + if (state === null) { + const req: RequirementKey = { + kind: 'diplomacy', + srcNationId: baseNationId, + destNationId, + }; + return unknownOrDeny(ctx, [req], '외교 정보가 없습니다.'); + } + const reason = disallowList[state]; + if (reason !== undefined) { + return { kind: 'deny', reason }; + } + return allow(); + }, +}); diff --git a/packages/logic/src/constraints/types.ts b/packages/logic/src/constraints/types.ts new file mode 100644 index 0000000..dbfe287 --- /dev/null +++ b/packages/logic/src/constraints/types.ts @@ -0,0 +1,38 @@ +export type ConstraintResult = + | { kind: 'allow' } + | { kind: 'deny'; reason: string; code?: string } + | { kind: 'unknown'; missing: RequirementKey[] }; + +export type RequirementKey = + | { kind: 'general'; id: number } + | { kind: 'city'; id: number } + | { kind: 'nation'; id: number } + | { kind: 'destGeneral'; id: number } + | { kind: 'destCity'; id: number } + | { kind: 'destNation'; id: number } + | { kind: 'diplomacy'; srcNationId: number; destNationId: number } + | { kind: 'arg'; key: string } + | { kind: 'env'; key: string }; + +export interface ConstraintContext { + actorId: number; + cityId?: number; + nationId?: number; + destGeneralId?: number; + destCityId?: number; + destNationId?: number; + args: Record; + env: Record; + mode: 'full' | 'precheck'; +} + +export interface StateView { + has(req: RequirementKey): boolean; + get(req: RequirementKey): unknown | null; +} + +export interface Constraint { + name: string; + requires(ctx: ConstraintContext): RequirementKey[]; + test(ctx: ConstraintContext, view: StateView): ConstraintResult; +} diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 287e613..ec2d37d 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -1,6 +1,7 @@ export * from './domain/entities.js'; export type { RandomGenerator } from '@sammo-ts/common'; export * from './actions/index.js'; +export * from './constraints/index.js'; export * from './logging/index.js'; export * from './ports/world.js'; export * from './ports/worldSnapshot.js';