From 4fe4af868a369ff97583b9f35bd94ad851b0bc05 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 4 Jan 2026 06:58:43 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B6=94=EA=B0=80=EB=90=9C=20=EC=A0=84?= =?UTF-8?q?=ED=88=AC=20=EB=B0=8F=20=EB=82=B4=EC=A0=95=20=ED=8A=B9=EA=B8=B0?= =?UTF-8?q?=20=EC=B4=88=EA=B8=B0=ED=99=94,=20=EB=8B=A8=EB=A0=A8,=20?= =?UTF-8?q?=EC=88=99=EB=A0=A8=20=EC=A0=84=ED=99=98=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EA=B4=91=20=EB=AA=85=EB=A0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/actions/turn/general/che_견문.ts | 269 ++++++++++++++++++ .../turn/general/che_내정특기초기화.ts | 81 ++++++ .../src/actions/turn/general/che_단련.ts | 228 +++++++++++++++ .../src/actions/turn/general/che_숙련전환.ts | 163 +++++++++++ .../turn/general/che_전투특기초기화.ts | 81 ++++++ .../logic/src/actions/turn/general/index.ts | 10 + resources/turn-commands/default.json | 5 + 7 files changed, 837 insertions(+) create mode 100644 packages/logic/src/actions/turn/general/che_견문.ts create mode 100644 packages/logic/src/actions/turn/general/che_내정특기초기화.ts create mode 100644 packages/logic/src/actions/turn/general/che_단련.ts create mode 100644 packages/logic/src/actions/turn/general/che_숙련전환.ts create mode 100644 packages/logic/src/actions/turn/general/che_전투특기초기화.ts 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..08486f2 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_견문.ts @@ -0,0 +1,269 @@ +import type { + GeneralTriggerState, +} from '@sammo-ts/logic/domain/entities.js'; +import type { + Constraint, + ConstraintContext, +} from '@sammo-ts/logic/constraints/types.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '@sammo-ts/logic/actions/engine.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { GeneralTurnCommandSpec } from './index.js'; +import { increaseMetaNumber } from '@sammo-ts/logic/war/utils.js'; + +export interface SightseeingArgs {} + +const ACTION_NAME = '견문'; + +const IncExp = 0x1; +const IncHeavyExp = 0x2; +const IncLeadership = 0x10; +const IncStrength = 0x20; +const IncIntel = 0x40; +const IncGold = 0x100; +const IncRice = 0x200; +const DecGold = 0x400; +const DecRice = 0x800; +const Wounded = 0x1000; +const HeavyWounded = 0x2000; + +const SIGHTSEEING_MESSAGES: Array<{ + flags: number; + texts: string[]; + weight: number; +}> = [ + { + flags: IncExp, + texts: [ + '아무일도 일어나지 않았습니다.', + '명사와 설전을 벌였으나 망신만 당했습니다.', + '동네 장사와 힘겨루기를 했지만 망신만 당했습니다.', + ], + weight: 1, + }, + { + flags: IncHeavyExp, + texts: [ + '주점에서 사람들과 어울려 술을 마셨습니다.', + '위기에 빠진 사람을 구해주었습니다.', + ], + weight: 1, + }, + { + flags: IncHeavyExp | IncLeadership, + texts: [ + '백성들에게 현인의 가르침을 설파했습니다.', + '어느 집의 도망친 가축을 되찾아 주었습니다.', + ], + weight: 2, + }, + { + flags: IncHeavyExp | IncStrength, + texts: [ + '동네 장사와 힘겨루기를 하여 멋지게 이겼습니다.', + '어느 집의 무너진 울타리를 고쳐주었습니다.', + ], + weight: 2, + }, + { + flags: IncHeavyExp | IncIntel, + texts: [ + '어느 명사와 설전을 벌여 멋지게 이겼습니다.', + '거리에서 글 모르는 아이들을 모아 글을 가르쳤습니다.', + ], + weight: 2, + }, + { + flags: IncExp | IncGold, + texts: ['지나가는 행인에게서 금을 :goldAmount: 받았습니다.'], + weight: 1, + }, + { + flags: IncExp | IncRice, + texts: ['지나가는 행인에게서 쌀을 :riceAmount: 받았습니다.'], + weight: 1, + }, + { + flags: IncExp | DecGold, + texts: [ + '산적을 만나 금 :goldAmount:을 빼앗겼습니다.', + '돈을 :goldAmount: 빌려주었다가 떼어먹혔습니다.', + ], + weight: 1, + }, + { + flags: IncExp | DecRice, + texts: ['쌀을 :riceAmount: 빌려주었다가 떼어먹혔습니다.'], + weight: 1, + }, + { + flags: IncExp | Wounded, + texts: ['호랑이에게 물려 다쳤습니다.', '곰에게 할퀴어 다쳤습니다.'], + weight: 1, + }, + { + flags: IncHeavyExp | Wounded, + texts: ['위기에 빠진 사람을 구해주다가 다쳤습니다.'], + weight: 1, + }, + { + flags: IncExp | HeavyWounded, + texts: ['호랑이에게 물려 크게 다쳤습니다.', '곰에게 할퀴어 크게 다쳤습니다.'], + weight: 1, + }, + { + flags: IncHeavyExp | Wounded | HeavyWounded, + texts: ['위기에 빠진 사람을 구하다가 죽을뻔 했습니다.'], + weight: 1, + }, + { + flags: IncHeavyExp | IncStrength | IncGold, + texts: ['산적과 싸워 금 :goldAmount:을 빼앗았습니다.'], + weight: 1, + }, + { + flags: IncHeavyExp | IncStrength | IncRice, + texts: [ + '호랑이를 잡아 고기 :riceAmount:을 얻었습니다.', + '곰을 잡아 고기 :riceAmount:을 얻었습니다.', + ], + weight: 1, + }, + { + flags: IncHeavyExp | IncIntel | IncGold, + texts: ['돈을 빌려주었다가 이자 :goldAmount:을 받았습니다.'], + weight: 1, + }, + { + flags: IncHeavyExp | IncIntel | IncRice, + texts: ['쌀을 빌려주었다가 이자 :riceAmount:을 받았습니다.'], + weight: 1, + }, +]; + +const pickByWeight = ( + rng: GeneralActionResolveContext['rng'] +): { flags: number; text: string } => { + if (SIGHTSEEING_MESSAGES.length === 0) { + return { flags: 0, text: '' }; + } + const total = SIGHTSEEING_MESSAGES.reduce( + (sum, entry) => sum + Math.max(entry.weight, 0), + 0 + ); + const base = SIGHTSEEING_MESSAGES[0]; + if (!base) { + return { flags: 0, text: '' }; + } + if (total <= 0) { + const text = base.texts[0] ?? ''; + return { flags: base.flags, text }; + } + let cursor = rng.nextFloat() * total; + for (const entry of SIGHTSEEING_MESSAGES) { + const weight = Math.max(entry.weight, 0); + cursor -= weight; + if (cursor <= 0) { + const index = rng.nextInt(0, entry.texts.length); + const text = entry.texts[index] ?? entry.texts[0] ?? ''; + return { flags: entry.flags, text }; + } + } + const fallback = SIGHTSEEING_MESSAGES[SIGHTSEEING_MESSAGES.length - 1]; + if (!fallback) { + return { flags: 0, text: '' }; + } + const index = rng.nextInt(0, fallback.texts.length); + const text = fallback.texts[index] ?? fallback.texts[0] ?? ''; + return { flags: fallback.flags, text }; +}; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_견문'; + public readonly name = ACTION_NAME; + + parseArgs(_raw: unknown): SightseeingArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: SightseeingArgs + ): Constraint[] { + return []; + } + + resolve( + context: GeneralActionResolveContext, + _args: SightseeingArgs + ): GeneralActionOutcome { + const general = context.general; + const picked = pickByWeight(context.rng); + let message = picked.text; + let exp = 0; + + if (picked.flags & IncExp) { + exp += 30; + } + if (picked.flags & IncHeavyExp) { + exp += 60; + } + if (picked.flags & IncLeadership) { + increaseMetaNumber(general.meta, 'leadership_exp', 2); + } + if (picked.flags & IncStrength) { + increaseMetaNumber(general.meta, 'strength_exp', 2); + } + if (picked.flags & IncIntel) { + increaseMetaNumber(general.meta, 'intel_exp', 2); + } + if (picked.flags & IncGold) { + general.gold += 300; + message = message.replace(':goldAmount:', '300'); + } + if (picked.flags & IncRice) { + general.rice += 300; + message = message.replace(':riceAmount:', '300'); + } + if (picked.flags & DecGold) { + general.gold = Math.max(0, general.gold - 200); + message = message.replace(':goldAmount:', '200'); + } + if (picked.flags & DecRice) { + general.rice = Math.max(0, general.rice - 200); + message = message.replace(':riceAmount:', '200'); + } + if (picked.flags & Wounded) { + const delta = context.rng.nextInt(10, 21); + general.injury = Math.min(80, general.injury + delta); + } + if (picked.flags & HeavyWounded) { + const delta = context.rng.nextInt(20, 51); + general.injury = Math.min(80, general.injury + delta); + } + + general.experience += exp; + + context.addLog(message); + + return { effects: [] }; + } +} + +// 예약 턴 실행은 기본 컨텍스트만 사용한다. +export const actionContextBuilder = defaultActionContextBuilder; + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_견문', + category: '개인', + reqArg: false, + args: {}, + 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..2b9b680 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_내정특기초기화.ts @@ -0,0 +1,81 @@ +import type { + GeneralTriggerState, +} from '@sammo-ts/logic/domain/entities.js'; +import type { + Constraint, + ConstraintContext, +} from '@sammo-ts/logic/constraints/types.js'; +import { allow, unknownOrDeny, readGeneral } 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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { GeneralTurnCommandSpec } from './index.js'; +import { setMetaNumber } from '@sammo-ts/logic/war/utils.js'; + +export interface ResetSpecialDomesticArgs {} + +const ACTION_NAME = '내정 특기 초기화'; + +const hasSpecial = (value: string | null | undefined): boolean => + value !== null && value !== undefined && value !== 'None'; + +const reqDomesticSpecial = (): Constraint => ({ + name: 'ReqGeneralDomesticSpecial', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const general = readGeneral(ctx, view); + if (!general) { + const req = { kind: 'general', id: ctx.actorId } as const; + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + if (hasSpecial(general.role.specialDomestic)) { + return allow(); + } + return { kind: 'deny', reason: '특기가 없습니다.' }; + }, +}); + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_내정특기초기화'; + public readonly name = ACTION_NAME; + + parseArgs(_raw: unknown): ResetSpecialDomesticArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: ResetSpecialDomesticArgs + ): Constraint[] { + return [reqDomesticSpecial()]; + } + + resolve( + context: GeneralActionResolveContext, + _args: ResetSpecialDomesticArgs + ): GeneralActionOutcome { + const general = context.general; + general.role.specialDomestic = null; + setMetaNumber(general.meta, 'specAge', general.age + 1); + context.addLog('새로운 내정 특기를 가질 준비가 되었습니다.'); + return { effects: [] }; + } +} + +// 예약 턴 실행은 기본 컨텍스트만 사용한다. +export const actionContextBuilder = defaultActionContextBuilder; + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_내정특기초기화', + category: '개인', + reqArg: false, + args: {}, + 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..48293dd --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_단련.ts @@ -0,0 +1,228 @@ +import type { + GeneralTriggerState, +} from '@sammo-ts/logic/domain/entities.js'; +import type { + Constraint, + ConstraintContext, + StateView, +} from '@sammo-ts/logic/constraints/types.js'; +import { + notBeNeutral, + reqGeneralCrew, + reqGeneralGold, + reqGeneralRice, +} from '@sammo-ts/logic/constraints/presets.js'; +import { allow, unknownOrDeny, readGeneral } 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 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 type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; +import { JosaUtil } from '@sammo-ts/common'; +import { getMetaNumber, setMetaNumber, increaseMetaNumber } from '@sammo-ts/logic/war/utils.js'; + +export interface DrillArgs {} + +export interface DrillContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + unitSet?: UnitSetDefinition | null; +} + +export interface DrillEnvironment { + develCost?: number; + defaultTrainLow?: number; + defaultAtmosLow?: number; +} + +type DrillPick = 'success' | 'normal' | 'fail'; + +const ACTION_NAME = '단련'; + +const resolveArmTypeName = ( + unitSet: UnitSetDefinition, + armType: number +): string => + unitSet.armTypes?.[String(armType)] ?? `병종${armType}`; + +const pickByWeight = ( + rng: DrillContext['rng'], + weights: Record +): T => { + const entries = Object.entries(weights) as Array<[T, number]>; + const first = entries[0]; + if (!first) { + throw new Error('Empty weights'); + } + let total = 0; + for (const [, weight] of entries) { + if (weight > 0) { + total += weight; + } + } + if (total <= 0) { + return first[0]; + } + let cursor = rng.nextFloat() * total; + for (const [key, weight] of entries) { + if (weight <= 0) { + continue; + } + cursor -= weight; + if (cursor <= 0) { + return key; + } + } + const last = entries[entries.length - 1]; + return last ? last[0] : first[0]; +}; + +const reqGeneralStat = ( + key: 'train' | 'atmos', + label: string, + minValue: number +): Constraint => ({ + name: `ReqGeneral${label}`, + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const general = readGeneral(ctx, view); + if (!general) { + const req = { kind: 'general', id: ctx.actorId } as const; + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + if (general[key] >= minValue) { + return allow(); + } + const josa = JosaUtil.pick(label, '이'); + return { kind: 'deny', reason: `${label}${josa} 부족합니다.` }; + }, +}); + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition< + TriggerState, + DrillArgs, + DrillContext +> { + public readonly key = 'che_단련'; + public readonly name = ACTION_NAME; + private readonly env: DrillEnvironment; + + constructor(env: DrillEnvironment = {}) { + this.env = env; + } + + parseArgs(_raw: unknown): DrillArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: DrillArgs + ): Constraint[] { + const trainLow = this.env.defaultTrainLow ?? 40; + const atmosLow = this.env.defaultAtmosLow ?? 40; + const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => + this.env.develCost ?? 0; + const getRequiredRice = (_context: ConstraintContext, _view: StateView): number => + this.env.develCost ?? 0; + return [ + notBeNeutral(), + reqGeneralCrew(), + reqGeneralStat('train', '훈련', trainLow), + reqGeneralStat('atmos', '사기', atmosLow), + reqGeneralGold(getRequiredGold), + reqGeneralRice(getRequiredRice), + ]; + } + + resolve( + context: DrillContext, + _args: DrillArgs + ): GeneralActionOutcome { + if (!context.unitSet) { + context.addLog('병종 정보를 확인할 수 없어 단련을 진행할 수 없습니다.'); + return { effects: [] }; + } + + const general = context.general; + const crewType = context.unitSet.crewTypes?.find( + (entry) => entry.id === general.crewTypeId + ); + if (!crewType) { + context.addLog('병종 정보를 확인할 수 없어 단련을 진행할 수 없습니다.'); + return { effects: [] }; + } + + const pick = pickByWeight(context.rng, { + success: 0.34, + normal: 0.33, + fail: 0.33, + }); + const multiplier = pick === 'success' ? 3 : pick === 'normal' ? 2 : 1; + + const baseScore = Math.round( + (general.crew * general.train * general.atmos) / 20 / 10000 + ); + const score = baseScore * multiplier; + const armTypeName = resolveArmTypeName( + context.unitSet, + crewType.armType + ); + const logPrefix = + pick === 'success' + ? '단련이 일취월장하여' + : pick === 'fail' + ? '단련이 지지부진하여' + : ''; + const logText = logPrefix + ? `${logPrefix} ${armTypeName} 숙련도가 ${score} 향상되었습니다.` + : `${armTypeName} 숙련도가 ${score} 향상되었습니다.`; + + const dexKey = `dex${crewType.armType}`; + const nextDex = getMetaNumber(general.meta, dexKey, 0) + score; + setMetaNumber(general.meta, dexKey, nextDex); + + const expGain = general.crew / 400; + general.experience += expGain; + + const statKey = pickByWeight(context.rng, { + leadership_exp: general.stats.leadership, + strength_exp: general.stats.strength, + intel_exp: general.stats.intelligence, + }); + increaseMetaNumber(general.meta, statKey, 1); + + const cost = this.env.develCost ?? 0; + general.gold = Math.max(0, general.gold - cost); + general.rice = Math.max(0, general.rice - cost); + + context.addLog(logText); + + return { effects: [] }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => ({ + ...base, + unitSet: options.unitSet ?? null, +}); + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_단련', + category: '군사', + reqArg: false, + args: {}, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ + develCost: env.develCost, + }), +}; 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..5a236ad --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_숙련전환.ts @@ -0,0 +1,163 @@ +import type { + GeneralTriggerState, +} from '@sammo-ts/logic/domain/entities.js'; +import type { + Constraint, + ConstraintContext, + StateView, +} from '@sammo-ts/logic/constraints/types.js'; +import { + notBeNeutral, + occupiedCity, + 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 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 type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; +import { JosaUtil } from '@sammo-ts/common'; +import { getMetaNumber, setMetaNumber, increaseMetaNumber } from '@sammo-ts/logic/war/utils.js'; + +export interface DexTransferArgs { + srcArmType: number; + destArmType: number; +} + +export interface DexTransferContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + unitSet?: UnitSetDefinition | null; +} + +export interface DexTransferEnvironment { + develCost?: number; +} + +const ACTION_NAME = '숙련전환'; +const DECREASE_COEFF = 0.4; +const CONVERT_COEFF = 0.9; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const resolveArmType = (value: unknown): number | null => { + if (typeof value !== 'number' || !Number.isInteger(value)) { + return null; + } + return value > 0 ? value : null; +}; + +const resolveArmTypeName = ( + unitSet: UnitSetDefinition | null | undefined, + armType: number +): string => + unitSet?.armTypes?.[String(armType)] ?? `병종${armType}`; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition< + TriggerState, + DexTransferArgs, + DexTransferContext +> { + public readonly key = 'che_숙련전환'; + public readonly name = ACTION_NAME; + private readonly env: DexTransferEnvironment; + + constructor(env: DexTransferEnvironment = {}) { + this.env = env; + } + + parseArgs(raw: unknown): DexTransferArgs | null { + const data = isRecord(raw) ? raw : {}; + const srcArmType = resolveArmType(data.srcArmType); + const destArmType = resolveArmType(data.destArmType); + if (srcArmType === null || destArmType === null) { + return null; + } + if (srcArmType === destArmType) { + return null; + } + return { srcArmType, destArmType }; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: DexTransferArgs + ): Constraint[] { + const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => + this.env.develCost ?? 0; + const getRequiredRice = (_context: ConstraintContext, _view: StateView): number => + this.env.develCost ?? 0; + return [ + notBeNeutral(), + occupiedCity(), + reqGeneralGold(getRequiredGold), + reqGeneralRice(getRequiredRice), + ]; + } + + resolve( + context: DexTransferContext, + args: DexTransferArgs + ): GeneralActionOutcome { + const general = context.general; + const srcKey = `dex${args.srcArmType}`; + const destKey = `dex${args.destArmType}`; + const srcDex = getMetaNumber(general.meta, srcKey, 0); + const cutDex = Math.trunc(srcDex * DECREASE_COEFF); + const addDex = Math.trunc(cutDex * CONVERT_COEFF); + + setMetaNumber(general.meta, srcKey, srcDex - cutDex); + setMetaNumber( + general.meta, + destKey, + getMetaNumber(general.meta, destKey, 0) + addDex + ); + + const srcName = resolveArmTypeName( + context.unitSet, + args.srcArmType + ); + const destName = resolveArmTypeName( + context.unitSet, + args.destArmType + ); + const cutJosa = JosaUtil.pick(String(cutDex), '을'); + const addJosa = JosaUtil.pick(String(addDex), '으로'); + + const cost = this.env.develCost ?? 0; + general.gold = Math.max(0, general.gold - cost); + general.rice = Math.max(0, general.rice - cost); + general.experience += 10; + increaseMetaNumber(general.meta, 'leadership_exp', 2); + + context.addLog( + `${srcName} 숙련 ${cutDex}${cutJosa} ${destName} 숙련 ${addDex}${addJosa} 전환했습니다.` + ); + + return { effects: [] }; + } +} + +export const actionContextBuilder: ActionContextBuilder = (base, options) => ({ + ...base, + unitSet: options.unitSet ?? null, +}); + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_숙련전환', + category: '군사', + reqArg: true, + args: { srcArmType: 0, destArmType: 0 }, + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ develCost: env.develCost }), +}; 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..c8d12ad --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_전투특기초기화.ts @@ -0,0 +1,81 @@ +import type { + GeneralTriggerState, +} from '@sammo-ts/logic/domain/entities.js'; +import type { + Constraint, + ConstraintContext, +} from '@sammo-ts/logic/constraints/types.js'; +import { allow, unknownOrDeny, readGeneral } 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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { GeneralTurnCommandSpec } from './index.js'; +import { setMetaNumber } from '@sammo-ts/logic/war/utils.js'; + +export interface ResetSpecialWarArgs {} + +const ACTION_NAME = '전투 특기 초기화'; + +const hasSpecial = (value: string | null | undefined): boolean => + value !== null && value !== undefined && value !== 'None'; + +const reqWarSpecial = (): Constraint => ({ + name: 'ReqGeneralWarSpecial', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const general = readGeneral(ctx, view); + if (!general) { + const req = { kind: 'general', id: ctx.actorId } as const; + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + if (hasSpecial(general.role.specialWar)) { + return allow(); + } + return { kind: 'deny', reason: '특기가 없습니다.' }; + }, +}); + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_전투특기초기화'; + public readonly name = ACTION_NAME; + + parseArgs(_raw: unknown): ResetSpecialWarArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: ResetSpecialWarArgs + ): Constraint[] { + return [reqWarSpecial()]; + } + + resolve( + context: GeneralActionResolveContext, + _args: ResetSpecialWarArgs + ): GeneralActionOutcome { + const general = context.general; + general.role.specialWar = null; + setMetaNumber(general.meta, 'specAge2', general.age + 1); + context.addLog('새로운 전투 특기를 가질 준비가 되었습니다.'); + return { effects: [] }; + } +} + +// 예약 턴 실행은 기본 컨텍스트만 사용한다. +export const actionContextBuilder = defaultActionContextBuilder; + +export const commandSpec: GeneralTurnCommandSpec = { + key: 'che_전투특기초기화', + category: '개인', + reqArg: false, + args: {}, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index 168d654..ddaca93 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -5,8 +5,13 @@ export const GENERAL_TURN_COMMAND_KEYS = [ 'che_임관', 'che_건국', 'che_훈련', + 'che_단련', + 'che_숙련전환', 'che_사기진작', 'che_요양', + 'che_견문', + 'che_내정특기초기화', + 'che_전투특기초기화', 'che_출병', 'che_주민선정', 'che_농지개간', @@ -40,8 +45,13 @@ 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'), che_주민선정: async () => import('./che_주민선정.js'), che_농지개간: async () => import('./che_농지개간.js'), diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index cfa4332..ff5cf84 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -4,8 +4,13 @@ "che_임관", "che_건국", "che_훈련", + "che_단련", + "che_숙련전환", "che_사기진작", "che_요양", + "che_견문", + "che_내정특기초기화", + "che_전투특기초기화", "che_출병", "che_주민선정", "che_농지개간",