feat: 추가된 전투 및 내정 특기 초기화, 단련, 숙련 전환 및 관광 명령
This commit is contained in:
@@ -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<TriggerState, SightseeingArgs> {
|
||||
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<TriggerState>,
|
||||
_args: SightseeingArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
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(),
|
||||
};
|
||||
@@ -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<TriggerState, ResetSpecialDomesticArgs> {
|
||||
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<TriggerState>,
|
||||
_args: ResetSpecialDomesticArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
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(),
|
||||
};
|
||||
@@ -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<TriggerState> {
|
||||
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 = <T extends string>(
|
||||
rng: DrillContext['rng'],
|
||||
weights: Record<T, number>
|
||||
): 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<TriggerState>
|
||||
> {
|
||||
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<TriggerState>,
|
||||
_args: DrillArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
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<DrillPick>(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,
|
||||
}),
|
||||
};
|
||||
@@ -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<TriggerState> {
|
||||
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<string, unknown> =>
|
||||
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<TriggerState>
|
||||
> {
|
||||
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<TriggerState>,
|
||||
args: DexTransferArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
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 }),
|
||||
};
|
||||
@@ -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<TriggerState, ResetSpecialWarArgs> {
|
||||
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<TriggerState>,
|
||||
_args: ResetSpecialWarArgs
|
||||
): GeneralActionOutcome<TriggerState> {
|
||||
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(),
|
||||
};
|
||||
@@ -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'),
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
"che_임관",
|
||||
"che_건국",
|
||||
"che_훈련",
|
||||
"che_단련",
|
||||
"che_숙련전환",
|
||||
"che_사기진작",
|
||||
"che_요양",
|
||||
"che_견문",
|
||||
"che_내정특기초기화",
|
||||
"che_전투특기초기화",
|
||||
"che_출병",
|
||||
"che_주민선정",
|
||||
"che_농지개간",
|
||||
|
||||
Reference in New Issue
Block a user