diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index dff98d4..2bd7f75 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -129,6 +129,8 @@ class MemoryStateView implements StateView { return `destNation:${req.id}`; case 'diplomacy': return `diplomacy:${req.srcNationId}:${req.destNationId}`; + case 'diplomacyList': + return 'diplomacy:list'; case 'arg': return `arg:${req.key}`; case 'env': diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 0cd3af9..ad6980e 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -351,6 +351,8 @@ class WorldStateView implements StateView { req.srcNationId, req.destNationId ); + case 'diplomacyList': + return this.world.listDiplomacy(); case 'arg': return this.args[req.key] ?? null; case 'env': diff --git a/packages/logic/src/actions/turn/actionContext.ts b/packages/logic/src/actions/turn/actionContext.ts index cef6791..6108d0c 100644 --- a/packages/logic/src/actions/turn/actionContext.ts +++ b/packages/logic/src/actions/turn/actionContext.ts @@ -37,6 +37,14 @@ export interface ActionContextWorldRef { toNationId: number; state: number; }>; + getDiplomacyEntry(fromNationId: number, toNationId: number): { + fromNationId: number; + toNationId: number; + state: number; + term: number; + dead?: number; + meta?: Record; + } | null; getGeneralById(id: number): ActionContextGeneral | null; getCityById(id: number): City | null; getNationById(id: number): Nation | null; diff --git a/packages/logic/src/actions/turn/nation/che_급습.ts b/packages/logic/src/actions/turn/nation/che_급습.ts new file mode 100644 index 0000000..d321f4c --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_급습.ts @@ -0,0 +1,289 @@ +import type { + General, + GeneralTriggerState, + Nation, +} from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + allowDiplomacyWithTerm, + availableStrategicCommand, + beChief, + existsDestNation, + occupiedCity, +} 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 { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, + GeneralActionResolver, +} from '@sammo-ts/logic/actions/engine.js'; +import { + createDiplomacyPatchEffect, + createLogEffect, +} from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { buildDefaultDiplomacy } from '../../../diplomacy/index.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { NationTurnCommandSpec } from './index.js'; + +export interface RaidArgs { + destNationId: number; +} + +export interface RaidResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + destNation: Nation; + diplomacy: { state: number; term: number }; + reverseDiplomacy: { state: number; term: number }; + friendlyGenerals: Array>; + destNationGenerals: Array>; +} + +const ACTION_NAME = '급습'; +const DEFAULT_GLOBAL_DELAY = 9; +const PRE_REQ_TURN = 0; +const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); +const TERM_REDUCE = 3; + +const parseNationId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + const value = Math.floor(raw); + return value > 0 ? value : null; +}; + +// 급습 쿨타임 계산을 담당한다. +export class CommandResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + private readonly pipeline: GeneralActionPipeline; + + constructor(modules: Array | null | undefined>) { + this.pipeline = new GeneralActionPipeline(modules); + } + + getGlobalDelay(context: RaidResolveContext): number { + return Math.round( + this.pipeline.onCalcStrategic( + context, + ACTION_NAME, + 'globalDelay', + DEFAULT_GLOBAL_DELAY + ) + ); + } +} + +// 급습 실행 결과를 계산한다. +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionResolver { + readonly key = 'che_급습'; + private readonly command: CommandResolver; + + constructor(modules: Array | null | undefined>) { + this.command = new CommandResolver(modules); + } + + resolve( + context: RaidResolveContext, + _args: RaidArgs + ): GeneralActionOutcome { + void _args; + const { general, nation } = context; + const generalName = general.name; + const generalJosa = JosaUtil.pick(generalName, '이'); + const nationName = nation?.name ?? '아국'; + const destNationName = context.destNation.name; + const actionJosa = JosaUtil.pick(ACTION_NAME, '을'); + const broadcastMessage = `${generalName}${generalJosa} ${destNationName}${ACTION_NAME}${actionJosa} 발동하였습니다.`; + + general.experience += EXP_DED_GAIN; + general.dedication += EXP_DED_GAIN; + + context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH }); + context.addLog( + `${destNationName}${ACTION_NAME}${actionJosa} 발동`, + { + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ); + + const effects: Array> = [ + createDiplomacyPatchEffect( + general.nationId, + context.destNation.id, + { + term: context.diplomacy.term - TERM_REDUCE, + } + ), + createDiplomacyPatchEffect( + context.destNation.id, + general.nationId, + { + term: context.reverseDiplomacy.term - TERM_REDUCE, + } + ), + ]; + + for (const target of context.friendlyGenerals) { + if (target.id === general.id) { + continue; + } + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + }) + ); + } + + const destBroadcast = `아국에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 발동되었습니다.`; + for (const target of context.destNationGenerals) { + effects.push( + createLogEffect(destBroadcast, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + }) + ); + } + + if (nation) { + const globalDelay = this.command.getGlobalDelay(context); + nation.meta = { + ...(nation.meta as object), + strategic_cmd_limit: globalDelay, + }; + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: nation.id, + format: LogFormat.YEAR_MONTH, + }) + ); + } + effects.push( + createLogEffect( + `${nationName}${generalName}${generalJosa} 아국에 ${ACTION_NAME}${actionJosa} 발동`, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: context.destNation.id, + format: LogFormat.PLAIN, + } + ) + ); + + return { effects }; + } +} + +// 급습 실행을 위한 정의/제약을 구성한다. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition< + TriggerState, + RaidArgs, + RaidResolveContext + > { + public readonly key = 'che_급습'; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(modules: Array | null | undefined>) { + this.resolver = new ActionResolver(modules); + } + + parseArgs(raw: unknown): RaidArgs | null { + const data = raw as { destNationId?: unknown }; + const destNationId = parseNationId(data?.destNationId); + if (destNationId === null) { + return null; + } + return { destNationId }; + } + + buildConstraints(_ctx: ConstraintContext, _args: RaidArgs): Constraint[] { + void _ctx; + void _args; + return [ + occupiedCity(), + beChief(), + existsDestNation(), + allowDiplomacyWithTerm( + 1, + 12, + '선포 12개월 이상인 상대국에만 가능합니다.' + ), + availableStrategicCommand(), + ]; + } + + resolve( + context: RaidResolveContext, + args: RaidArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} + +// 예약 턴 실행에 필요한 대상 국가/외교 정보를 구성한다. +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destNationId = options.actionArgs.destNationId; + if (typeof destNationId !== 'number') { + return null; + } + const worldRef = options.worldRef; + if (!worldRef) { + return null; + } + const destNation = worldRef.getNationById(destNationId); + if (!destNation) { + return null; + } + const diplomacy = + worldRef.getDiplomacyEntry(base.general.nationId, destNationId) ?? + buildDefaultDiplomacy(base.general.nationId, destNationId); + const reverseDiplomacy = + worldRef.getDiplomacyEntry(destNationId, base.general.nationId) ?? + buildDefaultDiplomacy(destNationId, base.general.nationId); + const generals = worldRef.listGenerals(); + const friendlyGenerals = generals.filter( + (general) => general.nationId === base.general.nationId + ); + const destNationGenerals = generals.filter( + (general) => general.nationId === destNationId + ); + return { + ...base, + destNation, + diplomacy: { state: diplomacy.state, term: diplomacy.term }, + reverseDiplomacy: { state: reverseDiplomacy.state, term: reverseDiplomacy.term }, + friendlyGenerals, + destNationGenerals, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_급습', + category: '외교', + reqArg: true, + args: { destNationId: 0 }, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? []), +}; diff --git a/packages/logic/src/actions/turn/nation/che_백성동원.ts b/packages/logic/src/actions/turn/nation/che_백성동원.ts new file mode 100644 index 0000000..d621029 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_백성동원.ts @@ -0,0 +1,244 @@ +import type { + City, + General, + GeneralTriggerState, +} from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + availableStrategicCommand, + beChief, + occupiedCity, + occupiedDestCity, +} 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 { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, + GeneralActionResolver, +} from '@sammo-ts/logic/actions/engine.js'; +import { createCityPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { NationTurnCommandSpec } from './index.js'; + +export interface MobilizePeopleArgs { + destCityId: number; +} + +export interface MobilizePeopleResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + destCity: City; + friendlyGenerals: Array>; +} + +const ACTION_NAME = '백성동원'; +const DEFAULT_GLOBAL_DELAY = 9; +const PRE_REQ_TURN = 0; +const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); +const DEFENCE_RATE = 0.8; + +const parseCityId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + const value = Math.floor(raw); + return value > 0 ? value : null; +}; + +// 백성동원 쿨타임 계산을 담당한다. +export class CommandResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + private readonly pipeline: GeneralActionPipeline; + + constructor(modules: Array | null | undefined>) { + this.pipeline = new GeneralActionPipeline(modules); + } + + getGlobalDelay(context: MobilizePeopleResolveContext): number { + return Math.round( + this.pipeline.onCalcStrategic( + context, + ACTION_NAME, + 'globalDelay', + DEFAULT_GLOBAL_DELAY + ) + ); + } +} + +// 백성동원 실행 결과를 계산한다. +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionResolver { + readonly key = 'che_백성동원'; + private readonly command: CommandResolver; + + constructor(modules: Array | null | undefined>) { + this.command = new CommandResolver(modules); + } + + resolve( + context: MobilizePeopleResolveContext, + _args: MobilizePeopleArgs + ): GeneralActionOutcome { + void _args; + const { general, nation } = context; + const generalName = general.name; + const generalJosa = JosaUtil.pick(generalName, '이'); + const cityName = context.destCity.name; + const broadcastMessage = `${generalName}${generalJosa} ${cityName}${ACTION_NAME}을 하였습니다.`; + + general.experience += EXP_DED_GAIN; + general.dedication += EXP_DED_GAIN; + + context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH }); + context.addLog( + `${cityName}${ACTION_NAME}을 발동`, + { + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ); + + const effects: Array> = []; + + for (const target of context.friendlyGenerals) { + if (target.id === general.id) { + continue; + } + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + }) + ); + } + + const nextDefence = Math.max( + context.destCity.defence, + context.destCity.defenceMax * DEFENCE_RATE + ); + const nextWall = Math.max( + context.destCity.wall, + context.destCity.wallMax * DEFENCE_RATE + ); + effects.push( + createCityPatchEffect( + { + defence: nextDefence, + wall: nextWall, + }, + context.destCity.id + ) + ); + + if (nation) { + const globalDelay = this.command.getGlobalDelay(context); + nation.meta = { + ...(nation.meta as object), + strategic_cmd_limit: globalDelay, + }; + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: nation.id, + format: LogFormat.YEAR_MONTH, + }) + ); + } + + return { effects }; + } +} + +// 백성동원 실행을 위한 정의/제약을 구성한다. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition< + TriggerState, + MobilizePeopleArgs, + MobilizePeopleResolveContext + > { + public readonly key = 'che_백성동원'; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(modules: Array | null | undefined>) { + this.resolver = new ActionResolver(modules); + } + + parseArgs(raw: unknown): MobilizePeopleArgs | null { + const data = raw as { destCityId?: unknown }; + const destCityId = parseCityId(data?.destCityId); + if (destCityId === null) { + return null; + } + return { destCityId }; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: MobilizePeopleArgs + ): Constraint[] { + void _ctx; + void _args; + return [ + occupiedCity(), + beChief(), + occupiedDestCity(), + availableStrategicCommand(), + ]; + } + + resolve( + context: MobilizePeopleResolveContext, + args: MobilizePeopleArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} + +// 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다. +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destCityId = options.actionArgs.destCityId; + if (typeof destCityId !== 'number') { + return null; + } + const worldRef = options.worldRef; + if (!worldRef) { + return null; + } + const destCity = worldRef.getCityById(destCityId); + if (!destCity) { + return null; + } + const friendlyGenerals = worldRef + .listGenerals() + .filter((general) => general.nationId === base.general.nationId); + return { + ...base, + destCity, + friendlyGenerals, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_백성동원', + category: '전략', + reqArg: true, + args: { destCityId: 0 }, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? []), +}; diff --git a/packages/logic/src/actions/turn/nation/che_수몰.ts b/packages/logic/src/actions/turn/nation/che_수몰.ts new file mode 100644 index 0000000..a8d4c01 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_수몰.ts @@ -0,0 +1,276 @@ +import type { + City, + General, + GeneralTriggerState, + Nation, +} from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + allowDiplomacyBetweenStatus, + availableStrategicCommand, + beChief, + notNeutralDestCity, + notOccupiedDestCity, + occupiedCity, +} 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 { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, + GeneralActionResolver, +} from '@sammo-ts/logic/actions/engine.js'; +import { createCityPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { NationTurnCommandSpec } from './index.js'; + +export interface FloodArgs { + destCityId: number; +} + +export interface FloodResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + destCity: City; + destNation: Nation | null; + friendlyGenerals: Array>; + destNationGenerals: Array>; +} + +const ACTION_NAME = '수몰'; +const DEFAULT_GLOBAL_DELAY = 9; +const PRE_REQ_TURN = 2; +const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); +const DAMAGE_RATE = 0.2; + +const parseCityId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + const value = Math.floor(raw); + return value > 0 ? value : null; +}; + +// 수몰 쿨타임 계산을 담당한다. +export class CommandResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + private readonly pipeline: GeneralActionPipeline; + + constructor(modules: Array | null | undefined>) { + this.pipeline = new GeneralActionPipeline(modules); + } + + getGlobalDelay(context: FloodResolveContext): number { + return Math.round( + this.pipeline.onCalcStrategic( + context, + ACTION_NAME, + 'globalDelay', + DEFAULT_GLOBAL_DELAY + ) + ); + } +} + +// 수몰 실행 결과를 계산한다. +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionResolver { + readonly key = 'che_수몰'; + private readonly command: CommandResolver; + + constructor(modules: Array | null | undefined>) { + this.command = new CommandResolver(modules); + } + + resolve( + context: FloodResolveContext, + _args: FloodArgs + ): GeneralActionOutcome { + void _args; + const { general, nation } = context; + const generalName = general.name; + const generalJosa = JosaUtil.pick(generalName, '이'); + const cityName = context.destCity.name; + const broadcastMessage = `${generalName}${generalJosa} ${cityName}${ACTION_NAME}을 발동하였습니다.`; + const destBroadcastMessage = `${cityName}${ACTION_NAME}이 발동되었습니다.`; + + general.experience += EXP_DED_GAIN; + general.dedication += EXP_DED_GAIN; + + context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH }); + context.addLog( + `${cityName}${ACTION_NAME}을 발동`, + { + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ); + + const effects: Array> = []; + + for (const target of context.friendlyGenerals) { + if (target.id === general.id) { + continue; + } + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + }) + ); + } + + for (const target of context.destNationGenerals) { + effects.push( + createLogEffect(destBroadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + }) + ); + } + + effects.push( + createCityPatchEffect( + { + defence: context.destCity.defence * DAMAGE_RATE, + wall: context.destCity.wall * DAMAGE_RATE, + }, + context.destCity.id + ) + ); + + if (nation) { + const globalDelay = this.command.getGlobalDelay(context); + nation.meta = { + ...(nation.meta as object), + strategic_cmd_limit: globalDelay, + }; + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: nation.id, + format: LogFormat.YEAR_MONTH, + }) + ); + } + + if (context.destNation) { + effects.push( + createLogEffect( + `${nation?.name ?? '상대국'}${generalName}${generalJosa} 아국의 ${cityName}${ACTION_NAME}을 발동`, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: context.destNation.id, + format: LogFormat.PLAIN, + } + ) + ); + } + + return { effects }; + } +} + +// 수몰 실행을 위한 정의/제약을 구성한다. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition< + TriggerState, + FloodArgs, + FloodResolveContext + > { + public readonly key = 'che_수몰'; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(modules: Array | null | undefined>) { + this.resolver = new ActionResolver(modules); + } + + parseArgs(raw: unknown): FloodArgs | null { + const data = raw as { destCityId?: unknown }; + const destCityId = parseCityId(data?.destCityId); + if (destCityId === null) { + return null; + } + return { destCityId }; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: FloodArgs + ): Constraint[] { + void _ctx; + void _args; + return [ + occupiedCity(), + beChief(), + notNeutralDestCity(), + notOccupiedDestCity(), + allowDiplomacyBetweenStatus([0], '교전중인 국가의 도시가 아닙니다.'), + availableStrategicCommand(), + ]; + } + + resolve( + context: FloodResolveContext, + args: FloodArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} + +// 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다. +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destCityId = options.actionArgs.destCityId; + if (typeof destCityId !== 'number') { + return null; + } + const worldRef = options.worldRef; + if (!worldRef) { + return null; + } + const destCity = worldRef.getCityById(destCityId); + if (!destCity) { + return null; + } + const destNation = worldRef.getNationById(destCity.nationId); + const generals = worldRef.listGenerals(); + const friendlyGenerals = generals.filter( + (general) => general.nationId === base.general.nationId + ); + const destNationGenerals = generals.filter( + (general) => general.nationId === destCity.nationId + ); + return { + ...base, + destCity, + destNation: destNation ?? null, + friendlyGenerals, + destNationGenerals, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_수몰', + category: '전략', + reqArg: true, + args: { destCityId: 0 }, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? []), +}; diff --git a/packages/logic/src/actions/turn/nation/che_이호경식.ts b/packages/logic/src/actions/turn/nation/che_이호경식.ts new file mode 100644 index 0000000..28a2366 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_이호경식.ts @@ -0,0 +1,305 @@ +import type { + General, + GeneralTriggerState, + Nation, +} from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + allowDiplomacyBetweenStatus, + availableStrategicCommand, + beChief, + existsDestNation, + occupiedCity, +} 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 { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, + GeneralActionResolver, +} from '@sammo-ts/logic/actions/engine.js'; +import { + createDiplomacyPatchEffect, + createLogEffect, +} from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { + buildDefaultDiplomacy, + DIPLOMACY_STATE, +} from '../../../diplomacy/index.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { NationTurnCommandSpec } from './index.js'; + +export interface DegradeRelationsArgs { + destNationId: number; +} + +export interface DegradeRelationsResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + destNation: Nation; + diplomacy: { state: number; term: number }; + reverseDiplomacy: { state: number; term: number }; + friendlyGenerals: Array>; + destNationGenerals: Array>; +} + +const ACTION_NAME = '이호경식'; +const DEFAULT_GLOBAL_DELAY = 9; +const PRE_REQ_TURN = 0; +const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); + +const parseNationId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + const value = Math.floor(raw); + return value > 0 ? value : null; +}; + +const resolveNextTerm = (state: number, term: number): number => + state === DIPLOMACY_STATE.WAR ? 3 : term + 3; + +// 이호경식 쿨타임 계산을 담당한다. +export class CommandResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + private readonly pipeline: GeneralActionPipeline; + + constructor(modules: Array | null | undefined>) { + this.pipeline = new GeneralActionPipeline(modules); + } + + getGlobalDelay(context: DegradeRelationsResolveContext): number { + return Math.round( + this.pipeline.onCalcStrategic( + context, + ACTION_NAME, + 'globalDelay', + DEFAULT_GLOBAL_DELAY + ) + ); + } +} + +// 이호경식 실행 결과를 계산한다. +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionResolver { + readonly key = 'che_이호경식'; + private readonly command: CommandResolver; + + constructor(modules: Array | null | undefined>) { + this.command = new CommandResolver(modules); + } + + resolve( + context: DegradeRelationsResolveContext, + _args: DegradeRelationsArgs + ): GeneralActionOutcome { + void _args; + const { general, nation } = context; + const generalName = general.name; + const generalJosa = JosaUtil.pick(generalName, '이'); + const nationName = nation?.name ?? '아국'; + const nationJosa = JosaUtil.pick(nationName, '이'); + const destNationName = context.destNation.name; + const actionJosa = JosaUtil.pick(ACTION_NAME, '을'); + const broadcastMessage = `${generalName}${generalJosa} ${destNationName}${ACTION_NAME}${actionJosa} 발동하였습니다.`; + + general.experience += EXP_DED_GAIN; + general.dedication += EXP_DED_GAIN; + + context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH }); + context.addLog( + `${destNationName}${ACTION_NAME}${actionJosa} 발동`, + { + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ); + + const effects: Array> = [ + createDiplomacyPatchEffect( + general.nationId, + context.destNation.id, + { + state: DIPLOMACY_STATE.DECLARATION, + term: resolveNextTerm( + context.diplomacy.state, + context.diplomacy.term + ), + } + ), + createDiplomacyPatchEffect( + context.destNation.id, + general.nationId, + { + state: DIPLOMACY_STATE.DECLARATION, + term: resolveNextTerm( + context.reverseDiplomacy.state, + context.reverseDiplomacy.term + ), + } + ), + ]; + + for (const target of context.friendlyGenerals) { + if (target.id === general.id) { + continue; + } + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + }) + ); + } + + const destBroadcast = `${nationName}${nationJosa} 아국에 ${ACTION_NAME}${actionJosa} 발동하였습니다.`; + for (const target of context.destNationGenerals) { + effects.push( + createLogEffect(destBroadcast, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + }) + ); + } + + if (nation) { + const globalDelay = this.command.getGlobalDelay(context); + nation.meta = { + ...(nation.meta as object), + strategic_cmd_limit: globalDelay, + }; + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: nation.id, + format: LogFormat.YEAR_MONTH, + }) + ); + } + effects.push( + createLogEffect( + `${nationName}${generalName}${generalJosa} 아국에 ${ACTION_NAME}${actionJosa} 발동`, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: context.destNation.id, + format: LogFormat.PLAIN, + } + ) + ); + + return { effects }; + } +} + +// 이호경식 실행을 위한 정의/제약을 구성한다. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition< + TriggerState, + DegradeRelationsArgs, + DegradeRelationsResolveContext + > { + public readonly key = 'che_이호경식'; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(modules: Array | null | undefined>) { + this.resolver = new ActionResolver(modules); + } + + parseArgs(raw: unknown): DegradeRelationsArgs | null { + const data = raw as { destNationId?: unknown }; + const destNationId = parseNationId(data?.destNationId); + if (destNationId === null) { + return null; + } + return { destNationId }; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: DegradeRelationsArgs + ): Constraint[] { + void _ctx; + void _args; + return [ + occupiedCity(), + beChief(), + existsDestNation(), + allowDiplomacyBetweenStatus( + [0, 1], + '선포, 전쟁중인 상대국에게만 가능합니다.' + ), + availableStrategicCommand(), + ]; + } + + resolve( + context: DegradeRelationsResolveContext, + args: DegradeRelationsArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} + +// 예약 턴 실행에 필요한 대상 국가/외교 정보를 구성한다. +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destNationId = options.actionArgs.destNationId; + if (typeof destNationId !== 'number') { + return null; + } + const worldRef = options.worldRef; + if (!worldRef) { + return null; + } + const destNation = worldRef.getNationById(destNationId); + if (!destNation) { + return null; + } + const diplomacy = + worldRef.getDiplomacyEntry(base.general.nationId, destNationId) ?? + buildDefaultDiplomacy(base.general.nationId, destNationId); + const reverseDiplomacy = + worldRef.getDiplomacyEntry(destNationId, base.general.nationId) ?? + buildDefaultDiplomacy(destNationId, base.general.nationId); + const generals = worldRef.listGenerals(); + const friendlyGenerals = generals.filter( + (general) => general.nationId === base.general.nationId + ); + const destNationGenerals = generals.filter( + (general) => general.nationId === destNationId + ); + return { + ...base, + destNation, + diplomacy: { state: diplomacy.state, term: diplomacy.term }, + reverseDiplomacy: { state: reverseDiplomacy.state, term: reverseDiplomacy.term }, + friendlyGenerals, + destNationGenerals, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_이호경식', + category: '외교', + reqArg: true, + args: { destNationId: 0 }, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? []), +}; diff --git a/packages/logic/src/actions/turn/nation/che_필사즉생.ts b/packages/logic/src/actions/turn/nation/che_필사즉생.ts new file mode 100644 index 0000000..ec50ce2 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_필사즉생.ts @@ -0,0 +1,221 @@ +import type { + General, + GeneralTriggerState, +} from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + allowDiplomacyStatus, + availableStrategicCommand, + beChief, + occupiedCity, +} 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 { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, + GeneralActionResolver, +} from '@sammo-ts/logic/actions/engine.js'; +import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { NationTurnCommandSpec } from './index.js'; + +export interface DesperateFightArgs {} + +export interface DesperateFightResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + nationGenerals: Array>; +} + +const ACTION_NAME = '필사즉생'; +const DEFAULT_GLOBAL_DELAY = 9; +const PRE_REQ_TURN = 2; +const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); +const TRAIN_CAP = 100; +const ATMOS_CAP = 100; + +// 필사즉생 쿨타임 계산을 담당한다. +export class CommandResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + private readonly pipeline: GeneralActionPipeline; + + constructor(modules: Array | null | undefined>) { + this.pipeline = new GeneralActionPipeline(modules); + } + + getGlobalDelay(context: DesperateFightResolveContext): number { + return Math.round( + this.pipeline.onCalcStrategic( + context, + ACTION_NAME, + 'globalDelay', + DEFAULT_GLOBAL_DELAY + ) + ); + } +} + +// 필사즉생 실행 결과를 계산한다. +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionResolver { + readonly key = 'che_필사즉생'; + private readonly command: CommandResolver; + + constructor(modules: Array | null | undefined>) { + this.command = new CommandResolver(modules); + } + + resolve( + context: DesperateFightResolveContext, + _args: DesperateFightArgs + ): GeneralActionOutcome { + void _args; + const { general, nation } = context; + const generalName = general.name; + const generalJosa = JosaUtil.pick(generalName, '이'); + const broadcastMessage = `${generalName}${generalJosa} ${ACTION_NAME}을 발동하였습니다.`; + + general.experience += EXP_DED_GAIN; + general.dedication += EXP_DED_GAIN; + + context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH }); + context.addLog(`${ACTION_NAME}을 발동`, { + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }); + + const effects: Array> = []; + + const updateTrainAtmos = ( + target: General + ): { train: number; atmos: number } | null => { + const nextTrain = Math.max(target.train, TRAIN_CAP); + const nextAtmos = Math.max(target.atmos, ATMOS_CAP); + if (nextTrain === target.train && nextAtmos === target.atmos) { + return null; + } + return { train: nextTrain, atmos: nextAtmos }; + }; + + const selfPatch = updateTrainAtmos(general); + if (selfPatch) { + general.train = selfPatch.train; + general.atmos = selfPatch.atmos; + } + + for (const target of context.nationGenerals) { + if (target.id === general.id) { + continue; + } + const patch = updateTrainAtmos(target); + if (patch) { + effects.push( + createGeneralPatchEffect(patch, target.id) + ); + } + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + }) + ); + } + + if (nation) { + const globalDelay = this.command.getGlobalDelay(context); + nation.meta = { + ...(nation.meta as object), + strategic_cmd_limit: globalDelay, + }; + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: nation.id, + format: LogFormat.YEAR_MONTH, + }) + ); + } + + return { effects }; + } +} + +// 필사즉생 실행을 위한 정의/제약을 구성한다. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition< + TriggerState, + DesperateFightArgs, + DesperateFightResolveContext + > { + public readonly key = 'che_필사즉생'; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(modules: Array | null | undefined>) { + this.resolver = new ActionResolver(modules); + } + + parseArgs(_raw: unknown): DesperateFightArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: DesperateFightArgs + ): Constraint[] { + void _ctx; + void _args; + return [ + occupiedCity(), + beChief(), + allowDiplomacyStatus([0], '전쟁중이 아닙니다.'), + availableStrategicCommand(), + ]; + } + + resolve( + context: DesperateFightResolveContext, + args: DesperateFightArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} + +// 예약 턴 실행에 필요한 국가 장수 목록을 구성한다. +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const worldRef = options.worldRef; + if (!worldRef) { + return null; + } + const nationGenerals = worldRef + .listGenerals() + .filter((entry) => entry.nationId === base.general.nationId); + return { + ...base, + nationGenerals, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_필사즉생', + category: '전략', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? []), +}; diff --git a/packages/logic/src/actions/turn/nation/che_허보.ts b/packages/logic/src/actions/turn/nation/che_허보.ts new file mode 100644 index 0000000..2831414 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_허보.ts @@ -0,0 +1,304 @@ +import type { + City, + General, + GeneralTriggerState, + Nation, +} from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + allowDiplomacyBetweenStatus, + availableStrategicCommand, + beChief, + notNeutralDestCity, + notOccupiedDestCity, + occupiedCity, +} 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 { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, + GeneralActionResolver, +} from '@sammo-ts/logic/actions/engine.js'; +import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { NationTurnCommandSpec } from './index.js'; + +export interface DeceptionArgs { + destCityId: number; +} + +export interface DeceptionResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + destCity: City; + destNation: Nation | null; + destCityGenerals: Array>; + friendlyGenerals: Array>; + destNationSupplyCities: City[]; +} + +const ACTION_NAME = '허보'; +const DEFAULT_GLOBAL_DELAY = 9; +const PRE_REQ_TURN = 1; +const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); + +const parseCityId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + const value = Math.floor(raw); + return value > 0 ? value : null; +}; + +const pickMoveCityId = ( + rng: GeneralActionResolveContext['rng'], + destCityId: number, + candidates: City[] +): number => { + if (candidates.length === 0) { + return destCityId; + } + let idx = rng.nextInt(0, candidates.length); + let cityId = candidates[idx]?.id ?? destCityId; + if (cityId === destCityId && candidates.length > 1) { + idx = rng.nextInt(0, candidates.length); + cityId = candidates[idx]?.id ?? destCityId; + } + return cityId; +}; + +// 허보 쿨타임 계산을 담당한다. +export class CommandResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + private readonly pipeline: GeneralActionPipeline; + + constructor(modules: Array | null | undefined>) { + this.pipeline = new GeneralActionPipeline(modules); + } + + getGlobalDelay(context: DeceptionResolveContext): number { + return Math.round( + this.pipeline.onCalcStrategic( + context, + ACTION_NAME, + 'globalDelay', + DEFAULT_GLOBAL_DELAY + ) + ); + } +} + +// 허보 실행 결과를 계산한다. +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionResolver { + readonly key = 'che_허보'; + private readonly command: CommandResolver; + + constructor(modules: Array | null | undefined>) { + this.command = new CommandResolver(modules); + } + + resolve( + context: DeceptionResolveContext, + _args: DeceptionArgs + ): GeneralActionOutcome { + void _args; + const { general, nation } = context; + const generalName = general.name; + const generalJosa = JosaUtil.pick(generalName, '이'); + const cityName = context.destCity.name; + const broadcastMessage = `${generalName}${generalJosa} ${cityName}${ACTION_NAME}를 발동하였습니다.`; + const destBroadcastMessage = `상대의 ${ACTION_NAME}에 당했다!`; + + general.experience += EXP_DED_GAIN; + general.dedication += EXP_DED_GAIN; + + context.addLog(`${ACTION_NAME} 발동!`, { format: LogFormat.MONTH }); + context.addLog( + `${cityName}${ACTION_NAME}를 발동`, + { + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ); + + const effects: Array> = []; + + for (const target of context.friendlyGenerals) { + if (target.id === general.id) { + continue; + } + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + }) + ); + } + + for (const target of context.destCityGenerals) { + const moveCityId = pickMoveCityId( + context.rng, + context.destCity.id, + context.destNationSupplyCities + ); + effects.push( + createLogEffect(destBroadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + }) + ); + if (moveCityId !== target.cityId) { + effects.push( + createGeneralPatchEffect( + { cityId: moveCityId }, + target.id + ) + ); + } + } + + if (nation) { + const globalDelay = this.command.getGlobalDelay(context); + nation.meta = { + ...(nation.meta as object), + strategic_cmd_limit: globalDelay, + }; + effects.push( + createLogEffect(broadcastMessage, { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: nation.id, + format: LogFormat.YEAR_MONTH, + }) + ); + } + + if (context.destNation) { + effects.push( + createLogEffect( + `${nation?.name ?? '상대국'}${generalName}${generalJosa} 아국의 ${cityName}${ACTION_NAME}를 발동`, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: context.destNation.id, + format: LogFormat.PLAIN, + } + ) + ); + } + + return { effects }; + } +} + +// 허보 실행을 위한 정의/제약을 구성한다. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition> { + public readonly key = 'che_허보'; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(modules: Array | null | undefined>) { + this.resolver = new ActionResolver(modules); + } + + parseArgs(raw: unknown): DeceptionArgs | null { + const data = raw as { destCityId?: unknown }; + const destCityId = parseCityId(data?.destCityId); + if (destCityId === null) { + return null; + } + return { destCityId }; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: DeceptionArgs + ): Constraint[] { + void _ctx; + void _args; + return [ + occupiedCity(), + beChief(), + notNeutralDestCity(), + notOccupiedDestCity(), + allowDiplomacyBetweenStatus( + [0, 1], + '선포, 전쟁중인 상대국에게만 가능합니다.' + ), + availableStrategicCommand(), + ]; + } + + resolve( + context: DeceptionResolveContext, + args: DeceptionArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} + +// 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다. +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destCityId = options.actionArgs.destCityId; + if (typeof destCityId !== 'number') { + return null; + } + const worldRef = options.worldRef; + if (!worldRef) { + return null; + } + const destCity = worldRef.getCityById(destCityId); + if (!destCity) { + return null; + } + const destNation = worldRef.getNationById(destCity.nationId); + const generals = worldRef.listGenerals(); + const destCityGenerals = generals.filter( + (general) => + general.nationId === destCity.nationId && + general.cityId === destCity.id + ); + const friendlyGenerals = generals.filter( + (general) => general.nationId === base.general.nationId + ); + const destNationSupplyCities = worldRef + .listCities() + .filter( + (city) => + city.nationId === destCity.nationId && city.supplyState > 0 + ); + return { + ...base, + destCity, + destNation: destNation ?? null, + destCityGenerals, + friendlyGenerals, + destNationSupplyCities, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_허보', + category: '전략', + reqArg: true, + args: { destCityId: 0 }, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition(env.generalActionModules ?? []), +}; diff --git a/packages/logic/src/actions/turn/nation/index.ts b/packages/logic/src/actions/turn/nation/index.ts index cbca48a..9868fd6 100644 --- a/packages/logic/src/actions/turn/nation/index.ts +++ b/packages/logic/src/actions/turn/nation/index.ts @@ -8,6 +8,12 @@ export const NATION_TURN_COMMAND_KEYS = [ 'che_불가침제의', 'che_불가침파기제의', 'che_의병모집', + 'che_허보', + 'che_필사즉생', + 'che_백성동원', + 'che_이호경식', + 'che_수몰', + 'che_급습', ] as const; export type NationTurnCommandKey = @@ -32,6 +38,12 @@ const defaultImporters: Record< 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'), }; export const isNationTurnCommandKey = ( diff --git a/packages/logic/src/constraints/diplomacy.ts b/packages/logic/src/constraints/diplomacy.ts index 1ef9256..27a45b0 100644 --- a/packages/logic/src/constraints/diplomacy.ts +++ b/packages/logic/src/constraints/diplomacy.ts @@ -9,6 +9,37 @@ import { } from './helpers.js'; import type { Constraint, RequirementKey } from './types.js'; +const readDiplomacyEntry = ( + view: { has(req: RequirementKey): boolean; get(req: RequirementKey): unknown }, + srcNationId: number, + destNationId: number +): { state: number | null; term: number | null } | null => { + const req: RequirementKey = { + kind: 'diplomacy', + srcNationId, + destNationId, + }; + if (!view.has(req)) { + return null; + } + const value = view.get(req); + if (typeof value === 'number') { + return { state: value, term: null }; + } + if (value && typeof value === 'object') { + const record = value as { state?: number; stateCode?: number; term?: number }; + const state = + typeof record.state === 'number' + ? record.state + : typeof record.stateCode === 'number' + ? record.stateCode + : null; + const term = typeof record.term === 'number' ? record.term : null; + return { state, term }; + } + return null; +}; + export const disallowDiplomacyBetweenStatus = ( disallowList: Record ): Constraint => ({ @@ -118,3 +149,104 @@ export const allowDiplomacyBetweenStatus = ( return allow(); }, }); + +export const allowDiplomacyWithTerm = ( + requiredState: number, + minTerm: number, + reason: string +): Constraint => ({ + name: 'AllowDiplomacyWithTerm', + 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 entry = readDiplomacyEntry(view, baseNationId, destNationId); + if (!entry || entry.state === null || entry.term === null) { + const req: RequirementKey = { + kind: 'diplomacy', + srcNationId: baseNationId, + destNationId, + }; + return unknownOrDeny(ctx, [req], '외교 정보가 없습니다.'); + } + if (entry.state !== requiredState || entry.term < minTerm) { + return { kind: 'deny', reason }; + } + return allow(); + }, +}); + +export const allowDiplomacyStatus = ( + allowList: number[], + reason: string +): Constraint => ({ + name: 'AllowDiplomacyStatus', + requires: (ctx) => { + const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }]; + if (ctx.nationId !== undefined) { + reqs.push({ kind: 'nation', id: ctx.nationId }); + } + reqs.push({ kind: 'diplomacyList' }); + return reqs; + }, + test: (ctx, view) => { + const general = readGeneral(ctx, view); + const baseNationId = ctx.nationId ?? general?.nationId; + if (baseNationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'diplomacyList' }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '외교 정보가 없습니다.'); + } + const list = view.get(req); + if (!Array.isArray(list)) { + return unknownOrDeny(ctx, [req], '외교 정보가 없습니다.'); + } + const matched = list.some((entry) => { + if (!entry || typeof entry !== 'object') { + return false; + } + const record = entry as { fromNationId?: number; state?: number }; + return ( + record.fromNationId === baseNationId && + typeof record.state === 'number' && + allowList.includes(record.state) + ); + }); + if (!matched) { + return { kind: 'deny', reason }; + } + return allow(); + }, +}); diff --git a/packages/logic/src/constraints/types.ts b/packages/logic/src/constraints/types.ts index dbfe287..1d53d55 100644 --- a/packages/logic/src/constraints/types.ts +++ b/packages/logic/src/constraints/types.ts @@ -11,6 +11,7 @@ export type RequirementKey = | { kind: 'destCity'; id: number } | { kind: 'destNation'; id: number } | { kind: 'diplomacy'; srcNationId: number; destNationId: number } + | { kind: 'diplomacyList' } | { kind: 'arg'; key: string } | { kind: 'env'; key: string }; diff --git a/packages/logic/src/triggers/types.ts b/packages/logic/src/triggers/types.ts index 05936a3..914459e 100644 --- a/packages/logic/src/triggers/types.ts +++ b/packages/logic/src/triggers/types.ts @@ -23,7 +23,14 @@ export type TriggerDomesticVarType = | 'rice' | 'probability'; -export type TriggerStrategicActionType = '의병모집'; +export type TriggerStrategicActionType = + | '의병모집' + | '허보' + | '필사즉생' + | '백성동원' + | '이호경식' + | '수몰' + | '급습'; export type TriggerStrategicVarType = 'delay' | 'globalDelay'; diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index d23b696..cfa4332 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -26,6 +26,12 @@ "che_선전포고", "che_불가침제의", "che_불가침파기제의", - "che_의병모집" + "che_의병모집", + "che_허보", + "che_필사즉생", + "che_백성동원", + "che_이호경식", + "che_수몰", + "che_급습" ] }