diff --git a/app/game-engine/src/turn/ai/generalAi/constraint.ts b/app/game-engine/src/turn/ai/generalAi/constraint.ts index 5835e98..e1d0144 100644 --- a/app/game-engine/src/turn/ai/generalAi/constraint.ts +++ b/app/game-engine/src/turn/ai/generalAi/constraint.ts @@ -11,6 +11,14 @@ export const resolveConstraintEnv = ( ): ConstraintEnv => { const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined; const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined; + const worldMeta = world.meta as Record; + const rawKillturn = worldMeta.killturn; + const killturn = + typeof rawKillturn === 'number' + ? rawKillturn + : typeof rawKillturn === 'string' + ? Number(rawKillturn) + : undefined; return { currentYear: world.currentYear, @@ -21,5 +29,6 @@ export const resolveConstraintEnv = ( relYear, openingPartYear: env.openingPartYear, minAvailableRecruitPop: env.minAvailableRecruitPop, + ...(Number.isFinite(killturn) ? { killturn } : {}), }; }; diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index bb163f3..f0f6c9f 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -136,6 +136,20 @@ export const buildReservedTurnDefinitions = async (options: { nation: Map; }> => { const itemModules = await loadItemModules([...ITEM_KEYS]); + options.env.itemCatalog = Object.fromEntries( + itemModules.map((item) => [ + item.key, + { + slot: item.slot, + name: item.name, + rawName: item.rawName, + cost: item.cost, + reqSecu: item.reqSecu, + buyable: item.buyable, + unique: item.unique, + }, + ]) + ); const itemRegistry = createItemModuleRegistry(itemModules); const itemActionModules = createItemActionModules(itemRegistry); const inheritBuffModules = createInheritBuffModules(); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 5c50b7a..0b79097 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -64,6 +64,13 @@ const resolveConstraintEnv = ( const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined; const joinModeRaw = worldMeta.join_mode ?? worldMeta.joinMode; const joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full'; + const killturnRaw = worldMeta.killturn; + const killturn = + typeof killturnRaw === 'number' + ? killturnRaw + : typeof killturnRaw === 'string' + ? Number(killturnRaw) + : undefined; return { ...env, @@ -76,6 +83,7 @@ const resolveConstraintEnv = ( openingPartYear: env.openingPartYear, minAvailableRecruitPop: env.minAvailableRecruitPop, join_mode: joinMode, + ...(Number.isFinite(killturn) ? { killturn } : {}), }; }; diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index e488364..810d123 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -1,6 +1,16 @@ import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; import type { WarActionModule } from '@sammo-ts/logic/war/actions.js'; +export interface TurnCommandItemCatalogEntry { + slot: 'horse' | 'weapon' | 'book' | 'item'; + name: string; + rawName: string; + cost: number | null; + reqSecu: number; + buyable: boolean; + unique: boolean; +} + export interface TurnCommandEnv { develCost: number; minAvailableRecruitPop?: number; @@ -25,6 +35,7 @@ export interface TurnCommandEnv { baseGold: number; baseRice: number; maxResourceActionAmount: number; + itemCatalog?: Record; generalActionModules?: Array; warActionModules?: Array; } diff --git a/packages/logic/src/actions/turn/general/che_모반시도.ts b/packages/logic/src/actions/turn/general/che_모반시도.ts new file mode 100644 index 0000000..26c9cfa --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_모반시도.ts @@ -0,0 +1,139 @@ +import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + allowRebellion, + beChief, + notBeNeutral, + notLord, + 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 { createGeneralPatchEffect, 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'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; + +const ACTION_NAME = '모반시도'; +const ACTION_KEY = 'che_모반시도'; + +export interface RebellionArgs {} + +export interface RebellionResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + nationGenerals?: General[]; +} + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + + parseArgs(_raw: unknown): RebellionArgs | null { + return {}; + } + + buildConstraints(_ctx: ConstraintContext, _args: RebellionArgs): Constraint[] { + return [notBeNeutral(), beChief(), occupiedCity(), suppliedCity(), notLord(), allowRebellion()]; + } + + resolve(context: RebellionResolveContext, _args: RebellionArgs): GeneralActionOutcome { + const general = context.general; + const nation = context.nation; + if (!nation) { + throw new Error('모반시도는 국가 정보가 필요합니다.'); + } + + const lord = + context.nationGenerals?.find((candidate) => candidate.nationId === nation.id && candidate.officerLevel === 12) ?? + null; + if (!lord || lord.id === general.id) { + throw new Error('모반할 대상 군주가 없습니다.'); + } + + const josaYi = JosaUtil.pick(general.name, '이'); + const effects: Array> = []; + + context.addLog(`【모반】${general.name}${josaYi} ${nation.name}의 군주 자리를 찬탈했습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + }); + context.addLog(`${general.name}${josaYi} ${lord.name}에게서 군주자리를 찬탈`, { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + }); + context.addLog('모반에 성공했습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + context.addLog(`모반으로 ${nation.name}의 군주자리를 찬탈`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + }); + + effects.push( + createLogEffect(`${general.name}에게 군주의 자리를 뺏겼습니다.`, { + scope: LogScope.GENERAL, + generalId: lord.id, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }), + createLogEffect(`${general.name}의 모반으로 인해 ${nation.name}의 군주자리를 박탈당함`, { + scope: LogScope.GENERAL, + generalId: lord.id, + category: LogCategory.HISTORY, + format: LogFormat.PLAIN, + }), + createGeneralPatchEffect( + { + officerLevel: 12, + meta: { + ...general.meta, + officer_city: 0, + officerCity: 0, + }, + }, + general.id + ), + createGeneralPatchEffect( + { + officerLevel: 1, + experience: Math.floor(lord.experience * 0.7), + meta: { + ...lord.meta, + officer_city: 0, + officerCity: 0, + }, + }, + lord.id + ) + ); + + return { effects }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const nationId = base.nation?.id ?? base.general.nationId; + return { + ...base, + nationGenerals: options.worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [], + }; +}; + +export const commandSpec: GeneralTurnCommandSpec = { + key: ACTION_KEY, + category: '국가', + reqArg: false, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/che_무작위건국.ts b/packages/logic/src/actions/turn/general/che_무작위건국.ts new file mode 100644 index 0000000..d8aaf2c --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_무작위건국.ts @@ -0,0 +1,260 @@ +import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + allowJoinAction, + beLord, + beOpeningPart, + checkNationNameDuplicate, + reqNationGeneralCount, + reqNationValue, + wanderingNation, +} 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, + createGeneralPatchEffect, + createNationPatchEffect, +} from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { z } from 'zod'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { GeneralTurnCommandSpec } from './index.js'; +import { parseArgsWithSchema } from '../parseArgs.js'; +import { JosaUtil } from '@sammo-ts/common'; + +const ACTION_NAME = '무작위 도시 건국'; +const ACTION_KEY = 'che_무작위건국'; +const ARGS_SCHEMA = z.object({ + nationName: z.string().min(1), + nationType: z.string().min(1), + colorType: z.number(), +}); +export type FoundingArgs = z.infer; + +export interface RandomFoundingResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + allCities?: City[]; + nationGenerals?: General[]; + currentYearMonth?: number; + initYearMonth?: number; +} + +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 = ACTION_KEY; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): FoundingArgs | null { + return parseArgsWithSchema(ARGS_SCHEMA, raw); + } + + buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] { + return [beOpeningPart(), reqNationValue('level', '국가규모', '==', 0, '정식 국가가 아니어야합니다.')]; + } + + buildConstraints(_ctx: ConstraintContext, args: FoundingArgs): Constraint[] { + return [ + beOpeningPart(), + beLord(), + wanderingNation(), + reqNationGeneralCount(2), + checkNationNameDuplicate(args.nationName), + allowJoinAction(), + ]; + } + + resolve( + context: RandomFoundingResolveContext, + args: FoundingArgs + ): GeneralActionOutcome { + const general = context.general; + const nation = context.nation; + if (!nation) { + throw new Error('건국은 국가 정보가 필요합니다.'); + } + + if ((context.currentYearMonth ?? 0) <= (context.initYearMonth ?? 0)) { + context.addLog('다음 턴부터 건국할 수 있습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + return { effects: [], alternative: { commandKey: 'che_인재탐색', args: {} } }; + } + + if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) { + throw new Error('Invalid color type'); + } + + const candidates = (context.allCities ?? []).filter((city) => city.nationId === 0 && [5, 6].includes(city.level)); + if (candidates.length === 0) { + context.addLog('건국할 수 있는 도시가 없습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + return { effects: [], alternative: { commandKey: 'che_해산', args: {} } }; + } + + const picked = candidates[context.rng.nextInt(0, candidates.length)]!; + const cityId = picked.id; + + const josaNationUl = JosaUtil.pick(args.nationName, '을'); + const josaNationYi = JosaUtil.pick(args.nationName, '이'); + const josaGeneralYi = JosaUtil.pick(general.name, '이'); + + context.addLog(`${args.nationName}${josaNationUl} 건국하였습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + context.addLog(`${general.name}${josaGeneralYi} ${picked.name}에 국가를 건설하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + }); + context.addLog(`【건국】${args.nationType} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + }); + context.addLog(`${args.nationName}${josaNationUl} 건국`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + }); + context.addLog(`${general.name}${josaGeneralYi} ${args.nationName}${josaNationUl} 건국`, { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + }); + + tryApplyUniqueLottery(context, { acquireType: '건국', reason: ACTION_NAME }); + + const effects: Array> = [ + createNationPatchEffect( + { + name: args.nationName, + typeCode: args.nationType, + color: NATION_COLORS[args.colorType]!, + level: 1, + capitalCityId: cityId, + meta: { + ...nation.meta, + can_국기변경: 1, + can_무작위수도이전: 1, + }, + }, + nation.id + ), + createCityPatchEffect( + { + nationId: nation.id, + }, + cityId + ), + createGeneralPatchEffect({ + experience: general.experience + 1000, + dedication: general.dedication + 1000, + }), + ]; + + if (general.cityId !== cityId) { + effects.push(createGeneralPatchEffect({ cityId }, general.id)); + const nationGenerals = context.nationGenerals ?? []; + for (const nationGeneral of nationGenerals) { + if (nationGeneral.id === general.id) { + continue; + } + effects.push(createGeneralPatchEffect({ cityId }, nationGeneral.id)); + } + } + + return { effects }; + } +} + +const resolveStartYear = (currentYear: number, raw: unknown): number => { + if (typeof raw === 'number' && Number.isFinite(raw)) { + return Math.floor(raw); + } + if (typeof raw === 'string') { + const parsed = Number(raw); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + return currentYear; +}; + +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const nationId = base.nation?.id ?? base.general.nationId; + const currentYear = options.world.currentYear; + const currentMonth = options.world.currentMonth; + + const startYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear); + const initYear = startYear; + const initMonth = 1; + + return { + ...base, + allCities: options.worldRef?.listCities() ?? [], + nationGenerals: options.worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [], + currentYearMonth: currentYear * 12 + currentMonth - 1, + initYearMonth: initYear * 12 + initMonth - 1, + }; +}; + +export const commandSpec: GeneralTurnCommandSpec = { + key: ACTION_KEY, + category: '국가', + reqArg: true, + availabilityArgs: { + nationName: 'string', + nationType: 'string', + colorType: 'number', + }, + argsSchema: ARGS_SCHEMA, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/che_선양.ts b/packages/logic/src/actions/turn/general/che_선양.ts new file mode 100644 index 0000000..c646ffd --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_선양.ts @@ -0,0 +1,169 @@ +import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { beLord, existsDestGeneral, friendlyDestGeneral } 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 { createGeneralPatchEffect, 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'; +import { z } from 'zod'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; +import { parseArgsWithSchema } from '../parseArgs.js'; + +const ACTION_NAME = '선양'; +const ACTION_KEY = 'che_선양'; +const ARGS_SCHEMA = z.object({ + destGeneralID: z.number().int().positive(), +}); +export type AbdicationArgs = z.infer; + +export interface AbdicationResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destGeneral?: General; +} + +const blockedPenaltyKeys = ['noChief', 'noFoundNation', 'noAmbassador'] as const; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): AbdicationArgs | null { + return parseArgsWithSchema(ARGS_SCHEMA, raw); + } + + buildMinConstraints(_ctx: ConstraintContext, _args: AbdicationArgs): Constraint[] { + return [beLord()]; + } + + buildConstraints(_ctx: ConstraintContext, _args: AbdicationArgs): Constraint[] { + return [beLord(), existsDestGeneral(), friendlyDestGeneral()]; + } + + resolve(context: AbdicationResolveContext, _args: AbdicationArgs): GeneralActionOutcome { + const general = context.general; + const nation = context.nation; + const destGeneral = context.destGeneral; + if (!nation) { + throw new Error('선양은 국가 정보가 필요합니다.'); + } + if (!destGeneral) { + throw new Error('선양 대상 장수가 없습니다.'); + } + + const penaltyRaw = destGeneral.meta.penalty; + if (penaltyRaw && typeof penaltyRaw === 'object' && !Array.isArray(penaltyRaw)) { + const penaltyMap = penaltyRaw as Record; + for (const penaltyKey of blockedPenaltyKeys) { + if (Object.prototype.hasOwnProperty.call(penaltyMap, penaltyKey)) { + return { + effects: [ + createLogEffect('선양할 수 없는 장수입니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } + } + } + + const josaYi = JosaUtil.pick(general.name, '이'); + const effects: Array> = []; + + context.addLog(`【선양】${general.name}${josaYi} ${nation.name}의 군주 자리를 ${destGeneral.name}에게 선양했습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + }); + context.addLog(`${general.name}${josaYi} ${destGeneral.name}에게 선양`, { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + }); + context.addLog(`${destGeneral.name}에게 군주의 자리를 물려줍니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + context.addLog(`${nation.name}의 군주자리를 ${destGeneral.name}에게 선양`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + }); + + effects.push( + createLogEffect(`${general.name}에게서 군주의 자리를 물려받습니다.`, { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }), + createLogEffect(`${nation.name}의 군주자리를 물려 받음`, { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.HISTORY, + format: LogFormat.PLAIN, + }), + createGeneralPatchEffect( + { + officerLevel: 12, + meta: { + ...destGeneral.meta, + officer_city: 0, + officerCity: 0, + }, + }, + destGeneral.id + ), + createGeneralPatchEffect( + { + officerLevel: 1, + experience: Math.floor(general.experience * 0.7), + meta: { + ...general.meta, + officer_city: 0, + officerCity: 0, + }, + }, + general.id + ) + ); + + return { effects }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destGeneralID = options.actionArgs.destGeneralID; + if (typeof destGeneralID !== 'number') { + return null; + } + const destGeneral = options.worldRef?.getGeneralById(destGeneralID) ?? undefined; + if (!destGeneral) { + return null; + } + + return { + ...base, + destGeneral, + }; +}; + +export const commandSpec: GeneralTurnCommandSpec = { + key: ACTION_KEY, + category: '국가', + reqArg: true, + availabilityArgs: { + destGeneralID: 'number', + }, + argsSchema: ARGS_SCHEMA, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/che_장비매매.ts b/packages/logic/src/actions/turn/general/che_장비매매.ts new file mode 100644 index 0000000..96b5df7 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_장비매매.ts @@ -0,0 +1,224 @@ +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + alwaysFail, + reqCityCapacity, + reqCityTrader, + reqGeneralGold, + reqGeneralRice, +} 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 { 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 { z } from 'zod'; +import type { + TurnCommandEnv, + TurnCommandItemCatalogEntry, +} from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { GeneralTurnCommandSpec } from './index.js'; +import { parseArgsWithSchema } from '../parseArgs.js'; + +const ACTION_NAME = '장비매매'; +const ACTION_KEY = 'che_장비매매'; +const ITEM_TYPES = ['horse', 'weapon', 'book', 'item'] as const; + +type ItemType = (typeof ITEM_TYPES)[number]; + +const ARGS_SCHEMA = z.object({ + itemType: z.enum(ITEM_TYPES), + itemCode: z.string().min(1), +}); +export type TradeItemArgs = z.infer; + +const ITEM_TYPE_NAME: Record = { + horse: '명마', + weapon: '무기', + book: '서적', + item: '도구', +}; + +const readItem = ( + catalog: Record | undefined, + itemCode: string +): TurnCommandItemCatalogEntry | null => { + if (!catalog || itemCode === 'None') { + return null; + } + return catalog[itemCode] ?? null; +}; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) {} + + parseArgs(raw: unknown): TradeItemArgs | null { + const args = parseArgsWithSchema(ARGS_SCHEMA, raw); + if (!args) { + return null; + } + const { itemType, itemCode } = args; + if (itemCode === 'None') { + return args; + } + const item = readItem(this.env.itemCatalog, itemCode); + if (!item) { + return null; + } + if (item.slot !== itemType || !item.buyable) { + return null; + } + return args; + } + + buildMinConstraints(_ctx: ConstraintContext, _args: TradeItemArgs): Constraint[] { + return [reqCityTrader()]; + } + + buildConstraints(_ctx: ConstraintContext, args: TradeItemArgs): Constraint[] { + const selectedItem = readItem(this.env.itemCatalog, args.itemCode); + const reqSecu = selectedItem?.reqSecu ?? 0; + const reqGold = selectedItem?.cost ?? 0; + + const constraints: Constraint[] = [ + reqCityTrader(), + reqCityCapacity('security', '치안 수치', reqSecu), + reqGeneralGold(() => reqGold), + reqGeneralRice(() => 0), + ]; + + if (args.itemCode === 'None') { + constraints.push({ + name: 'reqGeneralValue', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const req = { kind: 'general', id: ctx.actorId } as const; + const general = view.get(req) as GeneralActionResolveContext['general'] | null; + if (!general) { + return { kind: 'deny', reason: '장수 정보가 없습니다.' }; + } + if (general.role.items[args.itemType] !== null) { + return { kind: 'allow' }; + } + return { kind: 'deny', reason: `${ITEM_TYPE_NAME[args.itemType]}이 없습니다.` }; + }, + }); + return constraints; + } + + constraints.push({ + name: 'AlwaysFail', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const req = { kind: 'general', id: ctx.actorId } as const; + const general = view.get(req) as GeneralActionResolveContext['general'] | null; + if (!general) { + return { kind: 'deny', reason: '장수 정보가 없습니다.' }; + } + const currentItemCode = general.role.items[args.itemType]; + if (currentItemCode === args.itemCode) { + return alwaysFail('이미 가지고 있습니다.').test(ctx, view); + } + + if (currentItemCode) { + const currentItem = readItem(this.env.itemCatalog, currentItemCode); + if (currentItem && !currentItem.buyable) { + return alwaysFail('이미 진귀한 것을 가지고 있습니다.').test(ctx, view); + } + } + return { kind: 'allow' }; + }, + }); + + return constraints; + } + + resolve(context: GeneralActionResolveContext, args: TradeItemArgs): GeneralActionOutcome { + const general = context.general; + const nation = context.nation; + + const itemType = args.itemType; + const requestedItemCode = args.itemCode; + const currentItemCode = general.role.items[itemType]; + + const buying = requestedItemCode !== 'None'; + const finalItemCode = buying ? requestedItemCode : currentItemCode; + if (!finalItemCode) { + throw new Error('판매할 아이템이 없습니다.'); + } + + const item = readItem(this.env.itemCatalog, finalItemCode); + const itemName = item?.name ?? finalItemCode; + const itemRawName = item?.rawName ?? itemName; + const itemCost = item?.cost ?? 0; + const josaUl = JosaUtil.pick(itemRawName, '을'); + + const nextGold = buying ? Math.max(0, general.gold - itemCost) : general.gold + Math.floor(itemCost / 2); + const nextRole = { + ...general.role, + items: { + ...general.role.items, + [itemType]: buying ? finalItemCode : null, + }, + }; + + if (buying) { + context.addLog(`${itemName}${josaUl} 구입했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + } else { + context.addLog(`${itemName}${josaUl} 판매했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + } + + if (!buying && item && !item.buyable && nation) { + const josaYi = JosaUtil.pick(general.name, '이'); + context.addLog(`${general.name}${josaYi} ${itemName}${josaUl} 판매했습니다!`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + }); + context.addLog(`【판매】${nation.name}${general.name}${josaYi} ${itemName}${josaUl} 판매했습니다!`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + }); + } + + tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + + return { + effects: [ + createGeneralPatchEffect({ + gold: nextGold, + experience: general.experience + 10, + role: nextRole, + }), + ], + }; + } +} + +export const actionContextBuilder = defaultActionContextBuilder; + +export const commandSpec: GeneralTurnCommandSpec = { + key: ACTION_KEY, + category: '개인', + reqArg: true, + availabilityArgs: { + itemType: 'string', + itemCode: 'string', + }, + argsSchema: ARGS_SCHEMA, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), +}; diff --git a/packages/logic/src/actions/turn/general/che_장수대상임관.ts b/packages/logic/src/actions/turn/general/che_장수대상임관.ts new file mode 100644 index 0000000..ebe8dbd --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_장수대상임관.ts @@ -0,0 +1,268 @@ +import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js'; +import { + allowJoinAction, + beNeutral, + reqEnvValue, + unknownOrDeny, +} 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 { createGeneralPatchEffect, 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 { z } from 'zod'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import type { GeneralTurnCommandSpec } from './index.js'; +import { parseArgsWithSchema } from '../parseArgs.js'; + +const ACTION_NAME = '장수를 따라 임관'; +const ACTION_KEY = 'che_장수대상임관'; +const ARGS_SCHEMA = z.object({ + destGeneralID: z.number().int().positive(), +}); +export type FollowAppointmentArgs = z.infer; + +export interface FollowAppointmentResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destGeneral?: General; + destNation?: Nation; + destNationGeneralCount?: number; + initialNationGenLimit?: number; +} + +const existsDestNation = (destGeneralID: number): Constraint => ({ + name: 'existsDestNation', + requires: () => [{ kind: 'destGeneral', id: destGeneralID }], + test: (ctx, view) => { + const req: RequirementKey = { kind: 'destGeneral', id: destGeneralID }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); + } + const destGeneral = view.get(req) as General | null; + if (!destGeneral || destGeneral.nationId <= 0) { + return { kind: 'deny', reason: '국가 정보가 없습니다.' }; + } + return { kind: 'allow' }; + }, +}); + +const allowJoinDestNation = (destGeneralID: number): Constraint => ({ + name: 'allowJoinDestNation', + requires: () => [ + { kind: 'destGeneral', id: destGeneralID }, + { kind: 'generalList' }, + { kind: 'nationList' }, + { kind: 'env', key: 'relYear' }, + { kind: 'env', key: 'openingPartYear' }, + { kind: 'env', key: 'initialNationGenLimit' }, + { kind: 'env', key: 'maxGeneral' }, + ], + test: (ctx, view) => { + const req: RequirementKey = { kind: 'destGeneral', id: destGeneralID }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); + } + const destGeneral = view.get(req) as General | null; + if (!destGeneral || destGeneral.nationId <= 0) { + return { kind: 'deny', reason: '국가 정보가 없습니다.' }; + } + + const listReq: RequirementKey = { kind: 'generalList' }; + if (!view.has(listReq)) { + return unknownOrDeny(ctx, [listReq], '장수 정보가 없습니다.'); + } + const generals = view.get(listReq) as General[] | null; + if (!generals) { + return unknownOrDeny(ctx, [listReq], '장수 정보가 없습니다.'); + } + + const nationListReq: RequirementKey = { kind: 'nationList' }; + if (!view.has(nationListReq)) { + return unknownOrDeny(ctx, [nationListReq], '국가 정보가 없습니다.'); + } + const nations = view.get(nationListReq) as Nation[] | null; + if (!nations) { + return unknownOrDeny(ctx, [nationListReq], '국가 정보가 없습니다.'); + } + const destNation = nations.find((nation) => nation.id === destGeneral.nationId); + if (!destNation) { + return { kind: 'deny', reason: '국가 정보가 없습니다.' }; + } + + const relYear = typeof view.get({ kind: 'env', key: 'relYear' }) === 'number' ? (view.get({ kind: 'env', key: 'relYear' }) as number) : 0; + const openingPartYear = + typeof view.get({ kind: 'env', key: 'openingPartYear' }) === 'number' + ? (view.get({ kind: 'env', key: 'openingPartYear' }) as number) + : 3; + const initialNationGenLimit = + typeof view.get({ kind: 'env', key: 'initialNationGenLimit' }) === 'number' + ? (view.get({ kind: 'env', key: 'initialNationGenLimit' }) as number) + : 10; + const maxGeneral = + typeof view.get({ kind: 'env', key: 'maxGeneral' }) === 'number' + ? (view.get({ kind: 'env', key: 'maxGeneral' }) as number) + : 500; + + const currentCount = generals.filter((general) => general.nationId === destNation.id).length; + + if (destNation.level === 0) { + return { kind: 'allow' }; + } + + if (relYear < openingPartYear) { + if (currentCount < initialNationGenLimit) { + return { kind: 'allow' }; + } + return { kind: 'deny', reason: '초반 등용 제한 인원을 초과했습니다.' }; + } + + if (currentCount < maxGeneral) { + return { kind: 'allow' }; + } + return { kind: 'deny', reason: '등용 제한 인원을 초과했습니다.' }; + }, +}); + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): FollowAppointmentArgs | null { + return parseArgsWithSchema(ARGS_SCHEMA, raw); + } + + buildMinConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] { + return [ + reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), + beNeutral(), + allowJoinAction(), + ]; + } + + buildConstraints(ctx: ConstraintContext, args: FollowAppointmentArgs): Constraint[] { + void ctx; + return [ + reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), + beNeutral(), + existsDestNation(args.destGeneralID), + allowJoinDestNation(args.destGeneralID), + allowJoinAction(), + ]; + } + + resolve( + context: FollowAppointmentResolveContext, + _args: FollowAppointmentArgs + ): GeneralActionOutcome { + const general = context.general; + const destGeneral = context.destGeneral; + const destNation = context.destNation; + if (!destNation) { + throw new Error('임관 대상 국가 정보가 없습니다.'); + } + + const targetCityId = destGeneral?.cityId ?? destNation.capitalCityId ?? general.cityId; + const josaYi = JosaUtil.pick(general.name, '이'); + + context.addLog(`${destNation.name}에 임관했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + context.addLog(`${destNation.name}에 임관`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + }); + context.addLog(`${general.name}${josaYi} ${destNation.name}임관했습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + }); + + const initialNationGenLimit = context.initialNationGenLimit ?? 10; + const destNationGeneralCount = context.destNationGeneralCount ?? 0; + const expGain = destNationGeneralCount < initialNationGenLimit ? 700 : 100; + + tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + + return { + effects: [ + createGeneralPatchEffect({ + nationId: destNation.id, + officerLevel: 1, + cityId: targetCityId, + troopId: 0, + experience: general.experience + expGain, + meta: { + ...general.meta, + belong: 1, + officer_city: 0, + officerCity: 0, + }, + }), + createNationPatchEffect( + { + meta: { + ...destNation.meta, + gennum: + typeof destNation.meta.gennum === 'number' + ? destNation.meta.gennum + 1 + : destNationGeneralCount + 1, + }, + }, + destNation.id + ), + ], + }; + } +} + +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) ?? undefined; + const destNation = destGeneral ? worldRef.getNationById(destGeneral.nationId) ?? undefined : undefined; + if (!destGeneral || !destNation) { + return null; + } + + const currentCount = worldRef.listGenerals().filter((general) => general.nationId === destNation.id).length; + + const constValues = (options.scenarioConfig.const ?? {}) as Record; + const initialNationGenLimitRaw = constValues.initialNationGenLimit; + const initialNationGenLimit = + typeof initialNationGenLimitRaw === 'number' && Number.isFinite(initialNationGenLimitRaw) + ? Math.floor(initialNationGenLimitRaw) + : 10; + + return { + ...base, + destGeneral, + destNation, + destNationGeneralCount: currentCount, + initialNationGenLimit, + }; +}; + +export const commandSpec: GeneralTurnCommandSpec = { + key: ACTION_KEY, + category: '인사', + reqArg: true, + availabilityArgs: { + destGeneralID: 'number', + }, + argsSchema: ARGS_SCHEMA, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/che_전투태세.ts b/packages/logic/src/actions/turn/general/che_전투태세.ts new file mode 100644 index 0000000..ea8213c --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_전투태세.ts @@ -0,0 +1,142 @@ +import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + notBeNeutral, + notWanderingNation, + occupiedCity, + reqGeneralAtmosMargin, + reqGeneralCrew, + reqGeneralGold, + reqGeneralRice, + reqGeneralTrainMargin, +} 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 { createGeneralPatchEffect } 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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import { getTechCost } from '@sammo-ts/logic/world/unitSet.js'; +import type { GeneralTurnCommandSpec } from './index.js'; + +const ACTION_NAME = '전투태세'; +const ACTION_KEY = 'che_전투태세'; +const REQ_TERM = 3; + +export interface BattlePreparationArgs {} + +const readNationTech = (nation: Nation | null | undefined): number => { + if (!nation) { + return 0; + } + const tech = nation.meta.tech; + return typeof tech === 'number' ? tech : 0; +}; + +const readBattleStanceTerm = (meta: Record): number => { + const value = meta.battle_stance_term; + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0; +}; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) {} + + parseArgs(_raw: unknown): BattlePreparationArgs | null { + return {}; + } + + buildConstraints(_ctx: ConstraintContext, _args: BattlePreparationArgs): Constraint[] { + const nationRequirement = + _ctx.nationId !== undefined ? [{ kind: 'nation', id: _ctx.nationId } as const] : []; + return [ + notBeNeutral(), + notWanderingNation(), + occupiedCity(), + reqGeneralCrew(), + reqGeneralGold((ctx, view) => { + const general = view.get({ kind: 'general', id: ctx.actorId }) as { crew?: number } | null; + const nation = + ctx.nationId !== undefined ? (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null) : null; + const crew = typeof general?.crew === 'number' ? general.crew : 0; + const techCost = getTechCost(readNationTech(nation)); + return Math.round((crew / 100) * 3 * techCost); + }, nationRequirement), + reqGeneralRice(() => 0), + reqGeneralTrainMargin(Math.max(0, this.env.maxTrainByCommand - 10)), + reqGeneralAtmosMargin(Math.max(0, this.env.maxAtmosByCommand - 10)), + ]; + } + + resolve( + context: GeneralActionResolveContext, + _args: BattlePreparationArgs + ): GeneralActionOutcome { + const general = context.general; + const nation = context.nation; + const crew = general.crew; + const techCost = getTechCost(readNationTech(nation)); + const costGold = Math.round((crew / 100) * 3 * techCost); + + const previousTerm = readBattleStanceTerm(general.meta as Record); + const term = previousTerm >= REQ_TERM ? 1 : previousTerm + 1; + + if (term < REQ_TERM) { + context.addLog(`병사들을 열심히 훈련중... (${term}/3)`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + return { + effects: [ + createGeneralPatchEffect({ + gold: Math.max(0, general.gold - costGold), + meta: { + ...general.meta, + battle_stance_term: term, + }, + }), + ], + }; + } + + context.addLog(`전투태세 완료! (${term}/3)`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + + tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + + const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0; + + return { + effects: [ + createGeneralPatchEffect({ + gold: Math.max(0, general.gold - costGold), + experience: general.experience + 300, + dedication: general.dedication + 210, + meta: { + ...general.meta, + battle_stance_term: term, + leadership_exp: leadershipExp + 3, + }, + }), + ], + }; + } +} + +export const actionContextBuilder = defaultActionContextBuilder; + +export const commandSpec: GeneralTurnCommandSpec = { + key: ACTION_KEY, + category: '군사', + reqArg: false, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), +}; diff --git a/packages/logic/src/actions/turn/general/che_접경귀환.ts b/packages/logic/src/actions/turn/general/che_접경귀환.ts new file mode 100644 index 0000000..8d0c316 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_접경귀환.ts @@ -0,0 +1,116 @@ +import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { notBeNeutral, notOccupiedCity, notWanderingNation } 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 { 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 { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { searchDistance } from '@sammo-ts/logic/world/distance.js'; +import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; +import type { GeneralTurnCommandSpec } from './index.js'; + +const ACTION_NAME = '접경귀환'; +const ACTION_KEY = 'che_접경귀환'; + +export interface BorderReturnArgs {} + +export interface BorderReturnResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + map?: MapDefinition; + allCities?: City[]; +} + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + + parseArgs(_raw: unknown): BorderReturnArgs | null { + return {}; + } + + buildConstraints(_ctx: ConstraintContext, _args: BorderReturnArgs): Constraint[] { + return [notBeNeutral(), notWanderingNation(), notOccupiedCity()]; + } + + resolve( + context: BorderReturnResolveContext, + _args: BorderReturnArgs + ): GeneralActionOutcome { + const general = context.general; + const nation = context.nation; + const map = context.map; + const allCities = context.allCities ?? []; + if (!nation || !map) { + context.addLog('3칸 이내에 아국 도시가 없습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + return { effects: [] }; + } + + const distanceByCity = searchDistance(map, general.cityId, 3); + const candidates = allCities + .filter((city) => city.nationId === nation.id && city.supplyState === 1) + .map((city) => ({ city, distance: distanceByCity[city.id] })) + .filter((entry) => typeof entry.distance === 'number' && Number.isFinite(entry.distance)); + + if (candidates.length === 0) { + context.addLog('3칸 이내에 아국 도시가 없습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + return { effects: [] }; + } + + const minDistance = Math.min(...candidates.map((entry) => entry.distance as number)); + const nearestCities = candidates + .filter((entry) => entry.distance === minDistance) + .map((entry) => entry.city); + + if (nearestCities.length === 0) { + context.addLog('3칸 이내에 아국 도시가 없습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + return { effects: [] }; + } + + const targetCity = nearestCities[context.rng.nextInt(0, nearestCities.length)]!; + const josaRo = JosaUtil.pick(targetCity.name, '로'); + context.addLog(`${targetCity.name}${josaRo} 접경귀환했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + + return { + effects: [ + createGeneralPatchEffect({ + cityId: targetCity.id, + }), + ], + }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => ({ + ...base, + map: options.map, + allCities: options.worldRef?.listCities() ?? [], +}); + +export const commandSpec: GeneralTurnCommandSpec = { + key: ACTION_KEY, + category: '인사', + reqArg: false, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/che_증여.ts b/packages/logic/src/actions/turn/general/che_증여.ts new file mode 100644 index 0000000..32eac34 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_증여.ts @@ -0,0 +1,164 @@ +import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + existsDestGeneral, + friendlyDestGeneral, + notBeNeutral, + occupiedCity, + reqGeneralGold, + reqGeneralRice, + 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 { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { z } from 'zod'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import type { GeneralTurnCommandSpec } from './index.js'; +import { parseArgsWithSchema } from '../parseArgs.js'; + +const ACTION_NAME = '증여'; +const ACTION_KEY = 'che_증여'; +const ARGS_SCHEMA = z.object({ + isGold: z.boolean(), + amount: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value), + z.number().int().positive() + ), + destGeneralID: z.number().int().positive(), +}); +export type GiftArgs = z.infer; + +export interface GiftResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destGeneral?: General; +} + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) {} + + parseArgs(raw: unknown): GiftArgs | null { + const parsed = parseArgsWithSchema(ARGS_SCHEMA, raw); + if (!parsed) { + return null; + } + const maxAmount = this.env.maxResourceActionAmount > 0 ? this.env.maxResourceActionAmount : 10000; + return { + ...parsed, + amount: Math.max(100, Math.min(parsed.amount, maxAmount)), + }; + } + + buildMinConstraints(_ctx: ConstraintContext, _args: GiftArgs): Constraint[] { + return [notBeNeutral(), occupiedCity(), suppliedCity()]; + } + + buildConstraints(_ctx: ConstraintContext, args: GiftArgs): Constraint[] { + const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000; + const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000; + + return [ + notBeNeutral(), + occupiedCity(), + suppliedCity(), + existsDestGeneral(), + friendlyDestGeneral(), + args.isGold ? reqGeneralGold(() => minGold) : reqGeneralRice(() => minRice), + ]; + } + + resolve(context: GiftResolveContext, args: GiftArgs): GeneralActionOutcome { + const general = context.general; + const destGeneral = context.destGeneral; + if (!destGeneral) { + throw new Error('증여 대상 장수가 없습니다.'); + } + + const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000; + const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000; + + const resKey = args.isGold ? 'gold' : 'rice'; + const resName = args.isGold ? '금' : '쌀'; + const keepMin = args.isGold ? minGold : minRice; + + const available = Math.max(0, general[resKey] - keepMin); + const amount = Math.max(0, Math.min(args.amount, available)); + const amountText = amount.toLocaleString('en-US'); + + const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0; + + tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + + return { + effects: [ + createGeneralPatchEffect( + { + [resKey]: general[resKey] - amount, + experience: general.experience + 70, + dedication: general.dedication + 100, + meta: { + ...general.meta, + leadership_exp: leadershipExp + 1, + }, + }, + general.id + ), + createGeneralPatchEffect( + { + [resKey]: destGeneral[resKey] + amount, + }, + destGeneral.id + ), + createLogEffect(`${general.name}에게서 ${resName} ${amountText}을 증여 받았습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: destGeneral.id, + format: LogFormat.PLAIN, + }), + createLogEffect(`${destGeneral.name}에게 ${resName} ${amountText}을 증여했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const destGeneralID = options.actionArgs.destGeneralID; + if (typeof destGeneralID !== 'number') { + return null; + } + const destGeneral = options.worldRef?.getGeneralById(destGeneralID) ?? undefined; + if (!destGeneral) { + return null; + } + + return { + ...base, + destGeneral, + }; +}; + +export const commandSpec: GeneralTurnCommandSpec = { + key: ACTION_KEY, + category: '국가', + reqArg: true, + availabilityArgs: { + isGold: false, + amount: 0, + destGeneralID: 0, + }, + argsSchema: ARGS_SCHEMA, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), +}; diff --git a/packages/logic/src/actions/turn/general/che_해산.ts b/packages/logic/src/actions/turn/general/che_해산.ts new file mode 100644 index 0000000..0509a34 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_해산.ts @@ -0,0 +1,180 @@ +import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { beLord, wanderingNation } 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, + createGeneralPatchEffect, + 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 { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { GeneralTurnCommandSpec } from './index.js'; + +const ACTION_NAME = '해산'; +const ACTION_KEY = 'che_해산'; + +export interface DisbandFactionArgs {} + +export interface DisbandFactionResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + nationGenerals?: General[]; + nationCities?: City[]; + currentYearMonth?: number; + initYearMonth?: number; +} + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) {} + + parseArgs(_raw: unknown): DisbandFactionArgs | null { + return {}; + } + + buildConstraints(_ctx: ConstraintContext, _args: DisbandFactionArgs): Constraint[] { + return [beLord(), wanderingNation()]; + } + + resolve( + context: DisbandFactionResolveContext, + _args: DisbandFactionArgs + ): GeneralActionOutcome { + const general = context.general; + const nation = context.nation; + if (!nation) { + throw new Error('해산은 국가 정보가 필요합니다.'); + } + + if ((context.currentYearMonth ?? 0) <= (context.initYearMonth ?? 0)) { + context.addLog('다음 턴부터 해산할 수 있습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + return { effects: [], alternative: { commandKey: 'che_인재탐색', args: {} } }; + } + + const effects: Array> = []; + + const baseGold = this.env.baseGold > 0 ? this.env.baseGold : 1000; + const baseRice = this.env.baseRice > 0 ? this.env.baseRice : 1000; + + const nationGenerals = context.nationGenerals ?? []; + for (const targetGeneral of nationGenerals) { + const isActor = targetGeneral.id === general.id; + effects.push( + createGeneralPatchEffect( + { + nationId: 0, + officerLevel: 0, + troopId: 0, + gold: Math.min(targetGeneral.gold, baseGold), + rice: Math.min(targetGeneral.rice, baseRice), + meta: { + ...targetGeneral.meta, + belong: 0, + officer_city: 0, + officerCity: 0, + ...(isActor ? { makelimit: 12 } : {}), + }, + }, + targetGeneral.id + ) + ); + } + + const nationCities = context.nationCities ?? []; + for (const city of nationCities) { + effects.push( + createCityPatchEffect( + { + nationId: 0, + frontState: 0, + }, + city.id + ) + ); + } + + effects.push( + createNationPatchEffect( + { + meta: { + ...nation.meta, + collapsed: true, + }, + }, + nation.id + ) + ); + + const josaYi = JosaUtil.pick(general.name, '이'); + const josaUl = JosaUtil.pick(nation.name, '을'); + + context.addLog('세력을 해산했습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + context.addLog(`${general.name}${josaYi} 세력을 해산했습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + }); + context.addLog(`${nation.name}${josaUl} 해산`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + }); + + return { effects }; + } +} + +const resolveStartYear = (currentYear: number, raw: unknown): number => { + if (typeof raw === 'number' && Number.isFinite(raw)) { + return Math.floor(raw); + } + if (typeof raw === 'string') { + const parsed = Number(raw); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + return currentYear; +}; + +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const nationId = base.nation?.id ?? base.general.nationId; + const worldRef = options.worldRef; + const currentYear = options.world.currentYear; + const currentMonth = options.world.currentMonth; + const initYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear); + const initMonth = 1; + + return { + ...base, + nationGenerals: worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [], + nationCities: worldRef?.listCities().filter((city) => city.nationId === nationId) ?? [], + currentYearMonth: currentYear * 12 + currentMonth - 1, + initYearMonth: initYear * 12 + initMonth - 1, + }; +}; + +export const commandSpec: GeneralTurnCommandSpec = { + key: ACTION_KEY, + category: '국가', + reqArg: false, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), +}; diff --git a/packages/logic/src/actions/turn/general/cr_건국.ts b/packages/logic/src/actions/turn/general/cr_건국.ts new file mode 100644 index 0000000..e542b64 --- /dev/null +++ b/packages/logic/src/actions/turn/general/cr_건국.ts @@ -0,0 +1,188 @@ +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + allowJoinAction, + beLord, + beOpeningPart, + checkNationNameDuplicate, + neutralCity, + reqNationGeneralCount, + reqNationValue, + wanderingNation, +} 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 { + createCityPatchEffect, + createGeneralPatchEffect, + createNationPatchEffect, +} from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { z } from 'zod'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import type { GeneralTurnCommandSpec } from './index.js'; +import { parseArgsWithSchema } from '../parseArgs.js'; +import { JosaUtil } from '@sammo-ts/common'; + +const ACTION_NAME = '건국'; +const ACTION_KEY = 'cr_건국'; +const ARGS_SCHEMA = z.object({ + nationName: z.string().min(1), + nationType: z.string().min(1), + colorType: z.number(), +}); +export type FoundingArgs = z.infer; + +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 = ACTION_KEY; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): FoundingArgs | null { + return parseArgsWithSchema(ARGS_SCHEMA, raw); + } + + buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] { + return [beOpeningPart(), reqNationValue('level', '국가규모', '==', 0, '정식 국가가 아니어야합니다.')]; + } + + buildConstraints(_ctx: ConstraintContext, args: FoundingArgs): Constraint[] { + return [ + beOpeningPart(), + beLord(), + wanderingNation(), + reqNationGeneralCount(2), + checkNationNameDuplicate(args.nationName), + allowJoinAction(), + neutralCity(), + ]; + } + + resolve( + context: GeneralActionResolveContext, + args: FoundingArgs + ): GeneralActionOutcome { + const general = context.general; + const nation = context.nation; + if (!nation) { + throw new Error('건국은 국가 정보가 필요합니다.'); + } + + const cityId = general.cityId; + if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) { + throw new Error('Invalid color type'); + } + const color = NATION_COLORS[args.colorType]; + + const josaNationUl = JosaUtil.pick(args.nationName, '을'); + const josaNationYi = JosaUtil.pick(args.nationName, '이'); + const josaGeneralYi = JosaUtil.pick(general.name, '이'); + const cityName = context.city?.name ?? '알 수 없는 도시'; + + context.addLog(`${args.nationName}${josaNationUl} 건국하였습니다.`, { + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + context.addLog(`${general.name}${josaGeneralYi} ${cityName}에 국가를 건설하였습니다.`, { + category: LogCategory.ACTION, + scope: LogScope.SYSTEM, + }); + context.addLog(`【건국】${args.nationType} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, { + category: LogCategory.HISTORY, + scope: LogScope.SYSTEM, + }); + context.addLog(`${args.nationName}${josaNationUl} 건국`, { + category: LogCategory.HISTORY, + scope: LogScope.GENERAL, + }); + context.addLog(`${general.name}${josaGeneralYi} ${args.nationName}${josaNationUl} 건국`, { + category: LogCategory.HISTORY, + scope: LogScope.NATION, + }); + + tryApplyUniqueLottery(context, { acquireType: '건국', reason: ACTION_NAME }); + + const effects = [ + createNationPatchEffect( + { + name: args.nationName, + typeCode: args.nationType, + color: color!, + level: 1, + capitalCityId: cityId, + meta: { + ...nation.meta, + can_국기변경: 1, + }, + }, + nation.id + ), + createCityPatchEffect( + { + nationId: nation.id, + }, + cityId + ), + createGeneralPatchEffect({ + experience: general.experience + 1000, + dedication: general.dedication + 1000, + }), + ]; + + return { effects }; + } +} + +export const actionContextBuilder = defaultActionContextBuilder; + +export const commandSpec: GeneralTurnCommandSpec = { + key: ACTION_KEY, + category: '국가', + reqArg: true, + availabilityArgs: { + nationName: 'string', + nationType: 'string', + colorType: 'number', + }, + argsSchema: ARGS_SCHEMA, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/cr_맹훈련.ts b/packages/logic/src/actions/turn/general/cr_맹훈련.ts new file mode 100644 index 0000000..25596c0 --- /dev/null +++ b/packages/logic/src/actions/turn/general/cr_맹훈련.ts @@ -0,0 +1,110 @@ +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { + notBeNeutral, + notWanderingNation, + occupiedCity, + reqGeneralCrew, + reqGeneralTrainMargin, +} 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 { createGeneralPatchEffect } 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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import type { GeneralTurnCommandSpec } from './index.js'; + +const ACTION_NAME = '맹훈련'; +const ACTION_KEY = 'cr_맹훈련'; + +export interface FierceTrainingArgs {} + +const clamp = (value: number, min: number, max: number): number => { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +}; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition { + public readonly key = ACTION_KEY; + public readonly name = ACTION_NAME; + + constructor(private readonly env: TurnCommandEnv) {} + + parseArgs(_raw: unknown): FierceTrainingArgs | null { + return {}; + } + + buildMinConstraints(_ctx: ConstraintContext, _args: FierceTrainingArgs): Constraint[] { + return [notBeNeutral(), notWanderingNation(), occupiedCity()]; + } + + buildConstraints(_ctx: ConstraintContext, _args: FierceTrainingArgs): Constraint[] { + return [ + notBeNeutral(), + notWanderingNation(), + occupiedCity(), + reqGeneralCrew(), + reqGeneralTrainMargin(this.env.maxTrainByCommand), + ]; + } + + resolve( + context: GeneralActionResolveContext, + _args: FierceTrainingArgs + ): GeneralActionOutcome { + const general = context.general; + + const trainDelta = this.env.trainDelta > 0 ? this.env.trainDelta : 30; + const maxTrain = this.env.maxTrainByCommand > 0 ? this.env.maxTrainByCommand : 100; + const maxAtmos = this.env.maxAtmosByCommand > 0 ? this.env.maxAtmosByCommand : 100; + + const score = Math.round((general.stats.leadership * 100 * trainDelta * 2) / (Math.max(general.crew, 1) * 3)); + const scoreText = score.toLocaleString('en-US'); + + context.addLog(`훈련, 사기치가 ${scoreText} 상승했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + + tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + + const nextTrain = clamp(general.train + score, 0, maxTrain); + const nextAtmos = clamp(general.atmos + score, 0, maxAtmos); + const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0; + + return { + effects: [ + createGeneralPatchEffect({ + rice: Math.max(0, general.rice - 500), + train: nextTrain, + atmos: nextAtmos, + experience: general.experience + 150, + dedication: general.dedication + 100, + meta: { + ...general.meta, + leadership_exp: leadershipExp + 1, + }, + }), + ], + }; + } +} + +export const actionContextBuilder = defaultActionContextBuilder; + +export const commandSpec: GeneralTurnCommandSpec = { + key: ACTION_KEY, + category: '군사', + reqArg: false, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), +}; diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index bc5c31a..7221ca2 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -6,13 +6,19 @@ export const GENERAL_TURN_COMMAND_KEYS = [ 'che_랜덤임관', 'che_귀환', 'che_등용수락', + 'che_장수대상임관', 'che_건국', + 'cr_건국', + 'che_무작위건국', 'che_훈련', + 'cr_맹훈련', + 'che_전투태세', 'che_단련', 'che_숙련전환', 'che_사기진작', 'che_요양', 'che_견문', + 'che_장비매매', 'che_내정특기초기화', 'che_전투특기초기화', 'che_출병', @@ -34,9 +40,14 @@ export const GENERAL_TURN_COMMAND_KEYS = [ 'che_물자조달', 'che_헌납', 'che_이동', + 'che_접경귀환', 'che_방랑', 'che_하야', 'che_은퇴', + 'che_선양', + 'che_모반시도', + 'che_증여', + 'che_해산', 'che_등용', 'che_첩보', 'che_파괴', @@ -60,15 +71,21 @@ const defaultImporters: Record import('./che_거병.js'), che_임관: async () => import('./che_임관.js'), che_등용수락: () => import('./che_등용수락.js'), + che_장수대상임관: () => import('./che_장수대상임관.js'), che_랜덤임관: () => 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'), + cr_맹훈련: async () => import('./cr_맹훈련.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'), @@ -90,9 +107,14 @@ const defaultImporters: Record import('./che_물자조달.js'), che_헌납: async () => import('./che_헌납.js'), che_이동: async () => import('./che_이동.js'), + che_접경귀환: async () => import('./che_접경귀환.js'), che_방랑: async () => import('./che_방랑.js'), che_하야: async () => import('./che_하야.js'), che_은퇴: async () => import('./che_은퇴.js'), + che_선양: async () => import('./che_선양.js'), + che_모반시도: async () => import('./che_모반시도.js'), + che_증여: async () => import('./che_증여.js'), + che_해산: async () => import('./che_해산.js'), che_등용: async () => import('./che_등용.js'), che_첩보: async () => import('./che_첩보.js'), che_파괴: async () => import('./che_파괴.js'), diff --git a/packages/logic/src/constraints/city.ts b/packages/logic/src/constraints/city.ts index 3c15c7a..c6a8b95 100644 --- a/packages/logic/src/constraints/city.ts +++ b/packages/logic/src/constraints/city.ts @@ -51,6 +51,41 @@ export const occupiedCity = (options: { allowNeutral?: boolean } = {}): Constrai }, }); +export const notOccupiedCity = (): Constraint => ({ + name: 'notOccupiedCity', + requires: (ctx) => { + const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }]; + if (ctx.cityId !== undefined) { + reqs.push({ kind: 'city', id: ctx.cityId }); + } + return reqs; + }, + test: (ctx, view) => { + const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(generalReq)) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const general = view.get(generalReq) as General | null; + if (!general) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + + const cityId = ctx.cityId ?? general.cityId; + const cityReq: RequirementKey = { kind: 'city', id: cityId }; + if (!view.has(cityReq)) { + return unknownOrDeny(ctx, [cityReq], '도시 정보가 없습니다.'); + } + const city = view.get(cityReq) as City | null; + if (!city) { + return unknownOrDeny(ctx, [cityReq], '도시 정보가 없습니다.'); + } + if (city.nationId !== general.nationId) { + return allow(); + } + return { kind: 'deny', reason: '아국입니다.' }; + }, +}); + export const occupiedDestCity = (): Constraint => ({ name: 'occupiedDestCity', requires: (ctx) => { @@ -499,6 +534,8 @@ export const beNeutralCity = (): Constraint => ({ }, }); +export const neutralCity = (): Constraint => beNeutralCity(); + export const constructableCity = (): Constraint => ({ name: 'constructableCity', requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), diff --git a/packages/logic/src/constraints/general.ts b/packages/logic/src/constraints/general.ts index 5a7223e..1f303ef 100644 --- a/packages/logic/src/constraints/general.ts +++ b/packages/logic/src/constraints/general.ts @@ -286,6 +286,74 @@ export const noPenalty = (penaltyKey: string): Constraint => ({ }, }); +export const allowRebellion = (): Constraint => ({ + name: 'allowRebellion', + requires: (ctx) => [ + { kind: 'general', id: ctx.actorId }, + { kind: 'generalList' }, + { kind: 'env', key: 'killturn' }, + ], + test: (ctx, view) => { + const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(generalReq)) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const general = view.get(generalReq) as General | null; + if (!general) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + if (general.nationId === 0) { + return { kind: 'deny', reason: '재야입니다.' }; + } + + const generalListReq: RequirementKey = { kind: 'generalList' }; + if (!view.has(generalListReq)) { + return unknownOrDeny(ctx, [generalListReq], '장수 정보가 없습니다.'); + } + const generals = view.get(generalListReq) as General[] | null; + if (!generals) { + return unknownOrDeny(ctx, [generalListReq], '장수 정보가 없습니다.'); + } + + const lord = generals.find((entry) => entry.nationId === general.nationId && entry.officerLevel === 12); + if (!lord) { + return unknownOrDeny(ctx, [generalListReq], '군주 정보가 없습니다.'); + } + if (lord.id === general.id) { + return { kind: 'deny', reason: '이미 군주입니다.' }; + } + + const envReq: RequirementKey = { kind: 'env', key: 'killturn' }; + if (!view.has(envReq)) { + return unknownOrDeny(ctx, [envReq], '턴 정보가 없습니다.'); + } + const rawKillturn = view.get(envReq); + const worldKillturn = + typeof rawKillturn === 'number' + ? rawKillturn + : typeof rawKillturn === 'string' + ? Number(rawKillturn) + : NaN; + if (!Number.isFinite(worldKillturn)) { + return unknownOrDeny(ctx, [envReq], '턴 정보가 없습니다.'); + } + + const lordKillturn = typeof lord.meta.killturn === 'number' ? lord.meta.killturn : NaN; + if (!Number.isFinite(lordKillturn)) { + return unknownOrDeny(ctx, [generalListReq], '군주 정보가 없습니다.'); + } + if (lordKillturn >= worldKillturn) { + return { kind: 'deny', reason: '군주가 활동중입니다.' }; + } + + if ([2, 3, 6, 9].includes(lord.npcState)) { + return { kind: 'deny', reason: '군주가 NPC입니다.' }; + } + + return allow(); + }, +}); + export const reqGeneralValue = ( key: string, keyNick: string, diff --git a/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts b/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts new file mode 100644 index 0000000..188ba5a --- /dev/null +++ b/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts @@ -0,0 +1,405 @@ +import { describe, expect, it } from 'vitest'; +import type { City, General, Nation } from '../../../src/domain/entities.js'; +import type { WorldSnapshot } from '../../../src/world/types.js'; +import { InMemoryWorld, TestGameRunner } from '../../testEnv.js'; +import { MINIMAL_MAP } from '../../fixtures/minimalMap.js'; +import type { TurnCommandEnv, TurnCommandItemCatalogEntry } from '../../../src/actions/turn/commandEnv.js'; +import { commandSpec as rebellionSpec } from '../../../src/actions/turn/general/che_모반시도.js'; +import { commandSpec as abdicationSpec } from '../../../src/actions/turn/general/che_선양.js'; +import { commandSpec as giftSpec } from '../../../src/actions/turn/general/che_증여.js'; +import { commandSpec as disbandSpec } from '../../../src/actions/turn/general/che_해산.js'; +import { commandSpec as foundNationSpec } from '../../../src/actions/turn/general/cr_건국.js'; +import { commandSpec as tradeItemSpec } from '../../../src/actions/turn/general/che_장비매매.js'; +import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js'; +import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js'; + +const SYSTEM_ENV: TurnCommandEnv = { + develCost: 100, + trainDelta: 35, + atmosDelta: 35, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + sabotageDefaultProb: 0.5, + sabotageProbCoefByStat: 0.1, + sabotageDefenceCoefByGeneralCount: 0.1, + sabotageDamageMin: 10, + sabotageDamageMax: 30, + openingPartYear: 200, + maxGeneral: 10, + defaultNpcGold: 1000, + defaultNpcRice: 1000, + defaultCrewTypeId: 1, + defaultSpecialDomestic: null, + defaultSpecialWar: null, + initialNationGenLimit: 10, + maxTechLevel: 10, + baseGold: 1000, + baseRice: 1000, + maxResourceActionAmount: 10000, +}; + +const makeGeneral = (params: { + id: number; + nationId: number; + cityId: number; + name?: string; + officerLevel?: number; + experience?: number; + dedication?: number; + gold?: number; + rice?: number; + crew?: number; + meta?: Record; + items?: Partial; +}): General => ({ + id: params.id, + name: params.name ?? `장수${params.id}`, + nationId: params.nationId, + cityId: params.cityId, + troopId: 0, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + experience: params.experience ?? 0, + dedication: params.dedication ?? 0, + officerLevel: params.officerLevel ?? 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { + horse: params.items?.horse ?? null, + weapon: params.items?.weapon ?? null, + book: params.items?.book ?? null, + item: params.items?.item ?? null, + }, + }, + injury: 0, + gold: params.gold ?? 1000, + rice: params.rice ?? 1000, + crew: params.crew ?? 500, + crewTypeId: 1, + train: 20, + atmos: 20, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { + killturn: 24, + ...(params.meta ?? {}), + }, +}); + +const makeNation = (params: { + id: number; + name?: string; + level?: number; + typeCode?: string; + capitalCityId?: number | null; + chiefGeneralId?: number | null; + gold?: number; + rice?: number; + meta?: Record; +}): Nation => ({ + id: params.id, + name: params.name ?? `국가${params.id}`, + color: '#ff0000', + capitalCityId: params.capitalCityId ?? 1, + chiefGeneralId: params.chiefGeneralId ?? 1, + gold: params.gold ?? 10000, + rice: params.rice ?? 10000, + power: 0, + level: params.level ?? 1, + typeCode: params.typeCode ?? 'che_def', + meta: { + killturn: 24, + ...(params.meta ?? {}), + }, +}); + +const makeCity = (params: { id: number; nationId: number; level?: number; supplyState?: number }): City => ({ + id: params.id, + name: `도시${params.id}`, + nationId: params.nationId, + level: params.level ?? 1, + state: 0, + population: 20000, + populationMax: 50000, + agriculture: 500, + agricultureMax: 1000, + commerce: 500, + commerceMax: 1000, + security: 500, + securityMax: 1000, + defence: 300, + defenceMax: 1000, + wall: 300, + wallMax: 1000, + supplyState: params.supplyState ?? 1, + frontState: 0, + meta: { trust: 50, trade: 100 }, +}); + +const makeSnapshot = (params: { + generals: General[]; + nations: Nation[]; + cities: City[]; + startYear?: number; +}): WorldSnapshot => ({ + scenarioConfig: { + environment: { mapName: 'minimal_map', unitSet: 'default' }, + options: {}, + const: {}, + } as any, + scenarioMeta: { + title: '테스트', + startYear: params.startYear ?? 200, + life: 0, + fiction: 0, + history: [], + ignoreDefaultEvents: false, + }, + map: MINIMAL_MAP, + unitSet: { id: 'default', name: 'default', crewTypes: [] } as any, + nations: params.nations, + cities: params.cities, + generals: params.generals, + troops: [], + diplomacy: [], + events: [], + initialEvents: [], +}); + +describe('migrated general commands', () => { + it('che_모반시도: 군주를 찬탈한다', async () => { + const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, experience: 1000 }); + const chief = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '중신', officerLevel: 2, experience: 300 }); + const nation = makeNation({ id: 1, name: '위', chiefGeneralId: 1, capitalCityId: 1, level: 1 }); + const city = makeCity({ id: 1, nationId: 1 }); + + const world = new InMemoryWorld(makeSnapshot({ generals: [lord, chief], nations: [nation], cities: [city] })); + const runner = new TestGameRunner(world, 201, 1); + + await runner.runTurn([ + { + generalId: chief.id, + commandKey: 'che_모반시도', + resolver: rebellionSpec.createDefinition(SYSTEM_ENV), + args: {}, + context: { + nationGenerals: [lord, chief], + }, + }, + ]); + + const updatedChief = world.getGeneral(chief.id)!; + const updatedLord = world.getGeneral(lord.id)!; + expect(updatedChief.officerLevel).toBe(12); + expect(updatedLord.officerLevel).toBe(1); + expect(updatedLord.experience).toBe(700); + }); + + it('che_선양: 제약 없는 대상에게 선양한다', async () => { + const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, experience: 1000 }); + const heir = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '후계자', officerLevel: 1, experience: 200 }); + const nation = makeNation({ id: 1, name: '촉', chiefGeneralId: 1, capitalCityId: 1, level: 1 }); + const city = makeCity({ id: 1, nationId: 1 }); + + const world = new InMemoryWorld(makeSnapshot({ generals: [lord, heir], nations: [nation], cities: [city] })); + const runner = new TestGameRunner(world, 201, 1); + + await runner.runTurn([ + { + generalId: lord.id, + commandKey: 'che_선양', + resolver: abdicationSpec.createDefinition(SYSTEM_ENV), + args: { destGeneralID: heir.id }, + context: { + destGeneral: heir, + }, + }, + ]); + + const updatedLord = world.getGeneral(lord.id)!; + const updatedHeir = world.getGeneral(heir.id)!; + expect(updatedHeir.officerLevel).toBe(12); + expect(updatedLord.officerLevel).toBe(1); + expect(updatedLord.experience).toBe(700); + }); + + it('che_증여: 최소 보유량을 넘는 자원만 이전한다', async () => { + const actor = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 }); + const dest = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 }); + const nation = makeNation({ id: 1, name: '오', chiefGeneralId: 1, capitalCityId: 1, level: 1 }); + const city = makeCity({ id: 1, nationId: 1 }); + + const world = new InMemoryWorld(makeSnapshot({ generals: [actor, dest], nations: [nation], cities: [city] })); + const runner = new TestGameRunner(world, 201, 1); + + await runner.runTurn([ + { + generalId: actor.id, + commandKey: 'che_증여', + resolver: giftSpec.createDefinition(SYSTEM_ENV), + args: { isGold: true, amount: 500, destGeneralID: dest.id }, + context: { + destGeneral: dest, + }, + }, + ]); + + expect(world.getGeneral(actor.id)!.gold).toBe(1000); + expect(world.getGeneral(dest.id)!.gold).toBe(500); + }); + + it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => { + const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, gold: 1500, rice: 1800 }); + const member = makeGeneral({ id: 2, nationId: 1, cityId: 2, name: '부하', officerLevel: 1, gold: 2500, rice: 500 }); + const nation = makeNation({ + id: 1, + name: '방랑군', + chiefGeneralId: 1, + capitalCityId: 1, + level: 0, + typeCode: 'None', + meta: {}, + }); + const city1 = makeCity({ id: 1, nationId: 1, level: 5 }); + const city2 = makeCity({ id: 2, nationId: 1, level: 5 }); + + const world = new InMemoryWorld( + makeSnapshot({ generals: [lord, member], nations: [nation], cities: [city1, city2] }) + ); + const runner = new TestGameRunner(world, 201, 2); + + await runner.runTurn([ + { + generalId: lord.id, + commandKey: 'che_해산', + resolver: disbandSpec.createDefinition(SYSTEM_ENV), + args: {}, + context: { + nationGenerals: [lord, member], + nationCities: [city1, city2], + currentYearMonth: 201 * 12 + 2 - 1, + initYearMonth: 201 * 12 + 1 - 1, + }, + }, + ]); + + const updatedLord = world.getGeneral(lord.id)!; + const updatedMember = world.getGeneral(member.id)!; + expect(updatedLord.nationId).toBe(0); + expect(updatedMember.nationId).toBe(0); + expect(updatedLord.meta.makelimit).toBe(12); + expect(updatedLord.gold).toBe(1000); + expect(updatedMember.gold).toBe(1000); + expect(world.getCity(1)!.nationId).toBe(0); + expect(world.getCity(2)!.nationId).toBe(0); + expect(world.getNation(1)!.meta.collapsed).toBe(true); + }); + + it('cr_건국: 국가 정보를 건국 상태로 갱신한다', async () => { + const lord = makeGeneral({ + id: 1, + nationId: 1, + cityId: 1, + name: '건국자', + officerLevel: 12, + experience: 200, + dedication: 300, + }); + const follower = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '추종자', officerLevel: 1 }); + const nation = makeNation({ + id: 1, + name: '옛국가', + chiefGeneralId: 1, + capitalCityId: null, + level: 0, + typeCode: 'None', + meta: {}, + }); + const city = makeCity({ id: 1, nationId: 0, level: 5 }); + + const world = new InMemoryWorld( + makeSnapshot({ generals: [lord, follower], nations: [nation], cities: [city], startYear: 200 }) + ); + const runner = new TestGameRunner(world, 201, 1); + + await runner.runTurn([ + { + generalId: lord.id, + commandKey: 'cr_건국', + resolver: foundNationSpec.createDefinition(SYSTEM_ENV), + args: { nationName: '신국', nationType: 'che_def', colorType: 1 }, + }, + ]); + + const updatedNation = world.getNation(1)!; + const updatedLord = world.getGeneral(1)!; + expect(updatedNation.name).toBe('신국'); + expect(updatedNation.level).toBe(1); + expect(updatedNation.capitalCityId).toBe(1); + expect(world.getCity(1)!.nationId).toBe(1); + expect(updatedLord.experience).toBe(1200); + expect(updatedLord.dedication).toBe(1300); + }); + + it('che_장비매매: 동일 장비 재구매는 AlwaysFail 제약으로 막는다', () => { + const general = makeGeneral({ + id: 10, + nationId: 1, + cityId: 1, + gold: 5000, + items: { weapon: 'testSword' }, + }); + const city = makeCity({ id: 1, nationId: 1, level: 5 }); + const nation = makeNation({ id: 1, level: 1, chiefGeneralId: 10, capitalCityId: 1 }); + + const itemCatalog: Record = { + testSword: { + slot: 'weapon', + name: '테스트검', + rawName: '테스트검', + cost: 500, + reqSecu: 0, + buyable: true, + unique: false, + }, + }; + + const definition = tradeItemSpec.createDefinition({ + ...SYSTEM_ENV, + itemCatalog, + }); + const args = { itemType: 'weapon' as const, itemCode: 'testSword' }; + + const context: ConstraintContext = { + actorId: general.id, + cityId: city.id, + nationId: nation.id, + args, + env: {}, + mode: 'full', + }; + + const view: StateView = { + has: (req: RequirementKey) => { + if (req.kind === 'general') return req.id === general.id; + if (req.kind === 'city') return req.id === city.id; + if (req.kind === 'nation') return req.id === nation.id; + return false; + }, + get: (req: RequirementKey) => { + if (req.kind === 'general' && req.id === general.id) return general; + if (req.kind === 'city' && req.id === city.id) return city; + if (req.kind === 'nation' && req.id === nation.id) return nation; + return null; + }, + }; + + const result = evaluateActionConstraints(definition, context, view, args); + expect(result.kind).toBe('deny'); + if (result.kind === 'deny') { + expect(result.constraintName).toBe('AlwaysFail'); + } + }); +}); diff --git a/tools/compare-command-logs.ignore.json b/tools/compare-command-logs.ignore.json index 212ce53..7c58be9 100644 --- a/tools/compare-command-logs.ignore.json +++ b/tools/compare-command-logs.ignore.json @@ -13,5 +13,23 @@ "다음 턴부터 건국할 수 있습니다." ], "regex": [] + }, + "General/che_모반시도": { + "templates": [ + "${}에게 군주의 자리를 뺏겼습니다." + ], + "regex": [] + }, + "General/che_선양": { + "templates": [ + "${}에게서 군주의 자리를 물려받습니다." + ], + "regex": [] + }, + "General/che_장비매매": { + "templates": [ + "${}" + ], + "regex": [] } }