feat: preserve legacy nation turn execution

This commit is contained in:
2026-07-25 06:17:32 +00:00
parent 5040691d7c
commit 85f134fd12
36 changed files with 1346 additions and 283 deletions
+5
View File
@@ -14,5 +14,10 @@ export interface GeneralActionDefinition<
// 커맨드 입력 단계에서 최소 조건만 평가할 때 사용한다.
buildMinConstraints?(ctx: ConstraintContext, args: Args): Constraint[];
buildConstraints(ctx: ConstraintContext, args: Args): Constraint[];
// NationCommand::addTermStack()/setNextAvailable() 호환 실행 메타데이터.
getPreReqTurn?(context: Context, args: Args): number;
getPostReqTurn?(context: Context, args: Args): number;
getStackSequence?(context: Context, args: Args): number | null;
readonly countsAsInheritanceActiveAction?: boolean;
resolve(context: Context, args: Args): GeneralActionOutcome<TriggerState>;
}
+13
View File
@@ -12,6 +12,7 @@ import type {
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import { getNextTurnAt, type TurnSchedule } from '@sammo-ts/logic/turn/calendar.js';
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import type { MessageDraft } from '@sammo-ts/logic/messages/message.js';
enablePatches();
@@ -86,6 +87,11 @@ export interface NextTurnOverrideEffect {
nextTurnAt: Date;
}
export interface MessageAddEffect {
type: 'message:add';
draft: MessageDraft;
}
export type GeneralActionEffect<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
| GeneralPatchEffect<TriggerState>
| GeneralAddEffect<TriggerState>
@@ -94,6 +100,7 @@ export type GeneralActionEffect<TriggerState extends GeneralTriggerState = Gener
| NationAddEffect
| DiplomacyPatchEffect
| LogEffect
| MessageAddEffect
| NextTurnOverrideEffect;
export interface GeneralActionOutcome<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
@@ -203,6 +210,11 @@ export const createLogEffect = (message: string, options: Partial<Omit<LogEntryD
},
});
export const createMessageEffect = (draft: MessageDraft): MessageAddEffect => ({
type: 'message:add',
draft,
});
export const createNextTurnOverrideEffect = (nextTurnAt: Date): NextTurnOverrideEffect => ({
type: 'schedule:override',
nextTurnAt,
@@ -301,6 +313,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
createdNations.push(effect.nation as Nation);
break;
case 'diplomacy:patch':
case 'message:add':
pendingEffects.push(effect);
break;
case 'general:patch':
@@ -47,14 +47,15 @@ const requireCapitalCity = (reason: string): Constraint => ({
},
});
const reqDestCityValue = (
comp: '>' | '<' | '>=' | '<=',
required: number | 'origin',
reason: string
): Constraint => ({
const reqDestCityValue = (comp: '>' | '<' | '>=' | '<=', required: number | 'origin', reason: string): Constraint => ({
name: 'reqDestCityValue',
requires: (ctx) =>
ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }, { kind: 'env', key: 'map' }] : [],
ctx.nationId !== undefined
? [
{ kind: 'nation', id: ctx.nationId },
{ kind: 'env', key: 'map' },
]
: [],
test: (ctx: ConstraintContext, view: StateView) => {
if (ctx.nationId === undefined) {
return { kind: 'deny', reason };
@@ -71,8 +72,7 @@ const reqDestCityValue = (
required === 'origin'
? ((view.get({ kind: 'env', key: 'map' }) as MapDefinition | undefined)?.cities.find(
(mapCity) => mapCity.id === nation.capitalCityId
)?.level ??
0)
)?.level ?? 0)
: required;
const level = city.level;
const allow =
@@ -95,6 +95,7 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, ReduceCityArgs, ReduceCityResolveContext<TriggerState>> {
public readonly key = 'che_감축';
public readonly name = ACTION_NAME;
public readonly countsAsInheritanceActiveAction = true;
constructor(private readonly env: TurnCommandEnv) {}
@@ -122,6 +123,15 @@ export class ActionDefinition<
];
}
getPreReqTurn(): number {
return 5;
}
getStackSequence(context: ReduceCityResolveContext<TriggerState>): number {
const value = context.nation?.meta.capset;
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
}
resolve(
context: ReduceCityResolveContext<TriggerState>,
_args: ReduceCityArgs
@@ -163,6 +173,10 @@ export class ActionDefinition<
{
gold: nation.gold + recoverAmount,
rice: nation.rice + recoverAmount,
meta: {
...nation.meta,
capset: (typeof nation.meta.capset === 'number' ? nation.meta.capset : 0) + 1,
},
},
nation.id
),
@@ -194,6 +208,11 @@ export class ActionDefinition<
format: LogFormat.YEAR_MONTH,
}
),
createLogEffect(`<G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
// General Action Log
createLogEffect(`<G><b>${destCityName}</b></>${josaUl} ${ACTION_NAME}했습니다.`, {
scope: LogScope.GENERAL,
@@ -54,7 +54,11 @@ const NATION_COLORS = [
];
const ARGS_SCHEMA = z.object({
colorType: z.number().int().min(0).max(NATION_COLORS.length - 1),
colorType: z
.number()
.int()
.min(0)
.max(NATION_COLORS.length - 1),
});
export type ChangeFlagArgs = z.infer<typeof ARGS_SCHEMA>;
@@ -63,6 +67,7 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, ChangeFlagArgs> {
public readonly key = 'che_국기변경';
public readonly name = ACTION_NAME;
public readonly countsAsInheritanceActiveAction = true;
parseArgs(raw: unknown): ChangeFlagArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
@@ -141,6 +146,11 @@ export class ActionDefinition<
format: LogFormat.YEAR_MONTH,
}
),
createLogEffect(`<span style='color:${color};'><b>국기</b></span>를 변경`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
// General Action Log
createLogEffect(`<span style='color:${color};'><b>국기</b></span>를 변경하였습니다`, {
scope: LogScope.GENERAL,
@@ -27,6 +27,7 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, ChangeNationNameArgs> {
public readonly key = 'che_국호변경';
public readonly name = ACTION_NAME;
public readonly countsAsInheritanceActiveAction = true;
parseArgs(raw: unknown): ChangeNationNameArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
@@ -101,6 +102,11 @@ export class ActionDefinition<
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
createLogEffect(`국호를 <D><b>${newNationName}</b></>${josaRo} 변경`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
// General Action Log
createLogEffect(`국호를 <D><b>${newNationName}</b></>${josaRo} 변경합니다.`, {
scope: LogScope.GENERAL,
@@ -53,10 +53,19 @@ const TERM_REDUCE = 3;
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
private readonly initialNationGenLimit = 10
) {
this.pipeline = new GeneralActionPipeline(modules);
}
getPostReqTurn(context: RaidResolveContext<TriggerState>): number {
const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit);
const base = Math.round(Math.sqrt(genCount * 16) * 10);
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base));
}
getGlobalDelay(context: RaidResolveContext<TriggerState>): number {
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY));
}
@@ -69,8 +78,12 @@ export class ActionResolver<
readonly key = 'che_급습';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.command = new CommandResolver(modules, initialNationGenLimit);
}
getPostReqTurn(context: RaidResolveContext<TriggerState>): number {
return this.command.getPostReqTurn(context);
}
resolve(context: RaidResolveContext<TriggerState>, _args: RaidArgs): GeneralActionOutcome<TriggerState> {
@@ -167,8 +180,8 @@ export class ActionDefinition<
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.resolver = new ActionResolver(modules, initialNationGenLimit);
}
parseArgs(raw: unknown): RaidArgs | null {
@@ -191,6 +204,10 @@ export class ActionDefinition<
];
}
getPostReqTurn(context: RaidResolveContext<TriggerState>): number {
return this.resolver.getPostReqTurn(context);
}
resolve(context: RaidResolveContext<TriggerState>, args: RaidArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
@@ -235,5 +252,6 @@ export const commandSpec: NationTurnCommandSpec = {
reqArg: true,
availabilityArgs: { destNationId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit),
};
@@ -1,6 +1,12 @@
import type { GeneralTriggerState, City, General } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { beLord, occupiedCity, suppliedCity, beOpeningPart, reqNationAuxValue } from '@sammo-ts/logic/constraints/presets.js';
import {
beLord,
occupiedCity,
suppliedCity,
beOpeningPart,
reqNationAuxValue,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
@@ -39,6 +45,7 @@ export class ActionDefinition<
> {
public readonly key = 'che_무작위수도이전';
public readonly name = ACTION_NAME;
public readonly countsAsInheritanceActiveAction = true;
parseArgs(_raw: unknown): RandomMoveCapitalArgs | null {
return {};
@@ -57,6 +64,10 @@ export class ActionDefinition<
];
}
getPreReqTurn(): number {
return 1;
}
resolve(
context: RandomMoveCapitalResolveContext<TriggerState>,
_args: RandomMoveCapitalArgs
@@ -142,6 +153,11 @@ export class ActionDefinition<
format: LogFormat.YEAR_MONTH,
}
),
createLogEffect(`<G><b>${destCityName}</b></>${josaRo} <M>${ACTION_NAME}</>`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
// General Action Log
createLogEffect(`<G><b>${destCityName}</b></>${josaRo} 국가를 옮겼습니다.`, {
scope: LogScope.GENERAL,
@@ -1,4 +1,4 @@
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
import {
beChief,
@@ -27,16 +27,23 @@ import { clamp } from 'es-toolkit';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
const ARGS_SCHEMA = z.object({
destNationId: z.number(),
amountList: z.tuple([z.number(), z.number()]),
});
const ARGS_SCHEMA = z
.object({
destNationId: z.number().int().positive(),
amountList: z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]),
})
.refine(({ amountList }) => amountList[0] > 0 || amountList[1] > 0, {
message: '지원량은 0보다 커야 합니다.',
path: ['amountList'],
});
export type MaterialAidArgs = z.infer<typeof ARGS_SCHEMA>;
export interface MaterialAidResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destNation: Nation;
friendlyChiefs: Array<General<TriggerState>>;
destNationChiefs: Array<General<TriggerState>>;
}
const ACTION_NAME = '원조';
@@ -75,7 +82,12 @@ export class ActionDefinition<
}
buildMinConstraints(_ctx: ConstraintContext, _args: MaterialAidArgs): Constraint[] {
return [occupiedCity(), beChief(), suppliedCity(), reqNationValue('surlimit', '외교제한', '==', 0, '외교제한중입니다.')];
return [
occupiedCity(),
beChief(),
suppliedCity(),
reqNationValue('surlimit', '외교제한', '==', 0, '외교제한중입니다.'),
];
}
buildConstraints(_ctx: ConstraintContext, args: MaterialAidArgs): Constraint[] {
@@ -111,11 +123,24 @@ export class ActionDefinition<
const goldText = actualGold.toLocaleString();
const riceText = actualRice.toLocaleString();
const nationName = nation.name;
const josaUlRice = JosaUtil.pick(riceText, '을');
const josaRo = JosaUtil.pick(destNation.name, '로');
const nationName = nation.name;
const josaRoSrc = JosaUtil.pick(nationName, '로');
const broadcastMessage = `<D><b>${destNation.name}</b></>${josaRo} 금<C>${goldText}</> 쌀<C>${riceText}</>을 지원했습니다.`;
const recvAssist =
typeof destNation.meta.recv_assist === 'object' && destNation.meta.recv_assist !== null
? { ...destNation.meta.recv_assist }
: {};
const recvKey = `n${nation.id}`;
const priorEntry =
typeof recvAssist[recvKey] === 'object' && recvAssist[recvKey] !== null ? recvAssist[recvKey] : {};
const priorAmount = Number(priorEntry['1'] ?? 0);
recvAssist[recvKey] = {
0: nation.id,
1: (Number.isFinite(priorAmount) ? priorAmount : 0) + actualGold + actualRice,
};
const effects: Array<GeneralActionEffect<TriggerState>> = [
createNationPatchEffect(
@@ -133,6 +158,10 @@ export class ActionDefinition<
{
gold: destNation.gold + actualGold,
rice: destNation.rice + actualRice,
meta: {
...destNation.meta,
recv_assist: recvAssist,
},
},
destNation.id
),
@@ -146,6 +175,14 @@ export class ActionDefinition<
}
),
// Actor Nation History Log
createLogEffect(
`<D><b>${destNation.name}</b></>${josaRo} 금<C>${goldText}</> 쌀<C>${riceText}</>${josaUlRice} 지원`,
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
),
createLogEffect(
`<D><b>${destNation.name}</b></>${josaRo} 금<C>${goldText}</> 쌀<C>${riceText}</>${josaUlRice} 지원`,
{
@@ -157,7 +194,7 @@ export class ActionDefinition<
),
// Dest Nation History Log
createLogEffect(
`<D><b>${nationName}</b></>${JosaUtil.pick(nationName, '부터')} 금<C>${goldText}</> 쌀<C>${riceText}</>${josaUlRice} 지원 받음`,
`<D><b>${nationName}</b></>${josaRoSrc}부터 금<C>${goldText}</> 쌀<C>${riceText}</>${josaUlRice} 지원 받음`,
{
scope: LogScope.NATION,
nationId: destNation.id,
@@ -178,6 +215,30 @@ export class ActionDefinition<
}),
];
for (const chief of context.friendlyChiefs) {
if (chief.id !== general.id) {
effects.push(
createLogEffect(broadcastMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: chief.id,
format: LogFormat.PLAIN,
})
);
}
}
const destBroadcastMessage = `<D><b>${nationName}</b></>에서 금<C>${goldText}</> 쌀<C>${riceText}</>${josaUlRice} 원조했습니다.`;
for (const chief of context.destNationChiefs) {
effects.push(
createLogEffect(destBroadcastMessage, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: chief.id,
format: LogFormat.PLAIN,
})
);
}
general.experience += 5;
general.dedication += 5;
@@ -194,10 +255,15 @@ export const actionContextBuilder: ActionContextBuilder<MaterialAidArgs> = (base
const destNation = worldRef.getNationById(destNationId);
if (!destNation) return null;
const generals = worldRef.listGenerals();
return {
...base,
destNation,
friendlyChiefs: generals.filter(
(general) => general.nationId === base.general.nationId && general.officerLevel >= 5
),
destNationChiefs: generals.filter((general) => general.nationId === destNationId && general.officerLevel >= 5),
};
};
@@ -48,10 +48,19 @@ const DEFENCE_RATE = 0.8;
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
private readonly initialNationGenLimit = 10
) {
this.pipeline = new GeneralActionPipeline(modules);
}
getPostReqTurn(context: MobilizePeopleResolveContext<TriggerState>): number {
const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit);
const base = Math.round(Math.sqrt(genCount * 4) * 10);
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base));
}
getGlobalDelay(context: MobilizePeopleResolveContext<TriggerState>): number {
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY));
}
@@ -64,8 +73,12 @@ export class ActionResolver<
readonly key = 'che_백성동원';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.command = new CommandResolver(modules, initialNationGenLimit);
}
getPostReqTurn(context: MobilizePeopleResolveContext<TriggerState>): number {
return this.command.getPostReqTurn(context);
}
resolve(
@@ -144,8 +157,8 @@ export class ActionDefinition<
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.resolver = new ActionResolver(modules, initialNationGenLimit);
}
parseArgs(raw: unknown): MobilizePeopleArgs | null {
@@ -162,6 +175,10 @@ export class ActionDefinition<
return [occupiedCity(), beChief(), occupiedDestCity(), availableStrategicCommand()];
}
getPostReqTurn(context: MobilizePeopleResolveContext<TriggerState>): number {
return this.resolver.getPostReqTurn(context);
}
resolve(
context: MobilizePeopleResolveContext<TriggerState>,
args: MobilizePeopleArgs
@@ -198,5 +215,6 @@ export const commandSpec: NationTurnCommandSpec = {
reqArg: true,
availabilityArgs: { destCityId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit),
};
@@ -1,4 +1,4 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
beChief,
@@ -10,7 +10,7 @@ import {
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 { createLogEffect } from '@sammo-ts/logic/actions/engine.js';
import { createLogEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
@@ -32,6 +32,14 @@ const ARGS_SCHEMA = z.object({
});
export type NonAggressionProposalArgs = z.infer<typeof ARGS_SCHEMA>;
interface NonAggressionProposalContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destNation: Nation;
messageValidMinutes: number;
messageTime: Date;
}
const ACTION_NAME = '불가침 제의';
const MIN_TERM_MONTHS = 6;
@@ -90,7 +98,11 @@ const reqMinimumTreatyTerm = (minMonths: number): Constraint => ({
// 불가침 제의를 처리하는 국가 커맨드.
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, NonAggressionProposalArgs> {
> implements GeneralActionDefinition<
TriggerState,
NonAggressionProposalArgs,
NonAggressionProposalContext<TriggerState>
> {
public readonly key = 'che_불가침제의';
public readonly name = ACTION_NAME;
@@ -117,14 +129,42 @@ export class ActionDefinition<
}
resolve(
_context: GeneralActionResolveContext<TriggerState>,
context: NonAggressionProposalContext<TriggerState>,
args: NonAggressionProposalArgs
): GeneralActionOutcome<TriggerState> {
const destNationName =
(_context as { destNation?: { name?: string } }).destNation?.name ?? `국가${args.destNationId}`;
const { general, nation, destNation } = context;
if (!nation) {
return { effects: [createLogEffect('국가 정보가 없습니다.')] };
}
const destNationName = destNation.name;
const josaRo = JosaUtil.pick(destNationName, '로');
const josaWa = JosaUtil.pick(nation.name, '와');
const validUntil = new Date(context.messageTime.getTime() + context.messageValidMinutes * 60_000);
return {
effects: [
createMessageEffect({
msgType: 'diplomacy',
src: {
generalId: general.id,
generalName: general.name,
nationId: nation.id,
nationName: nation.name,
color: nation.color,
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: destNation.id,
nationName: destNation.name,
color: destNation.color,
icon: '',
},
text: `${nation.name}${josaWa} ${args.year}${args.month}월까지 불가침 제의 서신`,
time: context.messageTime,
validUntil,
option: { action: 'noAggression', year: args.year, month: args.month },
}),
createLogEffect(`<D><b>${destNationName}</b></>${josaRo} 불가침 제의 서신을 보냈습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
@@ -136,11 +176,18 @@ export class ActionDefinition<
}
// 예약 턴 실행에 필요한 날짜 정보를 제공한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
...base,
currentYear: options.world.currentYear,
currentMonth: options.world.currentMonth,
});
export const actionContextBuilder: ActionContextBuilder<NonAggressionProposalArgs> = (base, options) => {
const destNation = options.worldRef?.getNationById(options.actionArgs.destNationId);
if (!destNation) {
return null;
}
return {
...base,
destNation,
messageTime: base.general.turnTime,
messageValidMinutes: Math.max(30, Math.floor((options.world.tickSeconds / 60) * 3)),
};
};
export const commandSpec: NationTurnCommandSpec = {
key: 'che_불가침제의',
@@ -1,4 +1,4 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowDiplomacyBetweenStatus,
@@ -10,11 +10,11 @@ import {
} 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 { createLogEffect, createMessageEffect } 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 { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
@@ -27,13 +27,25 @@ const ARGS_SCHEMA = z.object({
});
export type NonAggressionCancelProposalArgs = z.infer<typeof ARGS_SCHEMA>;
interface NonAggressionCancelProposalContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destNation: Nation;
messageValidMinutes: number;
messageTime: Date;
}
const ACTION_NAME = '불가침 파기 제의';
const DIPLOMACY_NON_AGGRESSION = 7;
// 불가침 파기 제의를 처리하는 국가 커맨드.
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, NonAggressionCancelProposalArgs> {
> implements GeneralActionDefinition<
TriggerState,
NonAggressionCancelProposalArgs,
NonAggressionCancelProposalContext<TriggerState>
> {
public readonly key = 'che_불가침파기제의';
public readonly name = ACTION_NAME;
@@ -57,14 +69,41 @@ export class ActionDefinition<
}
resolve(
_context: GeneralActionResolveContext<TriggerState>,
args: NonAggressionCancelProposalArgs
context: NonAggressionCancelProposalContext<TriggerState>,
_args: NonAggressionCancelProposalArgs
): GeneralActionOutcome<TriggerState> {
const destNationName =
(_context as { destNation?: { name?: string } }).destNation?.name ?? `국가${args.destNationId}`;
const { general, nation, destNation } = context;
if (!nation) {
return { effects: [createLogEffect('국가 정보가 없습니다.')] };
}
const destNationName = destNation.name;
const josaRo = JosaUtil.pick(destNationName, '로');
const validUntil = new Date(context.messageTime.getTime() + context.messageValidMinutes * 60_000);
return {
effects: [
createMessageEffect({
msgType: 'diplomacy',
src: {
generalId: general.id,
generalName: general.name,
nationId: nation.id,
nationName: nation.name,
color: nation.color,
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: destNation.id,
nationName: destNation.name,
color: destNation.color,
icon: '',
},
text: `${nation.name}의 불가침 파기 제의 서신`,
time: context.messageTime,
validUntil,
option: { action: 'cancelNA', deletable: false },
}),
createLogEffect(`<D><b>${destNationName}</b></>${josaRo} 불가침 파기 제의 서신을 보냈습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
@@ -76,7 +115,18 @@ export class ActionDefinition<
}
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
export const actionContextBuilder = defaultActionContextBuilder;
export const actionContextBuilder: ActionContextBuilder<NonAggressionCancelProposalArgs> = (base, options) => {
const destNation = options.worldRef?.getNationById(options.actionArgs.destNationId);
if (!destNation) {
return null;
}
return {
...base,
destNation,
messageTime: base.general.turnTime,
messageValidMinutes: Math.max(30, Math.floor((options.world.tickSeconds / 60) * 3)),
};
};
export const commandSpec: NationTurnCommandSpec = {
key: 'che_불가침파기제의',
@@ -58,10 +58,19 @@ const battleGroundCity = (): Constraint => ({
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
private readonly initialNationGenLimit = 10
) {
this.pipeline = new GeneralActionPipeline(modules);
}
getPostReqTurn(context: FloodResolveContext<TriggerState>): number {
const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit);
const base = Math.round(Math.sqrt(genCount * 4) * 10);
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base));
}
getGlobalDelay(context: FloodResolveContext<TriggerState>): number {
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY));
}
@@ -74,8 +83,12 @@ export class ActionResolver<
readonly key = 'che_수몰';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.command = new CommandResolver(modules, initialNationGenLimit);
}
getPostReqTurn(context: FloodResolveContext<TriggerState>): number {
return this.command.getPostReqTurn(context);
}
resolve(context: FloodResolveContext<TriggerState>, _args: FloodArgs): GeneralActionOutcome<TriggerState> {
@@ -175,8 +188,8 @@ export class ActionDefinition<
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.resolver = new ActionResolver(modules, initialNationGenLimit);
}
parseArgs(raw: unknown): FloodArgs | null {
@@ -200,6 +213,14 @@ export class ActionDefinition<
];
}
getPreReqTurn(): number {
return PRE_REQ_TURN;
}
getPostReqTurn(context: FloodResolveContext<TriggerState>): number {
return this.resolver.getPostReqTurn(context);
}
resolve(context: FloodResolveContext<TriggerState>, args: FloodArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
@@ -238,5 +259,6 @@ export const commandSpec: NationTurnCommandSpec = {
reqArg: true,
availabilityArgs: { destCityId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit),
};
@@ -221,6 +221,11 @@ export class ActionResolver<
this.command = new CommandResolver(modules, env);
}
getPostReqTurn(context: VolunteerRecruitResolveContext<TriggerState>): number {
const value = context.nation ? readMetaNumber(context.nation.meta, 'gennum') : null;
return this.command.getPostDelay(context, value ?? 0);
}
resolve(
context: VolunteerRecruitResolveContext<TriggerState>,
_args: VolunteerRecruitArgs
@@ -363,6 +368,14 @@ export class ActionDefinition<
];
}
getPreReqTurn(): number {
return DEFAULT_PRE_TURN;
}
getPostReqTurn(context: VolunteerRecruitResolveContext<TriggerState>): number {
return this.resolver.getPostReqTurn(context);
}
resolve(
context: VolunteerRecruitResolveContext<TriggerState>,
args: VolunteerRecruitArgs
@@ -54,10 +54,19 @@ const resolveNextTerm = (state: number, term: number): number => (state === DIPL
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
private readonly initialNationGenLimit = 10
) {
this.pipeline = new GeneralActionPipeline(modules);
}
getPostReqTurn(context: DegradeRelationsResolveContext<TriggerState>): number {
const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit);
const base = Math.round(Math.sqrt(genCount * 16) * 10);
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base));
}
getGlobalDelay(context: DegradeRelationsResolveContext<TriggerState>): number {
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY));
}
@@ -70,8 +79,12 @@ export class ActionResolver<
readonly key = 'che_이호경식';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.command = new CommandResolver(modules, initialNationGenLimit);
}
getPostReqTurn(context: DegradeRelationsResolveContext<TriggerState>): number {
return this.command.getPostReqTurn(context);
}
resolve(
@@ -174,8 +187,8 @@ export class ActionDefinition<
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.resolver = new ActionResolver(modules, initialNationGenLimit);
}
parseArgs(raw: unknown): DegradeRelationsArgs | null {
@@ -198,6 +211,10 @@ export class ActionDefinition<
];
}
getPostReqTurn(context: DegradeRelationsResolveContext<TriggerState>): number {
return this.resolver.getPostReqTurn(context);
}
resolve(
context: DegradeRelationsResolveContext<TriggerState>,
args: DegradeRelationsArgs
@@ -245,5 +262,6 @@ export const commandSpec: NationTurnCommandSpec = {
reqArg: true,
availabilityArgs: { destNationId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit),
};
@@ -1,4 +1,4 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
allowDiplomacyBetweenStatus,
@@ -10,10 +10,10 @@ import {
} 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 { createLogEffect, createMessageEffect } 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 { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { NationTurnCommandSpec } from './index.js';
import { JosaUtil } from '@sammo-ts/common';
import { z } from 'zod';
@@ -27,12 +27,20 @@ const ARGS_SCHEMA = z.object({
});
export type StopWarProposalArgs = z.infer<typeof ARGS_SCHEMA>;
interface StopWarProposalContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destNation: Nation;
messageValidMinutes: number;
messageTime: Date;
}
const ACTION_NAME = '종전 제의';
// 종전 제의를 처리하는 국가 커맨드.
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, StopWarProposalArgs> {
> implements GeneralActionDefinition<TriggerState, StopWarProposalArgs, StopWarProposalContext<TriggerState>> {
public readonly key = 'che_종전제의';
public readonly name = ACTION_NAME;
@@ -56,14 +64,41 @@ export class ActionDefinition<
}
resolve(
_context: GeneralActionResolveContext<TriggerState>,
args: StopWarProposalArgs
context: StopWarProposalContext<TriggerState>,
_args: StopWarProposalArgs
): GeneralActionOutcome<TriggerState> {
const destNationName =
(_context as { destNation?: { name?: string } }).destNation?.name ?? `국가${args.destNationId}`;
const { general, nation, destNation } = context;
if (!nation) {
return { effects: [createLogEffect('국가 정보가 없습니다.')] };
}
const destNationName = destNation.name;
const josaRo = JosaUtil.pick(destNationName, '로');
const validUntil = new Date(context.messageTime.getTime() + context.messageValidMinutes * 60_000);
return {
effects: [
createMessageEffect({
msgType: 'diplomacy',
src: {
generalId: general.id,
generalName: general.name,
nationId: nation.id,
nationName: nation.name,
color: nation.color,
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: destNation.id,
nationName: destNation.name,
color: destNation.color,
icon: '',
},
text: `${nation.name}의 종전 제의 서신`,
time: context.messageTime,
validUntil,
option: { action: 'stopWar', deletable: false },
}),
createLogEffect(`<D><b>${destNationName}</b></>${josaRo} 종전 제의 서신을 보냈습니다.`, {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
@@ -75,7 +110,18 @@ export class ActionDefinition<
}
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
export const actionContextBuilder = defaultActionContextBuilder;
export const actionContextBuilder: ActionContextBuilder<StopWarProposalArgs> = (base, options) => {
const destNation = options.worldRef?.getNationById(options.actionArgs.destNationId);
if (!destNation) {
return null;
}
return {
...base,
destNation,
messageTime: base.general.turnTime,
messageValidMinutes: Math.max(30, Math.floor((options.world.tickSeconds / 60) * 3)),
};
};
export const commandSpec: NationTurnCommandSpec = {
key: 'che_종전제의',
@@ -88,6 +88,7 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, ExpandCityArgs, ExpandCityResolveContext<TriggerState>> {
public readonly key = 'che_증축';
public readonly name = ACTION_NAME;
public readonly countsAsInheritanceActiveAction = true;
constructor(private readonly env: TurnCommandEnv) {}
@@ -118,6 +119,15 @@ export class ActionDefinition<
];
}
getPreReqTurn(): number {
return 5;
}
getStackSequence(context: ExpandCityResolveContext<TriggerState>): number {
const value = context.nation?.meta.capset;
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
}
resolve(
context: ExpandCityResolveContext<TriggerState>,
_args: ExpandCityArgs
@@ -153,6 +163,10 @@ export class ActionDefinition<
{
gold: nation.gold - cost,
rice: nation.rice - cost,
meta: {
...nation.meta,
capset: (typeof nation.meta.capset === 'number' ? nation.meta.capset : 0) + 1,
},
},
nation.id
),
@@ -184,6 +198,11 @@ export class ActionDefinition<
format: LogFormat.YEAR_MONTH,
}
),
createLogEffect(`<G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
// General Action Log
createLogEffect(`<G><b>${destCityName}</b></>${josaUl} ${ACTION_NAME}했습니다.`, {
scope: LogScope.GENERAL,
@@ -36,35 +36,43 @@ export interface MoveCapitalResolveContext<
> extends GeneralActionResolveContext<TriggerState> {
destCity: City;
map: MapDefinition;
nationCities: City[];
}
const ACTION_NAME = '천도';
const hasRouteToDestCity = (destCityID: number, develCost: number): Constraint => ({
const hasRouteToDestCity = (destCityID: number): Constraint => ({
name: 'hasRouteToDestCity',
requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }, { kind: 'env', key: 'map' }],
requires: (ctx) => [
{ kind: 'nation', id: ctx.nationId! },
{ kind: 'env', key: 'map' },
{ kind: 'env', key: 'cities' },
],
test: (ctx: ConstraintContext, view: StateView) => {
const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined;
const map = view.get({ kind: 'env', key: 'map' }) as MapDefinition | undefined;
const cities = view.get({ kind: 'env', key: 'cities' }) as City[] | undefined;
if (!nation || !map || nation.capitalCityId === undefined || nation.capitalCityId === null) {
return { kind: 'allow' };
}
const dist = calcDistance(nation.capitalCityId, destCityID, map);
if (dist >= 50) {
const allowedCityIds = new Set(
(cities ?? []).filter((city) => city.nationId === nation.id).map((city) => city.id)
);
const dist = calcDistance(nation.capitalCityId, destCityID, map, allowedCityIds);
if (dist === null) {
return { kind: 'deny', reason: '천도 대상으로 도달할 방법이 없습니다.' };
}
const cost = develCost * 5 * Math.pow(2, dist);
if (nation.gold < cost + 1000) {
return { kind: 'allow' };
}
if (nation.rice < cost + 1000) {
return { kind: 'allow' };
}
return { kind: 'allow' };
},
});
const calcDistance = (fromCityId: number, toCityId: number, map: MapDefinition): number => {
const calcDistance = (
fromCityId: number,
toCityId: number,
map: MapDefinition,
allowedCityIds?: ReadonlySet<number>
): number | null => {
if (allowedCityIds && !allowedCityIds.has(toCityId)) return null;
if (fromCityId === toCityId) return 0;
const connections = new Map<number, number[]>();
@@ -82,6 +90,9 @@ const calcDistance = (fromCityId: number, toCityId: number, map: MapDefinition):
const nextNodes = connections.get(current) ?? [];
for (const next of nextNodes) {
if (allowedCityIds && !allowedCityIds.has(next)) {
continue;
}
if (!visited.has(next)) {
visited.add(next);
queue.push([next, dist + 1]);
@@ -89,7 +100,7 @@ const calcDistance = (fromCityId: number, toCityId: number, map: MapDefinition):
}
}
return 50;
return null;
};
export class ActionDefinition<
@@ -97,6 +108,7 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, MoveCapitalArgs, MoveCapitalResolveContext<TriggerState>> {
public readonly key = 'che_천도';
public readonly name = ACTION_NAME;
public readonly countsAsInheritanceActiveAction = true;
constructor(private readonly env: TurnCommandEnv) {}
@@ -119,8 +131,12 @@ export class ActionDefinition<
if (!nation || !map || nation.capitalCityId === undefined || nation.capitalCityId === null) {
return 0;
}
const dist = calcDistance(nation.capitalCityId, args.destCityID, map);
if (dist >= 50) {
const cities = view.get({ kind: 'env', key: 'cities' }) as City[] | undefined;
const allowedCityIds = new Set(
(cities ?? []).filter((city) => city.nationId === nation.id).map((city) => city.id)
);
const dist = calcDistance(nation.capitalCityId, args.destCityID, map, allowedCityIds);
if (dist === null) {
return 0;
}
return develcost * 5 * Math.pow(2, dist);
@@ -132,13 +148,38 @@ export class ActionDefinition<
beChief(),
suppliedCity(),
suppliedDestCity(),
hasRouteToDestCity(args.destCityID, develcost),
hasRouteToDestCity(args.destCityID),
reqNationValue('capitalCityId', '수도', '!=', args.destCityID, '이미 수도입니다.'),
reqNationGold((ctx, view) => baseGold + getRequiredCost(ctx, view), [{ kind: 'env', key: 'map' }]),
reqNationRice((ctx, view) => baseRice + getRequiredCost(ctx, view), [{ kind: 'env', key: 'map' }]),
reqNationGold(
(ctx, view) => baseGold + getRequiredCost(ctx, view),
[
{ kind: 'env', key: 'map' },
{ kind: 'env', key: 'cities' },
]
),
reqNationRice(
(ctx, view) => baseRice + getRequiredCost(ctx, view),
[
{ kind: 'env', key: 'map' },
{ kind: 'env', key: 'cities' },
]
),
];
}
getPreReqTurn(context: MoveCapitalResolveContext<TriggerState>, args: MoveCapitalArgs): number {
if (!context.nation?.capitalCityId) {
return 0;
}
const allowedCityIds = new Set(context.nationCities.map((city) => city.id));
return (calcDistance(context.nation.capitalCityId, args.destCityID, context.map, allowedCityIds) ?? 0) * 2;
}
getStackSequence(context: MoveCapitalResolveContext<TriggerState>): number {
const value = context.nation?.meta.capset;
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
}
resolve(
context: MoveCapitalResolveContext<TriggerState>,
args: MoveCapitalArgs
@@ -148,9 +189,12 @@ export class ActionDefinition<
return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] };
}
const dist = calcDistance(nation.capitalCityId, args.destCityID, map);
const cost = this.env.develCost * 5 * Math.pow(2, dist);
const nationCities = context.nationCities ?? [];
const allowedCityIds = nationCities.length > 0 ? new Set(nationCities.map((city) => city.id)) : undefined;
const dist = calcDistance(nation.capitalCityId, args.destCityID, map, allowedCityIds);
if (dist === null) {
return { effects: [createLogEffect('천도 대상으로 도달할 방법이 없습니다.', { scope: LogScope.GENERAL })] };
}
const generalName = general.name;
const nationName = nation.name;
const destCityName = destCity.name;
@@ -163,8 +207,11 @@ export class ActionDefinition<
createNationPatchEffect(
{
capitalCityId: args.destCityID,
gold: nation.gold - cost,
rice: nation.rice - cost,
// ref는 비용 보유를 제약에서 검사하지만 실행 시 차감하지 않는다.
meta: {
...nation.meta,
capset: (typeof nation.meta.capset === 'number' ? nation.meta.capset : 0) + 1,
},
},
nation.id
),
@@ -196,6 +243,11 @@ export class ActionDefinition<
format: LogFormat.YEAR_MONTH,
}
),
createLogEffect(`<G><b>${destCityName}</b></>${josaRo} <M>${ACTION_NAME}</>명령`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}),
// General Action Log
createLogEffect(`<G><b>${destCityName}</b></>${josaRo} ${ACTION_NAME}했습니다.`, {
scope: LogScope.GENERAL,
@@ -226,6 +278,7 @@ export const actionContextBuilder: ActionContextBuilder<MoveCapitalArgs> = (base
...base,
destCity,
map,
nationCities: worldRef.listCities().filter((city) => city.nationId === base.general.nationId),
};
};
@@ -72,6 +72,7 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, ScorchedEarthArgs, ScorchedEarthResolveContext<TriggerState>> {
public readonly key = 'che_초토화';
public readonly name = ACTION_NAME;
public readonly countsAsInheritanceActiveAction = true;
parseArgs(raw: unknown): ScorchedEarthArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
@@ -102,6 +103,10 @@ export class ActionDefinition<
];
}
getPreReqTurn(): number {
return PRE_REQ_TURN;
}
resolve(
context: ScorchedEarthResolveContext<TriggerState>,
_args: ScorchedEarthArgs
@@ -8,6 +8,7 @@ import {
occupiedCity,
} from '@sammo-ts/logic/constraints/presets.js';
import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js';
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
GeneralActionEffect,
@@ -15,7 +16,7 @@ import type {
GeneralActionResolveContext,
GeneralActionResolver,
} from '@sammo-ts/logic/actions/engine.js';
import { createLogEffect } 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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
@@ -32,10 +33,12 @@ export interface CounterStrategyResolveContext<
destNation: Nation;
friendlyGenerals: Array<General<TriggerState>>;
destNationGenerals: Array<General<TriggerState>>;
currentYearMonth: number;
}
const ACTION_NAME = '피장파장';
const DEFAULT_GLOBAL_DELAY = 8;
const TARGET_DELAY = 60;
const PRE_REQ_TURN = 1;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
@@ -89,11 +92,53 @@ const reqValidStrategicCommandType = (): Constraint => ({
},
});
const alwaysFail = (commandType: CounterStrategyArgs['commandType']): Constraint => ({
// Legacy inserts AlwaysFail when the selected strategy is still cooling down.
name: 'alwaysFail',
requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []),
test: (ctx, view) => {
if (ctx.nationId === undefined) {
return { kind: 'deny', reason: '국가 정보가 없습니다.' };
}
const nation = view.get({ kind: 'nation', id: ctx.nationId }) as Nation | undefined;
if (!nation) {
return { kind: 'deny', reason: '국가 정보가 없습니다.' };
}
const raw = nation.meta[`next_execute_${STRATEGIC_COMMANDS[commandType]}`];
const nextAvailable = typeof raw === 'number' ? raw : Number(raw ?? 0);
const year = Number(ctx.env.currentYear ?? ctx.env.year);
const month = Number(ctx.env.currentMonth ?? ctx.env.month);
if (!Number.isFinite(year) || !Number.isFinite(month)) {
return { kind: 'unknown', missing: [{ kind: 'env', key: 'currentYear' }] };
}
const currentYearMonth = Math.floor(year) * 12 + Math.floor(month) - 1;
if (Number.isFinite(nextAvailable) && nextAvailable > currentYearMonth) {
return { kind: 'deny', reason: '해당 전략을 아직 사용할 수 없습니다' };
}
return allow();
},
});
// 피장파장 실행 결과를 계산한다.
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, CounterStrategyArgs> {
readonly key = 'che_피장파장';
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined> = [],
private readonly initialNationGenLimit = 10
) {
this.pipeline = new GeneralActionPipeline(modules);
}
getTargetPostReqTurn(context: CounterStrategyResolveContext<TriggerState>): number {
const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit);
const base = Math.round(Math.sqrt(genCount * 2) * 10);
const triggered = Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base));
return Math.max(triggered, Math.round(TARGET_DELAY * 1.2));
}
resolve(
context: CounterStrategyResolveContext<TriggerState>,
@@ -105,6 +150,7 @@ export class ActionResolver<
const nationName = nation?.name ?? '아국';
const destNationName = destNation.name;
const targetCommandName = STRATEGIC_COMMANDS[args.commandType] ?? args.commandType;
const currentYearMonth = Number.isFinite(context.currentYearMonth) ? context.currentYearMonth : 0;
const actionName = ACTION_NAME;
const actionJosa = JosaUtil.pick(actionName, '을');
@@ -117,8 +163,8 @@ export class ActionResolver<
context.addLog(
`<D><b>${destNationName}</b></>에 <G><b>${targetCommandName}</b></> <M>${ACTION_NAME}</>${actionJosa} 발동`,
{
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
);
@@ -159,6 +205,7 @@ export class ActionResolver<
nation.meta = {
...(nation.meta as object),
strategic_cmd_limit: globalDelay,
[`next_execute_${targetCommandName}`]: currentYearMonth + this.getTargetPostReqTurn(context),
};
effects.push(
createLogEffect(broadcastMessage, {
@@ -170,6 +217,22 @@ export class ActionResolver<
);
}
const destMeta = destNation.meta;
const destKey = `next_execute_${targetCommandName}`;
const destRaw = destMeta[destKey];
const destNext = typeof destRaw === 'number' ? destRaw : Number(destRaw ?? 0);
effects.push(
createNationPatchEffect(
{
meta: {
...destMeta,
[destKey]: Math.max(Number.isFinite(destNext) ? destNext : 0, currentYearMonth) + TARGET_DELAY,
},
},
destNation.id
)
);
effects.push(
createLogEffect(
`<D><b>${nationName}</b></>의 <Y>${generalName}</>${generalJosa} 아국에 <G><b>${targetCommandName}</b></> <M>${ACTION_NAME}</>${actionJosa} 발동`,
@@ -206,7 +269,11 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, CounterStrategyArgs, CounterStrategyResolveContext<TriggerState>> {
public readonly key = 'che_피장파장';
public readonly name = ACTION_NAME;
private readonly resolver = new ActionResolver<TriggerState>();
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined> = [], initialNationGenLimit = 10) {
this.resolver = new ActionResolver(modules, initialNationGenLimit);
}
parseArgs(raw: unknown): CounterStrategyArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
@@ -226,10 +293,22 @@ export class ActionDefinition<
allowDiplomacyBetweenStatus([0, 1], '선포, 전쟁중인 상대국에게만 가능합니다.'),
availableStrategicCommand(),
reqValidStrategicCommandType(),
alwaysFail(_args.commandType),
];
}
resolve(context: CounterStrategyResolveContext<TriggerState>, args: CounterStrategyArgs): GeneralActionOutcome<TriggerState> {
getPreReqTurn(): number {
return PRE_REQ_TURN;
}
getPostReqTurn(): number {
return 8;
}
resolve(
context: CounterStrategyResolveContext<TriggerState>,
args: CounterStrategyArgs
): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
}
@@ -256,6 +335,7 @@ export const actionContextBuilder: ActionContextBuilder<CounterStrategyArgs> = (
destNation,
friendlyGenerals,
destNationGenerals,
currentYearMonth: options.world.currentYear * 12 + options.world.currentMonth - 1,
};
};
@@ -265,5 +345,6 @@ export const commandSpec: NationTurnCommandSpec = {
reqArg: true,
availabilityArgs: { destNationId: 0, commandType: '' },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit),
};
@@ -40,10 +40,19 @@ const ATMOS_CAP = 100;
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
private readonly initialNationGenLimit = 10
) {
this.pipeline = new GeneralActionPipeline(modules);
}
getPostReqTurn(context: DesperateFightResolveContext<TriggerState>): number {
const genCount = Math.max(context.nationGenerals.length, this.initialNationGenLimit);
const base = Math.round(Math.sqrt(genCount * 8) * 10);
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base));
}
getGlobalDelay(context: DesperateFightResolveContext<TriggerState>): number {
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY));
}
@@ -56,8 +65,12 @@ export class ActionResolver<
readonly key = 'che_필사즉생';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.command = new CommandResolver(modules, initialNationGenLimit);
}
getPostReqTurn(context: DesperateFightResolveContext<TriggerState>): number {
return this.command.getPostReqTurn(context);
}
resolve(
@@ -142,8 +155,8 @@ export class ActionDefinition<
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.resolver = new ActionResolver(modules, initialNationGenLimit);
}
parseArgs(_raw: unknown): DesperateFightArgs | null {
@@ -162,6 +175,14 @@ export class ActionDefinition<
];
}
getPreReqTurn(): number {
return PRE_REQ_TURN;
}
getPostReqTurn(context: DesperateFightResolveContext<TriggerState>): number {
return this.resolver.getPostReqTurn(context);
}
resolve(
context: DesperateFightResolveContext<TriggerState>,
args: DesperateFightArgs
@@ -188,5 +209,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '전략',
reqArg: false,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit),
};
@@ -65,10 +65,19 @@ const pickMoveCityId = (rng: GeneralActionResolveContext['rng'], destCityId: num
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
constructor(
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
private readonly initialNationGenLimit = 10
) {
this.pipeline = new GeneralActionPipeline(modules);
}
getPostReqTurn(context: DeceptionResolveContext<TriggerState>): number {
const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit);
const base = Math.round(Math.sqrt(genCount * 4) * 10);
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base));
}
getGlobalDelay(context: DeceptionResolveContext<TriggerState>): number {
return Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'globalDelay', DEFAULT_GLOBAL_DELAY));
}
@@ -81,8 +90,12 @@ export class ActionResolver<
readonly key = 'che_허보';
private readonly command: CommandResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.command = new CommandResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.command = new CommandResolver(modules, initialNationGenLimit);
}
getPostReqTurn(context: DeceptionResolveContext<TriggerState>): number {
return this.command.getPostReqTurn(context);
}
resolve(context: DeceptionResolveContext<TriggerState>, _args: DeceptionArgs): GeneralActionOutcome<TriggerState> {
@@ -176,8 +189,8 @@ export class ActionDefinition<
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>) {
this.resolver = new ActionResolver(modules);
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, initialNationGenLimit = 10) {
this.resolver = new ActionResolver(modules, initialNationGenLimit);
}
parseArgs(raw: unknown): DeceptionArgs | null {
@@ -201,6 +214,14 @@ export class ActionDefinition<
];
}
getPreReqTurn(): number {
return PRE_REQ_TURN;
}
getPostReqTurn(context: DeceptionResolveContext<TriggerState>): number {
return this.resolver.getPostReqTurn(context);
}
resolve(context: DeceptionResolveContext<TriggerState>, args: DeceptionArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
}
@@ -245,5 +266,6 @@ export const commandSpec: NationTurnCommandSpec = {
reqArg: true,
availabilityArgs: { destCityId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? [], env.initialNationGenLimit),
};
@@ -3,10 +3,7 @@ import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/c
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 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';
@@ -43,7 +40,9 @@ const reqNationAuxValue = (auxKey: string, actionName: string): Constraint => ({
},
});
export const createEventResearchCommand = (config: EventResearchConfig): {
export const createEventResearchCommand = (
config: EventResearchConfig
): {
ActionDefinition: new <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
env: TurnCommandEnv
) => GeneralActionDefinition<TriggerState, Record<string, never>>;
@@ -61,6 +60,7 @@ export const createEventResearchCommand = (config: EventResearchConfig): {
> implements GeneralActionDefinition<TriggerState, Record<string, never>> {
public readonly key = config.key;
public readonly name = ACTION_NAME;
public readonly countsAsInheritanceActiveAction = true;
constructor(private readonly env: TurnCommandEnv) {}
@@ -93,6 +93,10 @@ export const createEventResearchCommand = (config: EventResearchConfig): {
];
}
getPreReqTurn(): number {
return PRE_REQ_TURN;
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: Record<string, never>
+3 -8
View File
@@ -21,20 +21,15 @@ export type TriggerDomesticActionType =
export type TriggerDomesticVarType = 'cost' | 'score' | 'success' | 'fail' | 'train' | 'atmos' | 'rice' | 'probability';
export type TriggerStrategicActionType = '의병모집' | '허보' | '필사즉생' | '백성동원' | '이호경식' | '수몰' | '급습';
export type TriggerStrategicActionType =
'의병모집' | '허보' | '필사즉생' | '백성동원' | '이호경식' | '수몰' | '급습' | '피장파장';
export type TriggerStrategicVarType = 'delay' | 'globalDelay';
export type TriggerNationalIncomeType = 'gold' | 'rice' | 'pop';
export type GeneralStatName =
| 'leadership'
| 'strength'
| 'intelligence'
| 'experience'
| 'dedication'
| 'sabotageDefence'
| 'sabotageAttack';
'leadership' | 'strength' | 'intelligence' | 'experience' | 'dedication' | 'sabotageDefence' | 'sabotageAttack';
export type WarStatName =
| GeneralStatName