From 39e7eddcf7191a5288b37cb247ef9bde9462adc2 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Thu, 15 Jan 2026 15:56:24 +0000 Subject: [PATCH] =?UTF-8?q?=EB=AF=B8=EA=B5=AC=ED=98=84=20=EC=BB=A4?= =?UTF-8?q?=EB=A7=A8=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../actions/instant/nation/che_종전수락.ts | 172 ++++++ .../logic/src/actions/instant/nation/index.ts | 4 + .../src/actions/turn/nation/che_종전제의.ts | 88 +++ .../src/actions/turn/nation/che_초토화.ts | 277 ++++++++++ .../src/actions/turn/nation/che_피장파장.ts | 270 +++++++++ .../src/actions/turn/nation/cr_인구이동.ts | 189 +++++++ .../src/actions/turn/nation/eventResearch.ts | 154 ++++++ .../src/actions/turn/nation/event_극병연구.ts | 11 + .../actions/turn/nation/event_대검병연구.ts | 11 + .../src/actions/turn/nation/event_무희연구.ts | 11 + .../actions/turn/nation/event_산저병연구.ts | 11 + .../src/actions/turn/nation/event_상병연구.ts | 11 + .../actions/turn/nation/event_원융노병연구.ts | 11 + .../actions/turn/nation/event_음귀병연구.ts | 11 + .../actions/turn/nation/event_화륜차연구.ts | 11 + .../actions/turn/nation/event_화시병연구.ts | 11 + .../logic/src/actions/turn/nation/index.ts | 26 + .../actions/instant/nationStopWar.test.ts | 198 +++++++ .../test/actions/turn/nationMissing.test.ts | 519 ++++++++++++++++++ 19 files changed, 1996 insertions(+) create mode 100644 packages/logic/src/actions/instant/nation/che_종전수락.ts create mode 100644 packages/logic/src/actions/turn/nation/che_종전제의.ts create mode 100644 packages/logic/src/actions/turn/nation/che_초토화.ts create mode 100644 packages/logic/src/actions/turn/nation/che_피장파장.ts create mode 100644 packages/logic/src/actions/turn/nation/cr_인구이동.ts create mode 100644 packages/logic/src/actions/turn/nation/eventResearch.ts create mode 100644 packages/logic/src/actions/turn/nation/event_극병연구.ts create mode 100644 packages/logic/src/actions/turn/nation/event_대검병연구.ts create mode 100644 packages/logic/src/actions/turn/nation/event_무희연구.ts create mode 100644 packages/logic/src/actions/turn/nation/event_산저병연구.ts create mode 100644 packages/logic/src/actions/turn/nation/event_상병연구.ts create mode 100644 packages/logic/src/actions/turn/nation/event_원융노병연구.ts create mode 100644 packages/logic/src/actions/turn/nation/event_음귀병연구.ts create mode 100644 packages/logic/src/actions/turn/nation/event_화륜차연구.ts create mode 100644 packages/logic/src/actions/turn/nation/event_화시병연구.ts create mode 100644 packages/logic/test/actions/instant/nationStopWar.test.ts create mode 100644 packages/logic/test/actions/turn/nationMissing.test.ts diff --git a/packages/logic/src/actions/instant/nation/che_종전수락.ts b/packages/logic/src/actions/instant/nation/che_종전수락.ts new file mode 100644 index 0000000..974043b --- /dev/null +++ b/packages/logic/src/actions/instant/nation/che_종전수락.ts @@ -0,0 +1,172 @@ +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + allowDiplomacyBetweenStatus, + beChief, + destGeneralInDestNation, + existsDestGeneral, + existsDestNation, + notBeNeutral, +} from '@sammo-ts/logic/constraints/presets.js'; +import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { 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 { JosaUtil } from '@sammo-ts/common'; + +export interface StopWarAcceptArgs { + destNationId: number; + destGeneralId: number; +} + +const ACTION_NAME = '종전 수락'; +const DIPLOMACY_NEUTRAL = 2; + +const parseNationId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw > 0 ? Math.floor(raw) : null; +}; + +const parseGeneralId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw > 0 ? Math.floor(raw) : null; +}; + +const notSameDestGeneral = (): Constraint => ({ + name: 'NotSameDestGeneral', + requires: () => [{ kind: 'arg', key: 'destGeneralId' }], + test: (ctx) => { + const destGeneralId = ctx.args.destGeneralId; + if (typeof destGeneralId !== 'number') { + return unknownOrDeny(ctx, [{ kind: 'arg', key: 'destGeneralId' }], '장수 정보가 없습니다.'); + } + if (destGeneralId === ctx.actorId) { + return { kind: 'deny', reason: '대상이 올바르지 않습니다.' }; + } + return allow(); + }, +}); + +// 종전 수락은 메시지와 연결되는 즉시 국가 커맨드로 사용한다. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition { + public readonly key = 'che_종전수락'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): StopWarAcceptArgs | null { + const data = raw as { destNationId?: unknown; destGeneralId?: unknown }; + const destNationId = parseNationId(data?.destNationId); + const destGeneralId = parseGeneralId(data?.destGeneralId); + if (destNationId === null || destGeneralId === null) { + return null; + } + return { destNationId, destGeneralId }; + } + + buildConstraints(_ctx: ConstraintContext, _args: StopWarAcceptArgs): Constraint[] { + return [ + beChief(), + notBeNeutral(), + existsDestNation(), + existsDestGeneral(), + destGeneralInDestNation(), + notSameDestGeneral(), + allowDiplomacyBetweenStatus([0, 1], '상대국과 선포, 전쟁중이지 않습니다.'), + ]; + } + + resolve( + context: GeneralActionResolveContext, + args: StopWarAcceptArgs + ): GeneralActionOutcome { + const nationId = context.nation?.id; + if (nationId === undefined || nationId <= 0) { + return { + effects: [ + createLogEffect(`${ACTION_NAME}을 준비했지만 국가 정보가 없습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } + + const nationName = context.nation?.name ?? `국가${nationId}`; + const destNationName = + (context as { destNation?: { name?: string } }).destNation?.name ?? `국가${args.destNationId}`; + const generalName = context.general.name; + const josaYiGeneral = JosaUtil.pick(generalName, '이'); + const josaYiNation = JosaUtil.pick(nationName, '이'); + const josaWa = JosaUtil.pick(destNationName, '와'); + + return { + effects: [ + createDiplomacyPatchEffect(nationId, args.destNationId, { + state: DIPLOMACY_NEUTRAL, + term: 0, + }), + createDiplomacyPatchEffect(args.destNationId, nationId, { + state: DIPLOMACY_NEUTRAL, + term: 0, + }), + createLogEffect(`${destNationName}${josaWa} 종전에 합의했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }), + createLogEffect(`${destNationName}${josaWa} 종전 수락`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + createLogEffect( + `${generalName}${josaYiGeneral} ${destNationName}${josaWa} 종전 합의 하였습니다.`, + { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + } + ), + createLogEffect( + `【종전】${nationName}${josaYiNation} ${destNationName}${josaWa} 종전 합의 하였습니다.`, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ), + createLogEffect(`${destNationName}${josaWa} 종전`, { + scope: LogScope.NATION, + nationId, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + createLogEffect(`${nationName}${josaWa} 종전에 성공했습니다.`, { + scope: LogScope.GENERAL, + generalId: args.destGeneralId, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }), + createLogEffect(`${nationName}${josaWa} 종전 성공`, { + scope: LogScope.GENERAL, + generalId: args.destGeneralId, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + createLogEffect(`${nationName}${josaWa} 종전`, { + scope: LogScope.NATION, + nationId: args.destNationId, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + ], + }; + } +} diff --git a/packages/logic/src/actions/instant/nation/index.ts b/packages/logic/src/actions/instant/nation/index.ts index 5d70a51..218311a 100644 --- a/packages/logic/src/actions/instant/nation/index.ts +++ b/packages/logic/src/actions/instant/nation/index.ts @@ -7,3 +7,7 @@ export { ActionDefinition as NonAggressionCancelAcceptActionDefinition, type NonAggressionCancelAcceptArgs, } from './che_불가침파기수락.js'; +export { + ActionDefinition as StopWarAcceptActionDefinition, + type StopWarAcceptArgs, +} from './che_종전수락.js'; 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..25ccaf1 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_종전제의.ts @@ -0,0 +1,88 @@ +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + allowDiplomacyBetweenStatus, + beChief, + existsDestNation, + 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 { createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { NationTurnCommandSpec } from './index.js'; +import { JosaUtil } from '@sammo-ts/common'; + +export interface StopWarProposalArgs { + destNationId: number; +} + +const ACTION_NAME = '종전 제의'; + +const parseNationId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw > 0 ? Math.floor(raw) : null; +}; + +// 종전 제의를 처리하는 국가 커맨드. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition { + public readonly key = 'che_종전제의'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): StopWarProposalArgs | null { + const data = raw as { destNationId?: unknown }; + const destNationId = parseNationId(data?.destNationId); + if (destNationId === null) { + return null; + } + return { destNationId }; + } + + buildConstraints(_ctx: ConstraintContext, _args: StopWarProposalArgs): Constraint[] { + return [ + beChief(), + notBeNeutral(), + occupiedCity(), + suppliedCity(), + existsDestNation(), + allowDiplomacyBetweenStatus([0, 1], '선포, 전쟁중인 상대국에게만 가능합니다.'), + ]; + } + + resolve( + _context: GeneralActionResolveContext, + args: StopWarProposalArgs + ): GeneralActionOutcome { + const destNationName = + (_context as { destNation?: { name?: string } }).destNation?.name ?? `국가${args.destNationId}`; + const josaRo = JosaUtil.pick(destNationName, '로'); + return { + effects: [ + createLogEffect(`${destNationName}${josaRo} 종전 제의 서신을 보냈습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } +} + +// 예약 턴 실행은 기본 컨텍스트만 사용한다. +export const actionContextBuilder = defaultActionContextBuilder; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_종전제의', + category: '외교', + reqArg: true, + args: { destNationId: 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..fd4f6a8 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_초토화.ts @@ -0,0 +1,277 @@ +import type { City, GeneralTriggerState, Nation, TriggerValue } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; +import { + beChief, + disallowDiplomacyBetweenStatus, + occupiedCity, + occupiedDestCity, + suppliedCity, + suppliedDestCity, +} from '@sammo-ts/logic/constraints/presets.js'; +import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, +} from '@sammo-ts/logic/actions/engine.js'; +import { createCityPatchEffect, 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'; + +export interface ScorchedEarthArgs { + destCityId: number; +} + +export interface ScorchedEarthResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destCity: City; + destNation: Nation | null; +} + +const ACTION_NAME = '초토화'; +const PRE_REQ_TURN = 2; +const POST_REQ_TURN = 24; + +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 requireNoDiplomacyLimit = (): Constraint => ({ + name: 'requireNoDiplomacyLimit', + requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), + test: (ctx: ConstraintContext, view: StateView) => { + const nationId = ctx.nationId; + if (nationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const nation = view.get({ kind: 'nation', id: nationId }) as Nation | null; + if (!nation) { + return unknownOrDeny(ctx, [{ kind: 'nation', id: nationId }], '국가 정보가 없습니다.'); + } + const surlimit = typeof nation.meta?.surlimit === 'number' ? Number(nation.meta.surlimit) : 0; + if (surlimit > 0) { + return { kind: 'deny', reason: '외교제한 턴이 남아있습니다.' }; + } + return allow(); + }, +}); + +const notCapitalCity = (destCityId: number): Constraint => ({ + name: 'notCapitalCity', + requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), + test: (ctx: ConstraintContext, view: StateView) => { + const nationId = ctx.nationId; + if (nationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const nation = view.get({ kind: 'nation', id: nationId }) as Nation | null; + if (!nation) { + return unknownOrDeny(ctx, [{ kind: 'nation', id: nationId }], '국가 정보가 없습니다.'); + } + if (nation.capitalCityId === destCityId) { + return { kind: 'deny', reason: '수도입니다.' }; + } + return allow(); + }, +}); + +const calcReturnAmount = (destCity: City): number => { + let amount = destCity.population / 5; + const resourcePairs: Array<[number, number]> = [ + [destCity.agriculture, destCity.agricultureMax], + [destCity.commerce, destCity.commerceMax], + [destCity.security, destCity.securityMax], + ]; + for (const [current, max] of resourcePairs) { + if (max <= 0) { + continue; + } + amount *= (current - max * 0.5) / max + 0.8; + } + return Math.max(0, Math.floor(amount)); +}; + +const calcReducedValue = (current: number, max: number, ratio: number): number => { + if (max <= 0) { + return Math.max(0, Math.floor(current * ratio)); + } + return Math.max(Math.floor(max * 0.1), Math.floor(current * ratio)); +}; + +// 초토화 실행을 위한 정의/제약을 구성한다. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = 'che_초토화'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): ScorchedEarthArgs | null { + const data = raw as { destCityId?: unknown }; + const destCityId = parseCityId(data?.destCityId); + if (destCityId === null) { + return null; + } + return { destCityId }; + } + + buildConstraints(_ctx: ConstraintContext, args: ScorchedEarthArgs): Constraint[] { + void _ctx; + return [ + occupiedCity(), + occupiedDestCity(), + beChief(), + suppliedCity(), + suppliedDestCity(), + notCapitalCity(args.destCityId), + requireNoDiplomacyLimit(), + disallowDiplomacyBetweenStatus({ + 0: '평시에만 가능합니다.', + }), + ]; + } + + resolve( + context: ScorchedEarthResolveContext, + _args: ScorchedEarthArgs + ): GeneralActionOutcome { + void _args; + const { general, nation, destCity } = context; + if (!nation) { + return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; + } + + const generalName = general.name; + const nationName = nation.name; + const destCityName = destCity.name; + const josaUl = JosaUtil.pick(destCityName, '을'); + const josaYi = JosaUtil.pick(generalName, '이'); + const josaYiNation = JosaUtil.pick(nationName, '이'); + + const rewardAmount = calcReturnAmount(destCity); + const nextSurlimit = Number(nation.meta.surlimit ?? 0) + POST_REQ_TURN; + const nextAux: Record = { + ...nation.meta, + surlimit: nextSurlimit, + }; + + if (destCity.level >= 8) { + const current = typeof nation.meta.did_특성초토화 === 'number' ? Number(nation.meta.did_특성초토화) : 0; + nextAux.did_특성초토화 = current + 1; + } + + const reducedPopulation = calcReducedValue(destCity.population, destCity.populationMax, 0.2); + const reducedAgri = calcReducedValue(destCity.agriculture, destCity.agricultureMax, 0.2); + const reducedComm = calcReducedValue(destCity.commerce, destCity.commerceMax, 0.2); + const reducedSecu = calcReducedValue(destCity.security, destCity.securityMax, 0.2); + const reducedDef = calcReducedValue(destCity.defence, destCity.defenceMax, 0.2); + const reducedWall = Math.max(Math.floor(destCity.wallMax * 0.1), Math.floor(destCity.wall * 0.5)); + const trust = typeof destCity.meta?.trust === 'number' ? Number(destCity.meta.trust) : 0; + + const expGain = 5 * (PRE_REQ_TURN + 1); + general.experience = Math.max(0, Math.floor(general.experience * 0.9)) + expGain; + general.dedication += expGain; + + const effects: Array> = [ + createCityPatchEffect( + { + nationId: 0, + frontState: 0, + population: reducedPopulation, + agriculture: reducedAgri, + commerce: reducedComm, + security: reducedSecu, + defence: reducedDef, + wall: reducedWall, + meta: { + ...destCity.meta, + trust: Math.max(50, trust), + }, + }, + destCity.id + ), + createNationPatchEffect( + { + gold: nation.gold + rewardAmount, + rice: nation.rice + rewardAmount, + meta: nextAux, + }, + nation.id + ), + createLogEffect(`${destCityName}${josaUl} ${ACTION_NAME}했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + createLogEffect(`${destCityName}${josaUl} ${ACTION_NAME} 명령`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + createLogEffect( + `${generalName}${josaYi} ${destCityName}${josaUl} ${ACTION_NAME} 명령`, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ), + createLogEffect( + `${generalName}${josaYi} ${destCityName}${josaUl} ${ACTION_NAME}하였습니다.`, + { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + } + ), + createLogEffect( + `【${ACTION_NAME}】${nationName}${josaYiNation} ${destCityName}${josaUl} ${ACTION_NAME}하였습니다.`, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ), + ]; + + 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); + if (!destCity) { + return null; + } + const destNation = worldRef.getNationById(destCity.nationId); + return { + ...base, + destCity, + destNation: destNation ?? null, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_초토화', + category: '특수', + reqArg: true, + args: { destCityId: 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..301278a --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_피장파장.ts @@ -0,0 +1,270 @@ +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 { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.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 { 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 CounterStrategyArgs { + destNationId: number; + commandType: string; +} + +export interface CounterStrategyResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destNation: Nation; + friendlyGenerals: Array>; + destNationGenerals: Array>; +} + +const ACTION_NAME = '피장파장'; +const DEFAULT_GLOBAL_DELAY = 8; +const PRE_REQ_TURN = 1; +const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); + +const STRATEGIC_COMMANDS: Record = { + che_필사즉생: '필사즉생', + che_백성동원: '백성동원', + che_수몰: '수몰', + che_허보: '허보', + che_의병모집: '의병모집', + che_이호경식: '이호경식', + che_급습: '급습', + che_피장파장: '피장파장', +}; + +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 parseCommandType = (raw: unknown): string | null => { + if (typeof raw !== 'string') { + return null; + } + if (!Object.prototype.hasOwnProperty.call(STRATEGIC_COMMANDS, raw)) { + return null; + } + if (raw === 'che_피장파장') { + return null; + } + return raw; +}; + +const requireCommandType = (): Constraint => ({ + name: 'requireStrategicCommandType', + requires: () => [{ kind: 'arg', key: 'commandType' }], + test: (ctx) => { + const commandType = ctx.args.commandType; + if (typeof commandType !== 'string') { + return unknownOrDeny(ctx, [{ kind: 'arg', key: 'commandType' }], '전략 정보가 없습니다.'); + } + if (!Object.prototype.hasOwnProperty.call(STRATEGIC_COMMANDS, commandType)) { + return { kind: 'deny', reason: '전략 정보가 올바르지 않습니다.' }; + } + if (commandType === 'che_피장파장') { + return { kind: 'deny', reason: '같은 전략은 선택할 수 없습니다.' }; + } + return allow(); + }, +}); + +// 피장파장 실행 결과를 계산한다. +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionResolver { + readonly key = 'che_피장파장'; + + resolve( + context: CounterStrategyResolveContext, + args: CounterStrategyArgs + ): GeneralActionOutcome { + const { general, nation, destNation } = context; + const generalName = general.name; + const generalJosa = JosaUtil.pick(generalName, '이'); + const nationName = nation?.name ?? '아국'; + const destNationName = destNation.name; + const targetCommandName = STRATEGIC_COMMANDS[args.commandType] ?? args.commandType; + const actionJosa = JosaUtil.pick(ACTION_NAME, '을'); + + general.experience += EXP_DED_GAIN; + general.dedication += EXP_DED_GAIN; + + context.addLog(`${targetCommandName} 전략의 ${ACTION_NAME} 발동!`, { + format: LogFormat.MONTH, + }); + context.addLog( + `${destNationName}${targetCommandName} ${ACTION_NAME}${actionJosa} 발동`, + { + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ); + + const effects: Array> = []; + const broadcastMessage = `${generalName}${generalJosa} ${destNationName}${targetCommandName} 전략의 ${ACTION_NAME}${actionJosa} 발동하였습니다.`; + const destBroadcastMessage = `아국에 ${targetCommandName} 전략의 ${ACTION_NAME}${JosaUtil.pick( + ACTION_NAME, + '이' + )} 발동되었습니다.`; + + 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, + }) + ); + } + + if (nation) { + const globalDelay = DEFAULT_GLOBAL_DELAY; + 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} 아국에 ${targetCommandName} ${ACTION_NAME}${actionJosa} 발동`, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: destNation.id, + format: LogFormat.PLAIN, + } + ) + ); + + if (nation?.id !== undefined) { + effects.push( + createLogEffect( + `${generalName}${generalJosa} ${destNationName}${targetCommandName} ${ACTION_NAME}${actionJosa} 발동`, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: nation.id, + format: LogFormat.YEAR_MONTH, + } + ) + ); + } + + return { effects }; + } +} + +// 피장파장 실행을 위한 정의/제약을 구성한다. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = 'che_피장파장'; + public readonly name = ACTION_NAME; + private readonly resolver = new ActionResolver(); + + parseArgs(raw: unknown): CounterStrategyArgs | null { + const data = raw as { destNationId?: unknown; commandType?: unknown }; + const destNationId = parseNationId(data?.destNationId); + const commandType = parseCommandType(data?.commandType); + if (destNationId === null || commandType === null) { + return null; + } + return { destNationId, commandType }; + } + + buildConstraints(_ctx: ConstraintContext, _args: CounterStrategyArgs): Constraint[] { + void _ctx; + void _args; + return [ + occupiedCity(), + beChief(), + existsDestNation(), + allowDiplomacyBetweenStatus([0, 1], '선포, 전쟁중인 상대국에게만 가능합니다.'), + availableStrategicCommand(), + requireCommandType(), + ]; + } + + resolve(context: CounterStrategyResolveContext, args: CounterStrategyArgs): 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 generals = worldRef.listGenerals(); + const friendlyGenerals = generals.filter((general) => general.nationId === base.general.nationId); + const destNationGenerals = generals.filter((general) => general.nationId === destNationId); + return { + ...base, + destNation, + friendlyGenerals, + destNationGenerals, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_피장파장', + category: '전략', + reqArg: true, + args: { destNationId: 0, commandType: '' }, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/nation/cr_인구이동.ts b/packages/logic/src/actions/turn/nation/cr_인구이동.ts new file mode 100644 index 0000000..37219a9 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/cr_인구이동.ts @@ -0,0 +1,189 @@ +import type { City, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + beChief, + nearCity, + notSameDestCity, + occupiedCity, + occupiedDestCity, + reqCityCapacity, + reqNationGold, + reqNationRice, + 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 { createCityPatchEffect, 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 PopulationMoveArgs { + destCityId: number; + amount: number; +} + +export interface PopulationMoveResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destCity: City; + destNation: Nation | null; +} + +const ACTION_NAME = '인구이동'; +const AMOUNT_LIMIT = 100000; +const MIN_AVAILABLE_RECRUIT_POP = 30000; + +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 parseAmount = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + const value = Math.floor(raw); + if (value < 0) { + return null; + } + return clamp(value, 0, AMOUNT_LIMIT); +}; + +const calcCost = (develCost: number, amount: number): number => Math.round((develCost * amount) / 10000); + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = 'cr_인구이동'; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) {} + + parseArgs(raw: unknown): PopulationMoveArgs | null { + const data = raw as { destCityId?: unknown; amount?: unknown }; + const destCityId = parseCityId(data?.destCityId); + const amount = parseAmount(data?.amount); + if (destCityId === null || amount === null) { + return null; + } + return { destCityId, amount }; + } + + buildConstraints(_ctx: ConstraintContext, args: PopulationMoveArgs): Constraint[] { + const cost = calcCost(this.env.develCost, args.amount); + return [ + notSameDestCity(), + occupiedCity(), + reqCityCapacity('population', '주민', MIN_AVAILABLE_RECRUIT_POP + 100), + occupiedDestCity(), + nearCity(1), + beChief(), + suppliedCity(), + suppliedDestCity(), + reqNationGold(() => this.env.baseGold + cost), + reqNationRice(() => this.env.baseRice + cost), + ]; + } + + resolve( + context: PopulationMoveResolveContext, + args: PopulationMoveArgs + ): GeneralActionOutcome { + const { general, nation, city, destCity } = context; + if (!nation || !city) { + return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; + } + + const available = Math.max(0, city.population - MIN_AVAILABLE_RECRUIT_POP); + const amount = clamp(args.amount, 0, available); + if (amount <= 0) { + return { + effects: [ + createLogEffect('이동할 인구가 부족합니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } + + const cost = calcCost(this.env.develCost, amount); + const amountText = amount.toLocaleString(); + const destCityName = destCity.name; + const josaRo = JosaUtil.pick(destCityName, '로'); + + general.experience += 5; + general.dedication += 5; + + const effects: Array> = [ + createCityPatchEffect( + { + population: destCity.population + amount, + }, + destCity.id + ), + createCityPatchEffect( + { + population: city.population - amount, + }, + city.id + ), + createNationPatchEffect( + { + gold: nation.gold - cost, + rice: nation.rice - cost, + }, + nation.id + ), + createLogEffect(`${destCityName}${josaRo} 인구 ${amountText}명을 옮겼습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ]; + + 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); + if (!destCity) { + return null; + } + const destNation = worldRef.getNationById(destCity.nationId); + return { + ...base, + destCity, + destNation: destNation ?? null, + }; +}; + +export const commandSpec: NationTurnCommandSpec = { + key: 'cr_인구이동', + category: '특수', + reqArg: true, + args: { destCityId: 0, amount: 0 }, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), +}; diff --git a/packages/logic/src/actions/turn/nation/eventResearch.ts b/packages/logic/src/actions/turn/nation/eventResearch.ts new file mode 100644 index 0000000..b959cd7 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/eventResearch.ts @@ -0,0 +1,154 @@ +import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; +import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js'; +import { beChief, occupiedCity, reqNationGold, reqNationRice } 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 { 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 { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; +import { JosaUtil } from '@sammo-ts/common'; + +export interface EventResearchConfig { + key: string; + name: string; + auxKey: string; + preReqTurn: number; + cost: number; + category?: string; +} + +const requireNationAux = (auxKey: string, actionName: string): Constraint => ({ + name: 'requireNationAux', + requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), + test: (ctx: ConstraintContext, view: StateView) => { + if (ctx.nationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const nation = view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null; + if (!nation) { + return unknownOrDeny(ctx, [{ kind: 'nation', id: ctx.nationId }], '국가 정보가 없습니다.'); + } + const current = typeof nation.meta?.[auxKey] === 'number' ? Number(nation.meta?.[auxKey]) : 0; + if (current >= 1) { + return { kind: 'deny', reason: `${actionName}가 이미 완료되었습니다.` }; + } + return allow(); + }, +}); + +export const createEventResearchCommand = (config: EventResearchConfig): { + ActionDefinition: new ( + env: TurnCommandEnv + ) => GeneralActionDefinition>; + commandSpec: NationTurnCommandSpec; + actionContextBuilder: ActionContextBuilder; +} => { + const ACTION_NAME = config.name; + const COST = config.cost; + const PRE_REQ_TURN = config.preReqTurn; + const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); + const CATEGORY = config.category ?? '특수'; + + class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, + > implements GeneralActionDefinition> { + public readonly key = config.key; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) {} + + parseArgs(_raw: unknown): Record | null { + void _raw; + return {}; + } + + buildConstraints(_ctx: ConstraintContext, _args: Record): Constraint[] { + void _ctx; + void _args; + return [ + occupiedCity(), + beChief(), + requireNationAux(config.auxKey, ACTION_NAME), + reqNationGold(() => this.env.baseGold + COST), + reqNationRice(() => this.env.baseRice + COST), + ]; + } + + resolve( + context: GeneralActionResolveContext, + _args: Record + ): GeneralActionOutcome { + void _args; + const { general, nation } = context; + if (!nation) { + return { + effects: [ + createLogEffect('국가 정보가 없습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } + + general.experience += EXP_DED_GAIN; + general.dedication += EXP_DED_GAIN; + + const generalName = general.name; + const josaYi = JosaUtil.pick(generalName, '이'); + + return { + effects: [ + createNationPatchEffect( + { + gold: nation.gold - COST, + rice: nation.rice - COST, + meta: { + ...nation.meta, + [config.auxKey]: 1, + }, + }, + nation.id + ), + createLogEffect(`${ACTION_NAME} 완료`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + createLogEffect(`${ACTION_NAME} 완료`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + createLogEffect(`${generalName}${josaYi} ${ACTION_NAME} 완료`, { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + ], + }; + } + } + + const commandSpec: NationTurnCommandSpec = { + key: config.key as NationTurnCommandSpec['key'], + category: CATEGORY, + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), + }; + + return { + ActionDefinition, + commandSpec, + actionContextBuilder: defaultActionContextBuilder, + }; +}; diff --git a/packages/logic/src/actions/turn/nation/event_극병연구.ts b/packages/logic/src/actions/turn/nation/event_극병연구.ts new file mode 100644 index 0000000..caa825b --- /dev/null +++ b/packages/logic/src/actions/turn/nation/event_극병연구.ts @@ -0,0 +1,11 @@ +import { createEventResearchCommand } from './eventResearch.js'; + +const { ActionDefinition, commandSpec, actionContextBuilder } = createEventResearchCommand({ + key: 'event_극병연구', + name: '극병 연구', + auxKey: 'can_극병사용', + preReqTurn: 23, + cost: 100000, +}); + +export { ActionDefinition, commandSpec, actionContextBuilder }; diff --git a/packages/logic/src/actions/turn/nation/event_대검병연구.ts b/packages/logic/src/actions/turn/nation/event_대검병연구.ts new file mode 100644 index 0000000..eb4ecb3 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/event_대검병연구.ts @@ -0,0 +1,11 @@ +import { createEventResearchCommand } from './eventResearch.js'; + +const { ActionDefinition, commandSpec, actionContextBuilder } = createEventResearchCommand({ + key: 'event_대검병연구', + name: '대검병 연구', + auxKey: 'can_대검병사용', + preReqTurn: 11, + cost: 50000, +}); + +export { ActionDefinition, commandSpec, actionContextBuilder }; diff --git a/packages/logic/src/actions/turn/nation/event_무희연구.ts b/packages/logic/src/actions/turn/nation/event_무희연구.ts new file mode 100644 index 0000000..b75977f --- /dev/null +++ b/packages/logic/src/actions/turn/nation/event_무희연구.ts @@ -0,0 +1,11 @@ +import { createEventResearchCommand } from './eventResearch.js'; + +const { ActionDefinition, commandSpec, actionContextBuilder } = createEventResearchCommand({ + key: 'event_무희연구', + name: '무희 연구', + auxKey: 'can_무희사용', + preReqTurn: 23, + cost: 100000, +}); + +export { ActionDefinition, commandSpec, actionContextBuilder }; diff --git a/packages/logic/src/actions/turn/nation/event_산저병연구.ts b/packages/logic/src/actions/turn/nation/event_산저병연구.ts new file mode 100644 index 0000000..3ab6acc --- /dev/null +++ b/packages/logic/src/actions/turn/nation/event_산저병연구.ts @@ -0,0 +1,11 @@ +import { createEventResearchCommand } from './eventResearch.js'; + +const { ActionDefinition, commandSpec, actionContextBuilder } = createEventResearchCommand({ + key: 'event_산저병연구', + name: '산저병 연구', + auxKey: 'can_산저병사용', + preReqTurn: 11, + cost: 50000, +}); + +export { ActionDefinition, commandSpec, actionContextBuilder }; diff --git a/packages/logic/src/actions/turn/nation/event_상병연구.ts b/packages/logic/src/actions/turn/nation/event_상병연구.ts new file mode 100644 index 0000000..6b88b52 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/event_상병연구.ts @@ -0,0 +1,11 @@ +import { createEventResearchCommand } from './eventResearch.js'; + +const { ActionDefinition, commandSpec, actionContextBuilder } = createEventResearchCommand({ + key: 'event_상병연구', + name: '상병 연구', + auxKey: 'can_상병사용', + preReqTurn: 23, + cost: 100000, +}); + +export { ActionDefinition, commandSpec, actionContextBuilder }; diff --git a/packages/logic/src/actions/turn/nation/event_원융노병연구.ts b/packages/logic/src/actions/turn/nation/event_원융노병연구.ts new file mode 100644 index 0000000..a1f17d8 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/event_원융노병연구.ts @@ -0,0 +1,11 @@ +import { createEventResearchCommand } from './eventResearch.js'; + +const { ActionDefinition, commandSpec, actionContextBuilder } = createEventResearchCommand({ + key: 'event_원융노병연구', + name: '원융노병 연구', + auxKey: 'can_원융노병사용', + preReqTurn: 23, + cost: 100000, +}); + +export { ActionDefinition, commandSpec, actionContextBuilder }; diff --git a/packages/logic/src/actions/turn/nation/event_음귀병연구.ts b/packages/logic/src/actions/turn/nation/event_음귀병연구.ts new file mode 100644 index 0000000..232f646 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/event_음귀병연구.ts @@ -0,0 +1,11 @@ +import { createEventResearchCommand } from './eventResearch.js'; + +const { ActionDefinition, commandSpec, actionContextBuilder } = createEventResearchCommand({ + key: 'event_음귀병연구', + name: '음귀병 연구', + auxKey: 'can_음귀병사용', + preReqTurn: 11, + cost: 50000, +}); + +export { ActionDefinition, commandSpec, actionContextBuilder }; diff --git a/packages/logic/src/actions/turn/nation/event_화륜차연구.ts b/packages/logic/src/actions/turn/nation/event_화륜차연구.ts new file mode 100644 index 0000000..8c1b9b9 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/event_화륜차연구.ts @@ -0,0 +1,11 @@ +import { createEventResearchCommand } from './eventResearch.js'; + +const { ActionDefinition, commandSpec, actionContextBuilder } = createEventResearchCommand({ + key: 'event_화륜차연구', + name: '화륜차 연구', + auxKey: 'can_화륜차사용', + preReqTurn: 23, + cost: 100000, +}); + +export { ActionDefinition, commandSpec, actionContextBuilder }; diff --git a/packages/logic/src/actions/turn/nation/event_화시병연구.ts b/packages/logic/src/actions/turn/nation/event_화시병연구.ts new file mode 100644 index 0000000..74f6708 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/event_화시병연구.ts @@ -0,0 +1,11 @@ +import { createEventResearchCommand } from './eventResearch.js'; + +const { ActionDefinition, commandSpec, actionContextBuilder } = createEventResearchCommand({ + key: 'event_화시병연구', + name: '화시병 연구', + auxKey: 'can_화시병사용', + preReqTurn: 11, + cost: 50000, +}); + +export { ActionDefinition, commandSpec, actionContextBuilder }; diff --git a/packages/logic/src/actions/turn/nation/index.ts b/packages/logic/src/actions/turn/nation/index.ts index 74c15d3..6aaf8aa 100644 --- a/packages/logic/src/actions/turn/nation/index.ts +++ b/packages/logic/src/actions/turn/nation/index.ts @@ -6,6 +6,7 @@ export const NATION_TURN_COMMAND_KEYS = [ 'che_부대탈퇴지시', 'che_발령', 'che_선전포고', + 'che_종전제의', 'che_불가침제의', 'che_불가침파기제의', 'che_의병모집', @@ -15,14 +16,26 @@ export const NATION_TURN_COMMAND_KEYS = [ 'che_이호경식', 'che_수몰', 'che_급습', + 'che_피장파장', + 'che_초토화', 'che_천도', 'che_국호변경', 'che_무작위수도이전', 'che_국기변경', 'che_증축', 'che_감축', + 'cr_인구이동', 'che_몰수', 'che_물자원조', + 'event_원융노병연구', + 'event_화시병연구', + 'event_음귀병연구', + 'event_대검병연구', + 'event_화륜차연구', + 'event_산저병연구', + 'event_극병연구', + 'event_상병연구', + 'event_무희연구', ] as const; export type NationTurnCommandKey = (typeof NATION_TURN_COMMAND_KEYS)[number]; @@ -39,6 +52,7 @@ 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'), @@ -48,14 +62,26 @@ 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'), + cr_인구이동: async () => import('./cr_인구이동.js'), che_몰수: async () => import('./che_몰수.js'), che_물자원조: async () => import('./che_물자원조.js'), + event_원융노병연구: async () => import('./event_원융노병연구.js'), + event_화시병연구: async () => import('./event_화시병연구.js'), + event_음귀병연구: async () => import('./event_음귀병연구.js'), + event_대검병연구: async () => import('./event_대검병연구.js'), + event_화륜차연구: async () => import('./event_화륜차연구.js'), + event_산저병연구: async () => import('./event_산저병연구.js'), + event_극병연구: async () => import('./event_극병연구.js'), + event_상병연구: async () => import('./event_상병연구.js'), + event_무희연구: async () => import('./event_무희연구.js'), }; export const isNationTurnCommandKey = (value: string): value is NationTurnCommandKey => diff --git a/packages/logic/test/actions/instant/nationStopWar.test.ts b/packages/logic/test/actions/instant/nationStopWar.test.ts new file mode 100644 index 0000000..c6fca26 --- /dev/null +++ b/packages/logic/test/actions/instant/nationStopWar.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import type { City, General, Nation } from '../../../src/domain/entities.js'; +import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js'; +import { evaluateConstraints } from '../../../src/constraints/evaluate.js'; +import { resolveGeneralAction } from '../../../src/actions/engine.js'; +import type { TurnSchedule } from '../../../src/turn/calendar.js'; +import { ActionDefinition as StopWarAcceptAction } from '../../../src/actions/instant/nation/che_종전수락.js'; + +class TestStateView implements StateView { + private readonly store = new Map(); + + has(req: RequirementKey): boolean { + return this.store.has(this.getKey(req)); + } + + get(req: RequirementKey): unknown | null { + return this.store.get(this.getKey(req)) ?? null; + } + + set(req: RequirementKey, value: unknown): void { + this.store.set(this.getKey(req), value); + } + + private getKey(req: RequirementKey): string { + switch (req.kind) { + case 'general': + return `general:${req.id}`; + case 'city': + return `city:${req.id}`; + case 'nation': + return `nation:${req.id}`; + case 'destGeneral': + return `destGeneral:${req.id}`; + case 'destNation': + return `destNation:${req.id}`; + case 'diplomacy': + return `diplomacy:${req.srcNationId}:${req.destNationId}`; + case 'arg': + return `arg:${req.key}`; + case 'env': + return `env:${req.key}`; + default: + return 'unknown'; + } + } +} + +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, + gold: 5000, + rice: 5000, + power: 0, + level: 1, + typeCode: 'test', + meta: {}, +}); + +const schedule: TurnSchedule = { + entries: [{ startMinute: 0, tickMinutes: 60 }], +}; + +describe('Nation Instant Actions', () => { + it('che_종전수락: blocks when not at war/declare', () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + const destNation = buildNation(2); + const destGeneral = buildGeneral(2, 2, 2, 'Dest'); + const city = buildCity(1, 1); + const view = new TestStateView(); + view.set({ kind: 'general', id: general.id }, general); + view.set({ kind: 'city', id: city.id }, city); + view.set({ kind: 'nation', id: nation.id }, nation); + view.set({ kind: 'destNation', id: destNation.id }, destNation); + view.set({ kind: 'destGeneral', id: destGeneral.id }, destGeneral); + view.set( + { + kind: 'diplomacy', + srcNationId: nation.id, + destNationId: destNation.id, + }, + { state: 2, term: 0 } + ); + + const definition = new StopWarAcceptAction(); + const args = { destNationId: destNation.id, destGeneralId: destGeneral.id }; + const ctx: ConstraintContext = { + actorId: general.id, + nationId: nation.id, + cityId: city.id, + destNationId: destNation.id, + destGeneralId: destGeneral.id, + args, + env: {}, + mode: 'full', + }; + + const result = evaluateConstraints(definition.buildConstraints(ctx, args), ctx, view); + expect(result.kind).toBe('deny'); + }); + + it('che_종전수락: patches diplomacy to neutral', () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + const city = buildCity(1, 1); + const destNation = buildNation(2); + + const definition = new StopWarAcceptAction(); + const resolution = resolveGeneralAction( + definition, + { + general, + city, + nation, + rng: {} as any, + addLog: () => {}, + } as any, + { now: new Date(), schedule }, + { destNationId: destNation.id, destGeneralId: 2 } + ); + + const patches = resolution.effects.filter((effect) => effect.type === 'diplomacy:patch'); + expect(patches).toHaveLength(2); + for (const patch of patches) { + expect(patch.patch.state).toBe(2); + expect(patch.patch.term).toBe(0); + } + }); +}); diff --git a/packages/logic/test/actions/turn/nationMissing.test.ts b/packages/logic/test/actions/turn/nationMissing.test.ts new file mode 100644 index 0000000..8a38860 --- /dev/null +++ b/packages/logic/test/actions/turn/nationMissing.test.ts @@ -0,0 +1,519 @@ +import { describe, expect, it } from 'vitest'; +import type { City, General, Nation } from '../../../src/domain/entities.js'; +import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js'; +import { evaluateConstraints } from '../../../src/constraints/evaluate.js'; +import { resolveGeneralAction } from '../../../src/actions/engine.js'; +import type { MapDefinition } from '../../../src/world/types.js'; +import type { TurnSchedule } from '../../../src/turn/calendar.js'; +import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js'; +import { ActionDefinition as StopWarProposalAction } from '../../../src/actions/turn/nation/che_종전제의.js'; +import { ActionDefinition as ScorchedEarthAction } from '../../../src/actions/turn/nation/che_초토화.js'; +import { ActionDefinition as CounterStrategyAction } from '../../../src/actions/turn/nation/che_피장파장.js'; +import { ActionDefinition as PopulationMoveAction } from '../../../src/actions/turn/nation/cr_인구이동.js'; +import { ActionDefinition as EventWonyungAction } from '../../../src/actions/turn/nation/event_원융노병연구.js'; +import { ActionDefinition as EventHwasibyeongAction } from '../../../src/actions/turn/nation/event_화시병연구.js'; +import { ActionDefinition as EventEumgwiAction } from '../../../src/actions/turn/nation/event_음귀병연구.js'; +import { ActionDefinition as EventDaegeomAction } from '../../../src/actions/turn/nation/event_대검병연구.js'; +import { ActionDefinition as EventHwarunAction } from '../../../src/actions/turn/nation/event_화륜차연구.js'; +import { ActionDefinition as EventSanjeoAction } from '../../../src/actions/turn/nation/event_산저병연구.js'; +import { ActionDefinition as EventGeukAction } from '../../../src/actions/turn/nation/event_극병연구.js'; +import { ActionDefinition as EventSangAction } from '../../../src/actions/turn/nation/event_상병연구.js'; +import { ActionDefinition as EventMuheeAction } from '../../../src/actions/turn/nation/event_무희연구.js'; + +class TestStateView implements StateView { + private readonly store = new Map(); + + has(req: RequirementKey): boolean { + return this.store.has(this.getKey(req)); + } + + get(req: RequirementKey): unknown | null { + return this.store.get(this.getKey(req)) ?? null; + } + + set(req: RequirementKey, value: unknown): void { + this.store.set(this.getKey(req), value); + } + + private getKey(req: RequirementKey): string { + switch (req.kind) { + case 'general': + return `general:${req.id}`; + case 'generalList': + return 'general:list'; + case 'city': + return `city:${req.id}`; + case 'nation': + return `nation:${req.id}`; + case 'destGeneral': + return `destGeneral:${req.id}`; + case 'destCity': + return `destCity:${req.id}`; + case 'destNation': + 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': + return `env:${req.key}`; + default: + return 'unknown'; + } + } +} + +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, name = `City${id}`): City => ({ + id, + name, + nationId, + level: 2, + state: 0, + population: 60000, + populationMax: 100000, + agriculture: 1000, + agricultureMax: 2000, + commerce: 1000, + commerceMax: 2000, + security: 1000, + securityMax: 2000, + supplyState: 1, + frontState: 0, + defence: 400, + defenceMax: 800, + wall: 300, + wallMax: 600, + meta: {}, +}); + +const buildNation = (id: number, name = 'Nation'): Nation => ({ + id, + name: `${name}${id}`, + color: '#000000', + capitalCityId: id, + chiefGeneralId: id, + gold: 5000, + rice: 5000, + power: 0, + level: 1, + typeCode: 'test', + meta: { + tech: 1000, + strategic_cmd_limit: 0, + surlimit: 0, + }, +}); + +const buildMap = (fromId: number, toId: number): MapDefinition => ({ + id: 'test', + name: 'test', + cities: [ + { + id: fromId, + name: `City${fromId}`, + connections: [toId], + level: 1, + region: 1, + position: { x: 0, y: 0 }, + max: {} as any, + initial: {} as any, + }, + { + id: toId, + name: `City${toId}`, + connections: [fromId], + level: 1, + region: 1, + position: { x: 1, y: 1 }, + max: {} as any, + initial: {} as any, + }, + ], +}); + +const schedule: TurnSchedule = { + entries: [{ startMinute: 0, tickMinutes: 60 }], +}; + +const buildEnv = (): TurnCommandEnv => ({ + develCost: 1000, + trainDelta: 0, + atmosDelta: 0, + maxTrainByCommand: 0, + maxAtmosByCommand: 0, + sabotageDefaultProb: 0, + sabotageProbCoefByStat: 0, + sabotageDefenceCoefByGeneralCount: 0, + sabotageDamageMin: 0, + sabotageDamageMax: 0, + openingPartYear: 0, + maxGeneral: 0, + defaultNpcGold: 0, + defaultNpcRice: 0, + defaultCrewTypeId: 0, + defaultSpecialDomestic: null, + defaultSpecialWar: null, + initialNationGenLimit: 10, + maxTechLevel: 0, + baseGold: 0, + baseRice: 0, + maxResourceActionAmount: 100000, +}); + +const setupDiplomacy = ( + view: TestStateView, + srcNationId: number, + destNationId: number, + state: number, + term = 0 +) => { + view.set( + { + kind: 'diplomacy', + srcNationId, + destNationId, + }, + { + state, + term, + } + ); +}; + +describe('Nation Missing Actions', () => { + it('che_종전제의: blocks when not at war/declare', () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + const destNation = buildNation(2); + const city = buildCity(1, 1); + const view = new TestStateView(); + view.set({ kind: 'general', id: general.id }, general); + view.set({ kind: 'city', id: city.id }, city); + view.set({ kind: 'nation', id: nation.id }, nation); + view.set({ kind: 'destNation', id: destNation.id }, destNation); + setupDiplomacy(view, nation.id, destNation.id, 2); + + const definition = new StopWarProposalAction(); + const args = { destNationId: destNation.id }; + const ctx: ConstraintContext = { + actorId: general.id, + nationId: nation.id, + cityId: city.id, + destNationId: destNation.id, + args, + env: {}, + mode: 'full', + }; + + const result = evaluateConstraints(definition.buildConstraints(ctx, args), ctx, view); + expect(result.kind).toBe('deny'); + }); + + it('che_종전제의: emits proposal log', () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + const city = buildCity(1, 1); + const destNation = buildNation(2); + + const definition = new StopWarProposalAction(); + const resolution = resolveGeneralAction( + definition, + { + general, + city, + nation, + rng: {} as any, + addLog: () => {}, + } as any, + { now: new Date(), schedule }, + { destNationId: destNation.id } + ); + + expect(resolution.logs.some((log) => log.text.includes('종전 제의'))).toBe(true); + }); + + it('che_피장파장: blocks when not at war/declare', () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + const destNation = buildNation(2); + const city = buildCity(1, 1); + const view = new TestStateView(); + view.set({ kind: 'general', id: general.id }, general); + view.set({ kind: 'city', id: city.id }, city); + view.set({ kind: 'nation', id: nation.id }, nation); + view.set({ kind: 'destNation', id: destNation.id }, destNation); + setupDiplomacy(view, nation.id, destNation.id, 2); + + const definition = new CounterStrategyAction(); + const args = { destNationId: destNation.id, commandType: 'che_허보' }; + const ctx: ConstraintContext = { + actorId: general.id, + nationId: nation.id, + cityId: city.id, + destNationId: destNation.id, + args, + env: {}, + mode: 'full', + }; + + const result = evaluateConstraints(definition.buildConstraints(ctx, args), ctx, view); + expect(result.kind).toBe('deny'); + }); + + it('che_피장파장: applies strategic delay and experience gain', () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + const destNation = buildNation(2); + const city = buildCity(1, 1); + const otherGeneral = buildGeneral(2, 1, 1, 'Ally'); + const enemyGeneral = buildGeneral(3, 2, 2, 'Enemy'); + + const definition = new CounterStrategyAction(); + const resolution = resolveGeneralAction( + definition, + { + general, + city, + nation, + destNation, + friendlyGenerals: [general, otherGeneral], + destNationGenerals: [enemyGeneral], + rng: {} as any, + addLog: () => {}, + } as any, + { now: new Date(), schedule }, + { destNationId: destNation.id, commandType: 'che_허보' } + ); + + expect(resolution.general.experience).toBeGreaterThan(100); + expect(resolution.nation?.meta?.strategic_cmd_limit).toBe(8); + }); + + it('che_초토화: blocks when diplomacy limit exists', () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + nation.meta.surlimit = 1; + const destNation = buildNation(2); + const city = buildCity(1, 1); + const destCity = buildCity(2, 2); + const view = new TestStateView(); + view.set({ kind: 'general', id: general.id }, general); + view.set({ kind: 'city', id: city.id }, city); + view.set({ kind: 'nation', id: nation.id }, nation); + view.set({ kind: 'destCity', id: destCity.id }, destCity); + view.set({ kind: 'destNation', id: destNation.id }, destNation); + setupDiplomacy(view, nation.id, destNation.id, 1); + + const definition = new ScorchedEarthAction(); + const args = { destCityId: destCity.id }; + const ctx: ConstraintContext = { + actorId: general.id, + nationId: nation.id, + cityId: city.id, + destCityId: destCity.id, + destNationId: destNation.id, + args, + env: {}, + mode: 'full', + }; + + const result = evaluateConstraints(definition.buildConstraints(ctx, args), ctx, view); + expect(result.kind).toBe('deny'); + }); + + it('che_초토화: neutralizes city and increases nation resources', () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + const destNation = buildNation(2); + const city = buildCity(1, 1); + const destCity = buildCity(2, 2); + + const definition = new ScorchedEarthAction(); + const resolution = resolveGeneralAction( + definition, + { + general, + city, + nation, + destCity, + destNation, + rng: {} as any, + addLog: () => {}, + } as any, + { now: new Date(), schedule }, + { destCityId: destCity.id } + ); + + const destPatch = resolution.patches?.cities.find((patch) => patch.id === destCity.id); + expect(destPatch?.patch.nationId).toBe(0); + expect(resolution.nation?.gold).toBeGreaterThan(nation.gold); + expect((resolution.nation?.meta?.surlimit as number) ?? 0).toBeGreaterThan(0); + }); + + it('cr_인구이동: blocks without enough gold', () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + nation.gold = 0; + const destNation = buildNation(2); + const city = buildCity(1, 1); + const destCity = buildCity(2, 1); + const view = new TestStateView(); + view.set({ kind: 'general', id: general.id }, general); + view.set({ kind: 'city', id: city.id }, city); + view.set({ kind: 'nation', id: nation.id }, nation); + view.set({ kind: 'destCity', id: destCity.id }, destCity); + view.set({ kind: 'destNation', id: destNation.id }, destNation); + view.set({ kind: 'env', key: 'map' }, buildMap(city.id, destCity.id)); + + const definition = new PopulationMoveAction(buildEnv()); + const args = { destCityId: destCity.id, amount: 10000 }; + const ctx: ConstraintContext = { + actorId: general.id, + nationId: nation.id, + cityId: city.id, + destCityId: destCity.id, + args, + env: { + map: buildMap(city.id, destCity.id), + }, + mode: 'full', + }; + + const result = evaluateConstraints(definition.buildConstraints(ctx, args), ctx, view); + expect(result.kind).toBe('deny'); + }); + + it('cr_인구이동: moves population and pays cost', () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + const city = buildCity(1, 1); + const destCity = buildCity(2, 1); + + const definition = new PopulationMoveAction(buildEnv()); + const resolution = resolveGeneralAction( + definition, + { + general, + city, + nation, + destCity, + rng: {} as any, + addLog: () => {}, + } as any, + { now: new Date(), schedule }, + { destCityId: destCity.id, amount: 10000 } + ); + + const destPatch = resolution.patches?.cities.find((patch) => patch.id === destCity.id); + expect(destPatch?.patch.population).toBe(destCity.population + 10000); + expect(resolution.city?.population).toBe(city.population - 10000); + expect(resolution.nation?.gold).toBeLessThan(nation.gold); + }); + + const eventCommands = [ + { Action: EventWonyungAction, auxKey: 'can_원융노병사용', cost: 100000 }, + { Action: EventHwasibyeongAction, auxKey: 'can_화시병사용', cost: 50000 }, + { Action: EventEumgwiAction, auxKey: 'can_음귀병사용', cost: 50000 }, + { Action: EventDaegeomAction, auxKey: 'can_대검병사용', cost: 50000 }, + { Action: EventHwarunAction, auxKey: 'can_화륜차사용', cost: 100000 }, + { Action: EventSanjeoAction, auxKey: 'can_산저병사용', cost: 50000 }, + { Action: EventGeukAction, auxKey: 'can_극병사용', cost: 100000 }, + { Action: EventSangAction, auxKey: 'can_상병사용', cost: 100000 }, + { Action: EventMuheeAction, auxKey: 'can_무희사용', cost: 100000 }, + ]; + + for (const entry of eventCommands) { + it(`${entry.auxKey}: blocks when already researched`, () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + nation.meta[entry.auxKey] = 1; + const city = buildCity(1, 1); + const view = new TestStateView(); + view.set({ kind: 'general', id: general.id }, general); + view.set({ kind: 'city', id: city.id }, city); + view.set({ kind: 'nation', id: nation.id }, nation); + + const definition = new entry.Action(buildEnv()); + const args = {}; + const ctx: ConstraintContext = { + actorId: general.id, + nationId: nation.id, + cityId: city.id, + args, + env: {}, + mode: 'full', + }; + + const result = evaluateConstraints(definition.buildConstraints(ctx, args), ctx, view); + expect(result.kind).toBe('deny'); + }); + + it(`${entry.auxKey}: applies nation meta`, () => { + const general = buildGeneral(1, 1, 1); + const nation = buildNation(1); + const city = buildCity(1, 1); + const definition = new entry.Action(buildEnv()); + + const resolution = resolveGeneralAction( + definition, + { + general, + city, + nation, + rng: {} as any, + addLog: () => {}, + } as any, + { now: new Date(), schedule }, + {} + ); + + expect(resolution.nation?.meta?.[entry.auxKey]).toBe(1); + expect(resolution.nation?.gold).toBe(nation.gold - entry.cost); + }); + } +});