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..99ef94c --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_감축.ts @@ -0,0 +1,171 @@ +import type { City, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; +import { + beChief, + occupiedCity, + suppliedCity, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { GeneralActionEffect, GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createNationPatchEffect, createCityPatchEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; + +export interface ReduceCityArgs { } + +export interface ReduceCityResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + capitalCity: City; +} + +const ACTION_NAME = '감축'; + +const POP_INCREASE = 100000; +const DEVEL_INCREASE = 2000; +const WALL_INCREASE = 2000; +const DEFAULT_COST = 60000; +const COST_COEF = 500; +const MIN_POP = 30000; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = 'che_감축'; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) { } + + parseArgs(_raw: unknown): ReduceCityArgs | null { + return {}; + } + + private getRecoverAmount(): number { + return this.env.develCost * COST_COEF + DEFAULT_COST / 2; + } + + buildConstraints(_ctx: ConstraintContext, _args: ReduceCityArgs): Constraint[] { + return [ + occupiedCity(), + beChief(), + suppliedCity(), + { + name: 'reducibleCity', + requires: (ctx) => [ + { kind: 'nation', id: ctx.nationId! }, + { kind: 'city', id: 0 } + ], + test: (ctx: ConstraintContext, view: StateView) => { + const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; + const capitalCityId = nation?.capitalCityId; + + if (!capitalCityId) return { kind: 'deny', reason: '방랑상태에서는 불가능합니다.' }; + + const capitalCity = view.get({ kind: 'city', id: capitalCityId }) as City | undefined; + if (!capitalCity) return { kind: 'deny', reason: '수도 정보를 찾을 수 없습니다.' }; + if (capitalCity.level <= 4) return { kind: 'deny', reason: '더이상 감축할 수 없습니다.' }; + + return { kind: 'allow' }; + } + } + ]; + } + + resolve( + context: ReduceCityResolveContext, + _args: ReduceCityArgs + ): GeneralActionOutcome { + const { general, nation, capitalCity } = context; + if (!nation) { + return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; + } + + const recoverAmount = this.getRecoverAmount(); + const generalName = general.name; + const nationName = nation.name; + const destCityName = capitalCity.name; + + const josaUl = JosaUtil.pick(destCityName, '을'); + const josaYi = JosaUtil.pick(generalName, '이'); + const josaYiNation = JosaUtil.pick(nationName, '이'); + + const effects: Array> = [ + createCityPatchEffect({ + level: capitalCity.level - 1, + population: Math.max(capitalCity.population - POP_INCREASE, MIN_POP), + agriculture: Math.max(capitalCity.agriculture - DEVEL_INCREASE, 0), + commerce: Math.max(capitalCity.commerce - DEVEL_INCREASE, 0), + security: Math.max(capitalCity.security - DEVEL_INCREASE, 0), + defence: Math.max(capitalCity.defence - WALL_INCREASE, 0), + wall: Math.max(capitalCity.wall - WALL_INCREASE, 0), + populationMax: capitalCity.populationMax - POP_INCREASE, + agricultureMax: capitalCity.agricultureMax - DEVEL_INCREASE, + commerceMax: capitalCity.commerceMax - DEVEL_INCREASE, + securityMax: capitalCity.securityMax - DEVEL_INCREASE, + defenceMax: capitalCity.defenceMax - WALL_INCREASE, + wallMax: capitalCity.wallMax - WALL_INCREASE, + }, capitalCity.id), + createNationPatchEffect({ + gold: nation.gold + recoverAmount, + rice: nation.rice + recoverAmount, + }, nation.id), + // Global Action Log + createLogEffect(`${generalName}${josaYi} ${destCityName}${josaUl} ${ACTION_NAME}하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }), + // Global History Log + createLogEffect(`【${ACTION_NAME}】${nationName}${josaYiNation} ${destCityName}${josaUl} ${ACTION_NAME}하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // Actor Nation History Log + createLogEffect(`${generalName}${josaYi} ${destCityName}${josaUl} ${ACTION_NAME}`, { + scope: LogScope.NATION, + nationId: nation.id, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // General Action Log + createLogEffect(`${destCityName}${josaUl} ${ACTION_NAME}했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ]; + + general.experience += 30; + general.dedication += 30; + + return { effects }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const nation = base.nation; + if (!nation || !nation.capitalCityId) return null; + + const worldRef = options.worldRef; + if (!worldRef) return null; + + const capitalCity = worldRef.getCityById(nation.capitalCityId); + if (!capitalCity) return null; + + return { + ...base, + capitalCity, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_감축', + category: '국가', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), +}; 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..cab9bcf --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_국기변경.ts @@ -0,0 +1,125 @@ +import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import type { StateView } from '@sammo-ts/logic/world/stateView.js'; +import { + beChief, + occupiedCity, + suppliedCity, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { GeneralActionEffect, GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; + +export interface ChangeFlagArgs { + colorType: number; +} + +const ACTION_NAME = '국기변경'; + +const NATION_COLORS = [ + '#FF0000', '#800000', '#A0522D', '#FF6347', '#FFA500', '#FFDAB9', '#FFD700', '#FFFF00', '#7CFC00', '#00FF00', + '#808000', '#008000', '#2E8B57', '#008080', '#20B2AA', '#6495ED', '#7FFFD4', '#AFEEEE', '#87CEEB', '#00FFFF', + '#00BFFF', '#0000FF', '#000080', '#483D8B', '#7B68EE', '#BA55D3', '#800080', '#FF00FF', '#FFC0CB', '#F5F5DC', + '#E0FFFF', '#FFFFFF', '#A9A9A9', +]; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition { + public readonly key = 'che_국기변경'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): ChangeFlagArgs | null { + const data = raw as { colorType?: number }; + if (typeof data?.colorType !== 'number') return null; + if (data.colorType < 0 || data.colorType >= NATION_COLORS.length) return null; + return { colorType: data.colorType }; + } + + buildConstraints(_ctx: ConstraintContext, _args: ChangeFlagArgs): Constraint[] { + return [ + occupiedCity(), + beChief(), + suppliedCity(), + { + name: 'canChangeFlag', + requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], + test: (ctx: ConstraintContext, view: StateView) => { + const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; + const canChange = ((nation?.meta as any)?.[`can_${ACTION_NAME}`] ?? 0) !== 0; + if (!canChange) return { kind: 'deny', reason: '더이상 변경이 불가능합니다.' }; + return { kind: 'allow' }; + } + } + ]; + } + + resolve( + context: GeneralActionResolveContext, + args: ChangeFlagArgs + ): GeneralActionOutcome { + const { general, nation } = context; + if (!nation) { + return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; + } + + const color = NATION_COLORS[args.colorType]; + const generalName = general.name; + const nationName = nation.name; + + const josaYi = JosaUtil.pick(generalName, '이'); + const josaYiNation = JosaUtil.pick(nationName, '이'); + + const effects: Array> = [ + createNationPatchEffect({ + color: color!, + meta: { + ...nation.meta, + [`can_${ACTION_NAME}`]: 0, + }, + }, nation.id), + // Global Action Log + createLogEffect(`${generalName}${josaYi} 국기를 변경하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }), + // Global History Log + createLogEffect(`【${ACTION_NAME}】${nationName}${josaYiNation} 국기를 변경하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // Actor Nation History Log + createLogEffect(`${generalName}${josaYi} 국기를 변경하였습니다.`, { + scope: LogScope.NATION, + nationId: nation.id, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // General Action Log + createLogEffect(`국기를 변경하였습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ]; + + general.experience += 5; + general.dedication += 5; + + return { effects }; + } +} + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_국기변경', + category: '국가', + reqArg: true, + args: { colorType: 0 }, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; 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..3bd6bb4 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_국호변경.ts @@ -0,0 +1,122 @@ +import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + beChief, + occupiedCity, + suppliedCity, + checkNationNameDuplicate, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { GeneralActionEffect, GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; + +export interface ChangeNationNameArgs { + nationName: string; +} + +const ACTION_NAME = '국호변경'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition { + public readonly key = 'che_국호변경'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): ChangeNationNameArgs | null { + const data = raw as { nationName?: string }; + if (typeof data?.nationName !== 'string') return null; + + const name = data.nationName.trim(); + if (name.length < 1 || name.length > 8) return null; + + return { nationName: name }; + } + + buildConstraints(_ctx: ConstraintContext, args: ChangeNationNameArgs): Constraint[] { + return [ + occupiedCity(), + beChief(), + suppliedCity(), + checkNationNameDuplicate(args.nationName), + { + name: 'canChangeNationName', + requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], + test: (_ctx: ConstraintContext, view: StateView) => { + const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; + const canChange = ((nation?.meta as any)?.[`can_${ACTION_NAME}`] ?? 0) !== 0; + if (!canChange) return { kind: 'deny', reason: '더이상 변경이 불가능합니다.' }; + return { kind: 'allow' }; + } + } + ]; + } + + resolve( + context: GeneralActionResolveContext, + args: ChangeNationNameArgs + ): GeneralActionOutcome { + const { general, nation } = context; + if (!nation) { + return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; + } + + const generalName = general.name; + const oldNationName = nation.name; + const newNationName = args.nationName; + + const josaYi = JosaUtil.pick(generalName, '이'); + const josaYiNation = JosaUtil.pick(newNationName, '이'); + + const effects: Array> = [ + createNationPatchEffect({ + name: newNationName, + meta: { + ...nation.meta, + [`can_${ACTION_NAME}`]: 0, + }, + }, nation.id), + // Global Action Log + createLogEffect(`${generalName}${josaYi} 국호를 ${newNationName}로 변경하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }), + // Global History Log + createLogEffect(`【${ACTION_NAME}】${oldNationName}${josaYiNation} 국호를 ${newNationName}로 변경하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // Actor Nation History Log + createLogEffect(`${generalName}${josaYi} 국호를 ${newNationName}로 변경하였습니다.`, { + scope: LogScope.NATION, + nationId: nation.id, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // General Action Log + createLogEffect(`국호를 ${newNationName}로 변경하였습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ]; + + general.experience += 5; + general.dedication += 5; + + return { effects }; + } +} + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_국호변경', + category: '국가', + reqArg: true, + args: { nationName: '' }, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; 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..ed5f45e --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_몰수.ts @@ -0,0 +1,158 @@ +import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + beChief, + existsDestGeneral, + friendlyDestGeneral, + occupiedCity, + suppliedCity, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { GeneralActionEffect, GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createNationPatchEffect, createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import { clamp } from 'es-toolkit'; + +export interface SeizureArgs { + isGold: boolean; + amount: number; + destGeneralID: number; +} + +export interface SeizureResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destGeneral: General; +} + +const ACTION_NAME = '몰수'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = 'che_몰수'; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) { } + + parseArgs(raw: unknown): SeizureArgs | null { + const data = raw as { isGold?: boolean; amount?: number; destGeneralID?: number }; + if (typeof data?.isGold !== 'boolean') return null; + if (typeof data?.amount !== 'number') return null; + if (typeof data?.destGeneralID !== 'number') return null; + + const amount = Math.floor(data.amount / 100) * 100; + if (amount <= 0) return null; + + return { + isGold: data.isGold, + amount: clamp(amount, 100, this.env.maxResourceActionAmount ?? 10000), + destGeneralID: data.destGeneralID, + }; + } + + buildConstraints(_ctx: ConstraintContext, args: SeizureArgs): Constraint[] { + return [ + occupiedCity(), + beChief(), + suppliedCity(), + existsDestGeneral(), + friendlyDestGeneral(), + { + name: 'notSelf', + requires: () => [], + test: (ctx: ConstraintContext) => { + if (ctx.actorId === args.destGeneralID) { + return { kind: 'deny', reason: '본인입니다' }; + } + return { kind: 'allow' }; + } + } + ]; + } + + resolve( + context: SeizureResolveContext, + args: SeizureArgs + ): GeneralActionOutcome { + const { general, nation, destGeneral } = context; + if (!nation) { + return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; + } + + const resKey = args.isGold ? 'gold' : 'rice'; + const resName = args.isGold ? '금' : '쌀'; + + const actualAmount = clamp(args.amount, 0, (destGeneral as any)[resKey] ?? 0); + + if (actualAmount <= 0) { + return { + effects: [ + createLogEffect(`${destGeneral.name}에게서 몰수할 ${resName}이 없습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } + + const amountText = actualAmount.toLocaleString(); + const josaUl = JosaUtil.pick(amountText, '을'); + + const effects: Array> = [ + createGeneralPatchEffect({ + [resKey]: (destGeneral as any)[resKey] - actualAmount, + }, destGeneral.id), + createNationPatchEffect({ + [resKey]: (nation as any)[resKey] + actualAmount, + }, nation.id), + // Actor General Action Log + createLogEffect(`${destGeneral.name}에게서 ${resName} ${amountText}${josaUl} 몰수했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + // Target General Action Log + createLogEffect(`${resName} ${amountText}${josaUl} 몰수 당했습니다.`, { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }), + ]; + + general.experience += 5; + general.dedication += 5; + + return { effects }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destGeneralId = options.actionArgs.destGeneralID; + if (typeof destGeneralId !== 'number') return null; + + const worldRef = options.worldRef; + if (!worldRef) return null; + + const destGeneral = worldRef.getGeneralById(destGeneralId); + if (!destGeneral) return null; + + return { + ...base, + destGeneral, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_몰수', + category: '인사', + reqArg: true, + args: { isGold: false, amount: 0, destGeneralID: 0 }, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), +}; 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..6f61331 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts @@ -0,0 +1,175 @@ +import type { GeneralTriggerState, Nation, City, General } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; +import { + beMonarch, + occupiedCity, + suppliedCity, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { GeneralActionEffect, GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createNationPatchEffect, createCityPatchEffect, createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; + +export interface RandomMoveCapitalArgs { } + +export interface RandomMoveCapitalResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + neutralCandidateCities: City[]; + nationGenerals: General[]; +} + +const ACTION_NAME = '무작위 수도 이전'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = 'che_무작위수도이전'; + public readonly name = ACTION_NAME; + + parseArgs(_raw: unknown): RandomMoveCapitalArgs | null { + return {}; + } + + buildConstraints(_ctx: ConstraintContext, _args: RandomMoveCapitalArgs): Constraint[] { + return [ + occupiedCity(), + beMonarch(), + suppliedCity(), + { + name: 'canRandomMoveCapital', + requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], + test: (ctx: ConstraintContext, view: StateView) => { + const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; + const canMove = ((nation?.meta as any)?.[`can_무작위수도이전`] ?? 0) > 0; + if (!canMove) return { kind: 'deny', reason: '더이상 변경이 불가능합니다.' }; + return { kind: 'allow' }; + } + } + ]; + } + + resolve( + context: RandomMoveCapitalResolveContext, + _args: RandomMoveCapitalArgs + ): GeneralActionOutcome { + const { general, nation, neutralCandidateCities, nationGenerals, rng } = context; + if (!nation) { + return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; + } + + if (neutralCandidateCities.length === 0) { + return { + effects: [ + createLogEffect(`이동할 수 있는 도시가 없습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } + + const destCity = neutralCandidateCities[rng.nextInt(0, neutralCandidateCities.length)]!; + const oldCityId = nation.capitalCityId; + const generalName = general.name; + const nationName = nation.name; + const destCityName = destCity.name; + + const josaRo = JosaUtil.pick(destCityName, '로'); + const josaYi = JosaUtil.pick(generalName, '이'); + const josaYiNation = JosaUtil.pick(nationName, '이'); + + const effects: Array> = [ + createNationPatchEffect({ + capitalCityId: destCity.id, + meta: { + ...nation.meta, + can_무작위수도이전: ((nation.meta as any).can_무작위수도이전 ?? 0) - 1, + }, + }, nation.id), + // Set new capital + createCityPatchEffect({ + nationId: nation.id, + }, destCity.id), + // Set old capital to neutral + createCityPatchEffect({ + nationId: 0, + frontState: 0, + }, oldCityId!), + // Global Action Log + createLogEffect(`${generalName}${josaYi} ${destCityName}${josaRo} 수도 이전【${ACTION_NAME}】${nationName}${josaYiNation} ${destCityName}${josaRo} 수도 이전${generalName}${josaYi} ${destCityName}${josaRo} ${ACTION_NAME}${destCityName}${josaRo} 국가를 옮겼습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ]; + + // Move all generals to new capital + for (const targetGeneral of nationGenerals) { + effects.push(createGeneralPatchEffect({ + cityId: destCity.id, + }, targetGeneral.id)); + + if (targetGeneral.id !== general.id) { + effects.push(createLogEffect(`국가 수도를 ${destCityName}${josaRo} 옮겼습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: targetGeneral.id, + format: LogFormat.PLAIN, + })); + } + } + + general.experience += 10; + general.dedication += 10; + + return { effects }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const worldRef = options.worldRef; + if (!worldRef) return null; + + const allCities = worldRef.listCities(); + const neutralCandidateCities = allCities.filter(c => c.nationId === 0 && c.level >= 5 && c.level <= 6); + const nationGenerals = worldRef.listGenerals().filter(g => g.nationId === base.general.nationId); + + return { + ...base, + neutralCandidateCities, + nationGenerals, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_무작위수도이전', + category: '국가', + reqArg: false, + args: {}, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; 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..39d9e1d --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_물자원조.ts @@ -0,0 +1,191 @@ +import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; +import { + beChief, + occupiedCity, + suppliedCity, + differentDestNation, + existsDestNation, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { GeneralActionEffect, GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import { clamp } from 'es-toolkit'; + +export interface MaterialAidArgs { + destNationId: number; + amountList: [number, number]; // [gold, rice] +} + +export interface MaterialAidResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destNation: Nation; +} + +const ACTION_NAME = '원조'; +const COEF_AID_AMOUNT = 10000; +const POST_REQ_TURN = 12; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = 'che_물자원조'; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) { } + + parseArgs(raw: unknown): MaterialAidArgs | null { + const data = raw as { destNationId?: number; amountList?: [number, number] }; + if (typeof data?.destNationId !== 'number') return null; + if (!Array.isArray(data.amountList) || data.amountList.length !== 2) return null; + if (typeof data.amountList[0] !== 'number' || typeof data.amountList[1] !== 'number') return null; + + return { + destNationId: data.destNationId, + amountList: [data.amountList[0], data.amountList[1]], + }; + } + + buildConstraints(_ctx: ConstraintContext, args: MaterialAidArgs): Constraint[] { + const [goldAmount, riceAmount] = args.amountList; + return [ + occupiedCity(), + beChief(), + suppliedCity(), + existsDestNation(), + differentDestNation(), + { + name: 'aidLimit', + requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], + test: (ctx: ConstraintContext, view: StateView) => { + const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; + const limit = (nation?.level ?? 1) * COEF_AID_AMOUNT; + if (goldAmount > limit || riceAmount > limit) { + return { kind: 'deny', reason: '작위 제한량 이상은 보낼 수 없습니다.' }; + } + return { kind: 'allow' }; + } + }, + { + name: 'nationSurlimit', + requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], + test: (_ctx: ConstraintContext, view: StateView) => { + const nation = view.get({ kind: 'nation', id: _ctx.nationId! }) as Nation | undefined; + const surlimit = (nation?.meta as any)?.surlimit ?? 0; + if (surlimit > 0) return { kind: 'deny', reason: '외교제한중입니다.' }; + return { kind: 'allow' }; + } + }, + { + name: 'destNationSurlimit', + requires: () => [{ kind: 'nation', id: args.destNationId }], + test: (ctx: ConstraintContext, view: StateView) => { + const destNation = view.get({ kind: 'nation', id: args.destNationId }) as Nation | undefined; + const surlimit = (destNation?.meta as any)?.surlimit ?? 0; + if (surlimit > 0) return { kind: 'deny', reason: '상대국이 외교제한중입니다.' }; + return { kind: 'allow' }; + } + } + ]; + } + + resolve( + context: MaterialAidResolveContext, + args: MaterialAidArgs + ): GeneralActionOutcome { + const { general, nation, destNation } = context; + if (!nation || !destNation) { + return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; + } + + const [goldAmount, riceAmount] = args.amountList; + + // Fit amount to nation's resources + const actualGold = clamp(goldAmount, 0, Math.max(0, nation.gold - this.env.baseGold)); + const actualRice = clamp(riceAmount, 0, Math.max(0, nation.rice - this.env.baseRice)); + + const goldText = actualGold.toLocaleString(); + const riceText = actualRice.toLocaleString(); + const josaUlRice = JosaUtil.pick(riceText, '을'); + const josaRo = JosaUtil.pick(destNation.name, '로'); + const nationName = nation.name; + + const broadcastMessage = `${destNation.name}${josaRo} 금${goldText} 쌀${riceText}을 지원했습니다.`; + + const effects: Array> = [ + createNationPatchEffect({ + gold: nation.gold - actualGold, + rice: nation.rice - actualRice, + meta: { + ...nation.meta, + surlimit: ((nation.meta as any)?.surlimit ?? 0) + POST_REQ_TURN, + }, + }, nation.id), + createNationPatchEffect({ + gold: destNation.gold + actualGold, + rice: destNation.rice + actualRice, + }, destNation.id), + // Global History Log + createLogEffect(`【${ACTION_NAME}】${nationName}에서 ${destNation.name}${josaRo} 물자를 지원합니다`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // Actor Nation History Log + createLogEffect(`${destNation.name}${josaRo} 금${goldText} 쌀${riceText}${josaUlRice} 지원`, { + scope: LogScope.NATION, + nationId: nation.id, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // Dest Nation History Log + createLogEffect(`${nationName}로부터 금${goldText} 쌀${riceText}${josaUlRice} 지원 받음`, { + scope: LogScope.NATION, + nationId: destNation.id, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // General Action Log + createLogEffect(broadcastMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ]; + + general.experience += 5; + general.dedication += 5; + + return { effects }; + } +} + +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; + + return { + ...base, + destNation, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_물자원조', + category: '외교', + reqArg: true, + args: { destNationId: 0, amountList: [0, 0] }, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), +}; diff --git a/packages/logic/src/actions/turn/nation/che_선전포고.ts b/packages/logic/src/actions/turn/nation/che_선전포고.ts index 3f11d16..954ef6f 100644 --- a/packages/logic/src/actions/turn/nation/che_선전포고.ts +++ b/packages/logic/src/actions/turn/nation/che_선전포고.ts @@ -1,25 +1,33 @@ -import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { beChief, disallowDiplomacyBetweenStatus, existsDestNation, + nearNation, notBeNeutral, occupiedCity, suppliedCity, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; -import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import type { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, +} 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 { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; export interface DeclareWarArgs { destNationId: number; } +// DeclareWarResolveContext is not used anymore as it was replaced by inline type in ActionDefinition + const ACTION_NAME = '선전포고'; // legacy 규칙: 선전포고 상태는 24턴 유지. const DIPLOMACY_DECLARE = 1; @@ -34,7 +42,11 @@ const parseNationId = (raw: unknown): number | null => { export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition { +> implements GeneralActionDefinition< + TriggerState, + DeclareWarArgs, + GeneralActionResolveContext & { destNation: Nation } +> { public readonly key = 'che_선전포고'; public readonly name = ACTION_NAME; @@ -48,12 +60,24 @@ export class ActionDefinition< } buildConstraints(_ctx: ConstraintContext, _args: DeclareWarArgs): Constraint[] { + const relYear = typeof _ctx.env.relYear === 'number' ? _ctx.env.relYear : 0; + return [ notBeNeutral(), occupiedCity(), suppliedCity(), beChief(), existsDestNation(), + nearNation(), + // legacy: startYear + 1. 0-indexed relYear로는 1 + { + name: 'declareWarYearLimit', + requires: () => [], + test: () => { + if (relYear >= 1) return { kind: 'allow' }; + return { kind: 'deny', reason: '초반제한 해제 2년전부터 가능합니다.' }; + }, + }, disallowDiplomacyBetweenStatus({ 0: '아국과 이미 교전중입니다.', 1: '아국과 이미 선포중입니다.', @@ -63,14 +87,14 @@ export class ActionDefinition< } resolve( - context: GeneralActionResolveContext, + context: GeneralActionResolveContext & { destNation: Nation }, args: DeclareWarArgs ): GeneralActionOutcome { const nationId = context.nation?.id; - if (nationId === undefined || nationId <= 0) { + if (nationId === undefined || nationId <= 0 || !context.destNation) { return { effects: [ - createLogEffect(`${ACTION_NAME}을 준비했지만 국가 정보가 없습니다.`, { + createLogEffect(`${ACTION_NAME}을 준비했지만 국가(또는 대상 국가) 정보가 없습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, format: LogFormat.MONTH, @@ -78,28 +102,86 @@ export class ActionDefinition< ], }; } - return { - effects: [ - createDiplomacyPatchEffect(nationId, args.destNationId, { - state: DIPLOMACY_DECLARE, - term: DECLARE_TERM, - }), - createDiplomacyPatchEffect(args.destNationId, nationId, { - state: DIPLOMACY_DECLARE, - term: DECLARE_TERM, - }), - createLogEffect(`${ACTION_NAME}을 실행했습니다. (국가 ${args.destNationId})`, { - scope: LogScope.GENERAL, + + const nationName = context.nation?.name ?? '아국'; + const destNationName = context.destNation.name; + const generalName = context.general.name; + const josaGa = JosaUtil.pick(nationName, '가'); + const josaGaGeneral = JosaUtil.pick(generalName, '이'); + + const broadcastMessage = `${nationName}${josaGa} ${destNationName}에게 ${ACTION_NAME}하였습니다!`; + const historyMessage = `${nationName}${josaGa} ${destNationName}에게 ${ACTION_NAME}`; + + const effects: Array> = [ + createDiplomacyPatchEffect(nationId, args.destNationId, { + state: DIPLOMACY_DECLARE, + term: DECLARE_TERM, + }), + createDiplomacyPatchEffect(args.destNationId, nationId, { + state: DIPLOMACY_DECLARE, + term: DECLARE_TERM, + }), + // Global Action Log + createLogEffect(broadcastMessage, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }), + // Global History Log + createLogEffect(historyMessage, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // Actor Nation History Log + createLogEffect(historyMessage, { + scope: LogScope.NATION, + nationId: nationId, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // Target Nation History Log + createLogEffect(historyMessage, { + scope: LogScope.NATION, + nationId: args.destNationId, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // National Message (국메) + createLogEffect( + `【국메】${generalName}${josaGaGeneral} ${destNationName}에게 ${ACTION_NAME}하였습니다!`, + { + scope: LogScope.NATION, + nationId: nationId, category: LogCategory.ACTION, - format: LogFormat.MONTH, - }), - ], - }; + format: LogFormat.PLAIN, + } + ), + ]; + + return { effects }; } } -// 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +// 예약 턴 실행에 필요한 대상 국가 정보를 구성한다. +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; + } + return { + ...base, + destNation, + }; +}; export const commandSpec: NationTurnCommandSpec = { key: 'che_선전포고', 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..227aa70 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_증축.ts @@ -0,0 +1,170 @@ +import type { City, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; +import { + beChief, + occupiedCity, + suppliedCity, + reqNationGold, + reqNationRice, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { GeneralActionEffect, GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createNationPatchEffect, createCityPatchEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { JosaUtil } from '@sammo-ts/common'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; + +export interface ExpandCityArgs { } + +export interface ExpandCityResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + capitalCity: City; +} + +const ACTION_NAME = '증축'; + +const POP_INCREASE = 100000; +const DEVEL_INCREASE = 2000; +const WALL_INCREASE = 2000; +const DEFAULT_COST = 60000; +const COST_COEF = 500; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = 'che_증축'; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) { } + + parseArgs(_raw: unknown): ExpandCityArgs | null { + return {}; + } + + private getCost(): number { + return this.env.develCost * COST_COEF + DEFAULT_COST; + } + + buildConstraints(_ctx: ConstraintContext, _args: ExpandCityArgs): Constraint[] { + const cost = this.getCost(); + return [ + occupiedCity(), + beChief(), + suppliedCity(), + reqNationGold(() => this.env.baseGold + cost), + reqNationRice(() => this.env.baseRice + cost), + { + name: 'expandableCity', + requires: (ctx) => [ + { kind: 'nation', id: ctx.nationId! }, + { kind: 'city', id: 0 } + ], + test: (ctx: ConstraintContext, view: StateView) => { + const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; + const capitalCityId = nation?.capitalCityId; + + if (!capitalCityId) return { kind: 'deny', reason: '방랑상태에서는 불가능합니다.' }; + + const capitalCity = view.get({ kind: 'city', id: capitalCityId }) as City | undefined; + if (!capitalCity) return { kind: 'deny', reason: '수도 정보를 찾을 수 없습니다.' }; + if (capitalCity.level <= 3) return { kind: 'deny', reason: '수진, 진, 관문에서는 불가능합니다.' }; + if (capitalCity.level >= 8) return { kind: 'deny', reason: '더이상 증축할 수 없습니다.' }; + + return { kind: 'allow' }; + } + } + ]; + } + + resolve( + context: ExpandCityResolveContext, + _args: ExpandCityArgs + ): GeneralActionOutcome { + const { general, nation, capitalCity } = context; + if (!nation) { + return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; + } + + const cost = this.getCost(); + const generalName = general.name; + const nationName = nation.name; + const destCityName = capitalCity.name; + + const josaUl = JosaUtil.pick(destCityName, '을'); + const josaYi = JosaUtil.pick(generalName, '이'); + const josaYiNation = JosaUtil.pick(nationName, '이'); + + const effects: Array> = [ + createCityPatchEffect({ + level: capitalCity.level + 1, + populationMax: capitalCity.populationMax + POP_INCREASE, + agricultureMax: capitalCity.agricultureMax + DEVEL_INCREASE, + commerceMax: capitalCity.commerceMax + DEVEL_INCREASE, + securityMax: capitalCity.securityMax + DEVEL_INCREASE, + defenceMax: capitalCity.defenceMax + WALL_INCREASE, + wallMax: capitalCity.wallMax + WALL_INCREASE, + }, capitalCity.id), + createNationPatchEffect({ + gold: nation.gold - cost, + rice: nation.rice - cost, + }, nation.id), + // Global Action Log + createLogEffect(`${generalName}${josaYi} ${destCityName}${josaUl} ${ACTION_NAME}하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }), + // Global History Log + createLogEffect(`【${ACTION_NAME}】${nationName}${josaYiNation} ${destCityName}${josaUl} ${ACTION_NAME}하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // Actor Nation History Log + createLogEffect(`${generalName}${josaYi} ${destCityName}${josaUl} ${ACTION_NAME}`, { + scope: LogScope.NATION, + nationId: nation.id, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // General Action Log + createLogEffect(`${destCityName}${josaUl} ${ACTION_NAME}했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ]; + + general.experience += 30; + general.dedication += 30; + + return { effects }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const nation = base.nation; + if (!nation || !nation.capitalCityId) return null; + + const worldRef = options.worldRef; + if (!worldRef) return null; + + const capitalCity = worldRef.getCityById(nation.capitalCityId); + if (!capitalCity) return null; + + return { + ...base, + capitalCity, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_증축', + category: '국가', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), +}; 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..411fbe6 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_천도.ts @@ -0,0 +1,206 @@ +import type { City, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; +import { + beChief, + occupiedCity, + occupiedDestCity, + suppliedCity, + suppliedDestCity, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { GeneralActionEffect, GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import { createLogEffect, createNationPatchEffect } 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'; +import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; + +export interface MoveCapitalArgs { + destCityID: number; +} + +export interface MoveCapitalResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destCity: City; + map: MapDefinition; +} + +const ACTION_NAME = '천도'; + +const calcDistance = ( + fromCityId: number, + toCityId: number, + map: MapDefinition +): number => { + if (fromCityId === toCityId) return 0; + + const connections = new Map(); + + for (const city of map.cities) { + connections.set(city.id, city.connections ?? []); + } + + const queue: Array<[number, number]> = [[fromCityId, 0]]; + const visited = new Set([fromCityId]); + + while (queue.length > 0) { + const [current, dist] = queue.shift()!; + if (current === toCityId) return dist; + + const nextNodes = connections.get(current) ?? []; + for (const next of nextNodes) { + if (!visited.has(next)) { + visited.add(next); + queue.push([next, dist + 1]); + } + } + } + + return 50; +}; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = 'che_천도'; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) { } + + parseArgs(raw: unknown): MoveCapitalArgs | null { + const data = raw as { destCityID?: number }; + if (typeof data?.destCityID !== 'number') return null; + return { destCityID: data.destCityID }; + } + + buildConstraints(_ctx: ConstraintContext, args: MoveCapitalArgs): Constraint[] { + const develcost = this.env.develCost; + + return [ + occupiedCity(), + occupiedDestCity(), + beChief(), + suppliedCity(), + suppliedDestCity(), + { + name: 'notCurrentCapital', + requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], + test: (ctx: ConstraintContext, view: StateView) => { + const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; + if (nation?.capitalCityId === args.destCityID) { + return { kind: 'deny', reason: '이미 수도입니다.' }; + } + return { kind: 'allow' }; + } + }, + { + name: 'reqMoveCapitalCost', + requires: (ctx) => [ + { kind: 'nation', id: ctx.nationId! }, + { kind: 'env', key: 'map' } + ], + test: (ctx: ConstraintContext, view: StateView) => { + const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; + const map = view.get({ kind: 'env', key: 'map' }) as MapDefinition | undefined; + if (!map || !nation || nation.capitalCityId === undefined || nation.capitalCityId === null) return { kind: 'allow' }; + + const dist = calcDistance(nation.capitalCityId, args.destCityID, map); + const cost = develcost * 5 * Math.pow(2, dist); + + if (nation.gold < cost + 1000) return { kind: 'deny', reason: `금이 부족합니다. (필요: ${cost + 1000})` }; + if (nation.rice < cost + 1000) return { kind: 'deny', reason: `쌀이 부족합니다. (필요: ${cost + 1000})` }; + + return { kind: 'allow' }; + } + } + ]; + } + + resolve( + context: MoveCapitalResolveContext, + args: MoveCapitalArgs + ): GeneralActionOutcome { + const { general, nation, destCity, map } = context; + if (!nation || nation.capitalCityId === undefined || nation.capitalCityId === null) { + return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; + } + + const dist = calcDistance(nation.capitalCityId, args.destCityID, map); + const cost = this.env.develCost * 5 * Math.pow(2, dist); + + const generalName = general.name; + const nationName = nation.name; + const destCityName = destCity.name; + + const josaRo = JosaUtil.pick(destCityName, '로'); + const josaYi = JosaUtil.pick(generalName, '이'); + const josaYiNation = JosaUtil.pick(nationName, '이'); + + const effects: Array> = [ + createNationPatchEffect({ + capitalCityId: args.destCityID, + gold: nation.gold - cost, + rice: nation.rice - cost, + }, nation.id), + // Global Action Log + createLogEffect(`${generalName}${josaYi} ${destCityName}${josaRo} ${ACTION_NAME}를 명령하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }), + // Global History Log + createLogEffect(`【${ACTION_NAME}】${nationName}${josaYiNation} ${destCityName}${josaRo} ${ACTION_NAME}하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // Actor Nation History Log + createLogEffect(`${generalName}${josaYi} ${destCityName}${josaRo} ${ACTION_NAME} 명령`, { + scope: LogScope.NATION, + nationId: nation.id, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + // General Action Log + createLogEffect(`${destCityName}${josaRo} ${ACTION_NAME}했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ]; + + general.experience += 5 * (dist * 2 + 1); + general.dedication += 5 * (dist * 2 + 1); + + return { effects }; + } +} + +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); + const map = options.map; + if (!destCity || !map) return null; + + return { + ...base, + destCity, + map, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_천도', + category: '국가', + reqArg: true, + args: { destCityID: 0 }, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), +}; diff --git a/packages/logic/src/actions/turn/nation/index.ts b/packages/logic/src/actions/turn/nation/index.ts index a05862a..74d1734 100644 --- a/packages/logic/src/actions/turn/nation/index.ts +++ b/packages/logic/src/actions/turn/nation/index.ts @@ -15,6 +15,14 @@ export const NATION_TURN_COMMAND_KEYS = [ 'che_이호경식', 'che_수몰', 'che_급습', + 'che_천도', + 'che_국호변경', + 'che_무작위수도이전', + 'che_국기변경', + 'che_증축', + 'che_감축', + 'che_몰수', + 'che_물자원조', ] as const; export type NationTurnCommandKey = (typeof NATION_TURN_COMMAND_KEYS)[number]; @@ -40,6 +48,14 @@ 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'), + che_몰수: async () => import('./che_몰수.js'), + che_물자원조: async () => import('./che_물자원조.js'), }; export const isNationTurnCommandKey = (value: string): value is NationTurnCommandKey => @@ -50,7 +66,7 @@ export class NationTurnCommandLoader { constructor( private readonly importers: Record = defaultImporters - ) {} + ) { } async load(key: NationTurnCommandKey): Promise { const cached = this.cache.get(key); diff --git a/packages/logic/src/constraints/nation.ts b/packages/logic/src/constraints/nation.ts index 24033e6..16da94b 100644 --- a/packages/logic/src/constraints/nation.ts +++ b/packages/logic/src/constraints/nation.ts @@ -1,4 +1,4 @@ -import type { General, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js'; import { allow, readGeneral, readMetaNumber, readNation, resolveDestNationId, unknownOrDeny } from './helpers.js'; import type { Constraint, ConstraintContext, RequirementKey, StateView } from './types.js'; @@ -272,3 +272,68 @@ export const checkNationNameDuplicate = (name: string): Constraint => ({ return allow(); }, }); + +export const nearNation = (): Constraint => ({ + name: 'nearNation', + requires: (ctx) => { + const reqs: RequirementKey[] = [ + { kind: 'env', key: 'cities' }, + { kind: 'env', key: 'map' }, + ]; + if (ctx.nationId !== undefined) { + reqs.push({ kind: 'nation', id: ctx.nationId }); + } else { + reqs.push({ kind: 'general', id: ctx.actorId }); + } + const destNationId = resolveDestNationId(ctx); + if (destNationId !== undefined) { + reqs.push({ kind: 'destNation', id: destNationId }); + } + return reqs; + }, + test: (ctx, view) => { + const cities = view.get({ kind: 'env', key: 'cities' }) as City[] | null; + const map = view.get({ kind: 'env', key: 'map' }) as { + cities: Array<{ id: number; connections: number[] }>; + } | null; + if (!cities || !map) { + return unknownOrDeny(ctx, [], '지도 정보가 없습니다.'); + } + + const destNationId = resolveDestNationId(ctx); + if (destNationId === undefined) { + return unknownOrDeny(ctx, [], '상대 국가 정보가 없습니다.'); + } + + let baseNationId = ctx.nationId; + if (baseNationId === undefined) { + const general = readGeneral(ctx, view); + if (!general) { + return unknownOrDeny(ctx, [], '아국 정보가 없습니다.'); + } + baseNationId = general.nationId; + } + + const baseNationCityIds = new Set(cities.filter((c) => c.nationId === baseNationId).map((c) => c.id)); + const destNationCityIds = new Set(cities.filter((c) => c.nationId === destNationId).map((c) => c.id)); + + if (baseNationCityIds.size === 0) { + return { kind: 'deny', reason: '아국 도시가 없습니다.' }; + } + if (destNationCityIds.size === 0) { + return { kind: 'deny', reason: '상대 국가 도시가 없습니다.' }; + } + + for (const cityId of baseNationCityIds) { + const cityDef = map.cities.find((c) => c.id === cityId); + if (!cityDef) continue; + for (const neighborId of cityDef.connections) { + if (destNationCityIds.has(neighborId)) { + return allow(); + } + } + } + + return { kind: 'deny', reason: '인접 국가가 아닙니다.' }; + }, +}); diff --git a/packages/logic/test/actions/turn/nation.test.ts b/packages/logic/test/actions/turn/nation.test.ts new file mode 100644 index 0000000..41656a8 --- /dev/null +++ b/packages/logic/test/actions/turn/nation.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from 'vitest'; +import type { City, General, Nation } from '../../../src/domain/entities.js'; +import { resolveGeneralAction } from '../../../src/actions/engine.js'; +import { ActionDefinition as DeclareWarAction } from '../../../src/actions/turn/nation/che_선전포고.js'; +import { ActionDefinition as LastStandAction } from '../../../src/actions/turn/nation/che_필사즉생.js'; +import { LogCategory, LogScope } from '../../../src/logging/types.js'; +import type { MapDefinition } from '../../../src/world/types.js'; +import type { TurnSchedule } from '../../../src/turn/calendar.js'; + +const buildGeneral = (id: number, nationId: number, cityId: number, name = 'TestGeneral'): General => ({ + id, + name, + nationId, + cityId, + troopId: 0, + stats: { + leadership: 70, + strength: 70, + intelligence: 70, + }, + experience: 100, + dedication: 100, + officerLevel: 3, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { + horse: null, + weapon: null, + book: null, + item: null, + }, + }, + injury: 0, + gold: 1000, + rice: 2000, + crew: 1500, + crewTypeId: 100, + train: 80, + atmos: 80, + age: 25, + npcState: 0, + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + meta: {}, +}); + +const buildCity = (id: number, nationId: number): City => ({ + id, + name: `City${id}`, + nationId, + level: 2, + state: 0, + population: 10000, + populationMax: 10000, + agriculture: 1000, + agricultureMax: 1000, + commerce: 1000, + commerceMax: 1000, + security: 1000, + securityMax: 1000, + supplyState: 1, + frontState: 0, + defence: 200, + defenceMax: 400, + wall: 200, + wallMax: 400, + meta: {}, +}); + +const buildNation = (id: number, name = 'Nation'): Nation => ({ + id, + name: `${name}${id}`, + color: '#000000', + capitalCityId: id, + chiefGeneralId: id === 1 ? 1 : null, + gold: 5000, + rice: 5000, + power: 0, + level: 1, + typeCode: 'test', + meta: { + tech: 1000, + strategic_cmd_limit: 0, + }, +}); + +const schedule: TurnSchedule = { + entries: [{ startMinute: 0, tickMinutes: 60 }], +}; + +describe('Nation Actions', () => { + describe('che_선전포고 (Declare War)', () => { + it('succeeds with neighbor and year 2', () => { + const nation1 = buildNation(1); + const nation2 = buildNation(2); + const city1 = buildCity(1, 1); + const city2 = buildCity(2, 2); + const general = buildGeneral(1, 1, 1); + nation1.chiefGeneralId = general.id; + + const definition = new DeclareWarAction(); + const context = { + general, + city: city1, + nation: nation1, + destNation: nation2, + cities: [city1, city2], + nations: [nation1, nation2], + rng: {} as any, + addLog: () => {}, + }; + + const resolution = resolveGeneralAction( + definition, + context as any, + { now: new Date(), schedule }, + { destNationId: 2 } + ); + + expect(resolution.effects).toContainEqual( + expect.objectContaining({ + type: 'diplomacy:patch', + srcNationId: 1, + destNationId: 2, + patch: expect.objectContaining({ state: 1 }), + }) + ); + expect(resolution.logs.some((l) => l.scope === LogScope.SYSTEM && l.category === LogCategory.ACTION)).toBe( + true + ); + expect(resolution.logs.some((l) => l.text.includes('【국메】'))).toBe(true); + }); + + it('fails if not neighbor', () => { + const nation1 = buildNation(1); + const nation2 = buildNation(2); + const city1 = buildCity(1, 1); + const city2 = buildCity(99, 2); // city 99 is not connected to city 1 + + const isolatedMap: MapDefinition = { + id: 'test', + name: 'test', + cities: [ + { + id: 1, + name: 'City1', + connections: [], + level: 1, + region: 1, + position: { x: 0, y: 0 }, + max: {} as any, + initial: {} as any, + }, + { + id: 99, + name: 'City2', + connections: [], + level: 1, + region: 1, + position: { x: 1, y: 1 }, + max: {} as any, + initial: {} as any, + }, + ], + }; + + const definition = new DeclareWarAction(); + const constraints = definition.buildConstraints( + { + actorId: 1, + nationId: 1, + destNationId: 2, + env: { + relYear: 1, + map: isolatedMap, + cities: [city1, city2], + }, + args: { destNationId: 2 }, + mode: 'full', + } as any, + { destNationId: 2 } + ); + + const nearNationConstraint = constraints.find((c) => c.name === 'nearNation'); + expect(nearNationConstraint).toBeDefined(); + + // NearNation needs StateView + const view = { + has: () => true, + get: (req: any) => { + if (req.kind === 'env' && req.key === 'cities') return [city1, city2]; + if (req.kind === 'env' && req.key === 'map') return isolatedMap; + if (req.kind === 'destNation') return nation2; + if (req.kind === 'nation') return nation1; + return null; + }, + }; + + const result = nearNationConstraint!.test( + { + actorId: 1, + nationId: 1, + destNationId: 2, + env: { relYear: 1, map: isolatedMap, cities: [city1, city2] }, + args: { destNationId: 2 }, + mode: 'full', + } as any, + view as any + ); + + expect(result.kind).toBe('deny'); + if (result.kind === 'deny') { + expect(result.reason).toBe('인접 국가가 아닙니다.'); + } + }); + }); + + describe('che_필사즉생 (Last Stand)', () => { + it('increases experience and sets strategic limit', () => { + const nation = buildNation(1); + const general = buildGeneral(1, 1, 1); + const definition = new LastStandAction([]); + + const context = { + general, + nation, + nationGenerals: [general], + addLog: () => {}, + rng: {} as any, + }; + + definition.resolve(context as any, {}); + + expect(general.experience).toBeGreaterThan(100); + expect(nation.meta.strategic_cmd_limit).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/logic/test/scenarios/diplomacy.test.ts b/packages/logic/test/scenarios/diplomacy.test.ts index 01b6b32..03e3cd7 100644 --- a/packages/logic/test/scenarios/diplomacy.test.ts +++ b/packages/logic/test/scenarios/diplomacy.test.ts @@ -181,6 +181,9 @@ describe('Diplomacy Scenario', () => { commandKey: 'che_선전포고', resolver: declareWarDef, args: { destNationId: NATION_B_ID }, + context: { + destNation: mockNationB, + }, }, ]);