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