fix: 커맨드 차등 생명주기와 로그 그래프를 보강
장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다. 실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
@@ -28,12 +28,15 @@ export interface GeneralActionResolveContext<
|
||||
rng: RandomGenerator;
|
||||
city?: City;
|
||||
nation?: Nation | null;
|
||||
/** Ref General이 한 장수 lifecycle 동안 유지하는 정적 국가 조회값. */
|
||||
legacyStaticNationName?: string;
|
||||
addLog(message: string, options?: Partial<Omit<LogEntryDraft, 'text'>>): void;
|
||||
addPostProgressionLog?(message: string, options?: Partial<Omit<LogEntryDraft, 'text'>>): void;
|
||||
}
|
||||
|
||||
export type GeneralActionResolveInputContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> = Omit<
|
||||
GeneralActionResolveContext<TriggerState>,
|
||||
'addLog'
|
||||
'addLog' | 'addPostProgressionLog'
|
||||
>;
|
||||
|
||||
export interface TurnScheduleContext {
|
||||
@@ -125,6 +128,7 @@ export interface GeneralActionResolution {
|
||||
completed: boolean;
|
||||
nextTurnAt: Date;
|
||||
logs: LogEntryDraft[];
|
||||
postProgressionLogs: LogEntryDraft[];
|
||||
effects: GeneralActionEffect[];
|
||||
destroyedNationIds?: NationId[];
|
||||
created?: {
|
||||
@@ -216,6 +220,7 @@ export const createLogEffect = (message: string, options: Partial<Omit<LogEntryD
|
||||
...(options.userId !== undefined ? { userId: options.userId } : {}),
|
||||
...(options.subType !== undefined ? { subType: options.subType } : {}),
|
||||
...(options.meta !== undefined ? { meta: options.meta } : {}),
|
||||
...(options.legacyFlushGroup !== undefined ? { legacyFlushGroup: options.legacyFlushGroup } : {}),
|
||||
format: options.format ?? LogFormat.MONTH,
|
||||
},
|
||||
});
|
||||
@@ -357,6 +362,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
||||
args: Args
|
||||
): GeneralActionResolution => {
|
||||
const logs: LogEntryDraft[] = [];
|
||||
const postProgressionLogs: LogEntryDraft[] = [];
|
||||
const accumulator: ActionResolutionAccumulator = {
|
||||
createdGenerals: [],
|
||||
createdNations: [],
|
||||
@@ -372,6 +378,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
||||
} as WorldState<TriggerState>,
|
||||
(draft) => {
|
||||
const addLog = createActionLogSink(context, logs);
|
||||
const addPostProgressionLog = createActionLogSink(context, postProgressionLogs);
|
||||
|
||||
outcome = resolver.resolve(
|
||||
{
|
||||
@@ -381,6 +388,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
||||
city: castDraft(draft.city),
|
||||
nation: castDraft(draft.nation),
|
||||
addLog,
|
||||
addPostProgressionLog,
|
||||
} as GeneralActionResolveContext<TriggerState>,
|
||||
args
|
||||
);
|
||||
@@ -405,6 +413,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
||||
completed: outcome?.completed !== false,
|
||||
nextTurnAt,
|
||||
logs,
|
||||
postProgressionLogs,
|
||||
effects: accumulator.pendingEffects,
|
||||
...(outcome?.alternative ? { alternative: outcome.alternative } : {}),
|
||||
...(outcome?.deletedTroopIds?.length ? { deletedTroopIds: outcome.deletedTroopIds } : {}),
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface ActionRandomSource {
|
||||
}
|
||||
|
||||
export interface ActionContextGeneral extends General {
|
||||
picture?: string | null;
|
||||
imageServer?: number;
|
||||
turnTime: Date;
|
||||
}
|
||||
|
||||
@@ -23,6 +25,8 @@ export type ActionContextBase = {
|
||||
worldView?: GeneralWorldView;
|
||||
rng: ActionRandomSource;
|
||||
uniqueLottery?: UniqueLotteryRunner;
|
||||
/** Ref General#getStaticNation()이 수뇌턴과 장수턴 사이에 유지하는 캐시값. */
|
||||
legacyStaticNationName?: string;
|
||||
time?: {
|
||||
year: number;
|
||||
month: number;
|
||||
@@ -72,6 +76,10 @@ export interface ActionContextWorldRef {
|
||||
|
||||
export interface ActionContextOptions<TArgs extends Record<string, unknown> = Record<string, unknown>> {
|
||||
world: ActionContextWorldState;
|
||||
/** Ref Message::sendRaw stamps the current logical game tick, not the actor turn time. */
|
||||
gameNow?: Date;
|
||||
/** Differential fixtures may point shared message icons at the Ref asset origin. */
|
||||
messageSharedIconBaseUrl?: string;
|
||||
scenarioConfig: ScenarioConfig;
|
||||
scenarioMeta?: ScenarioMeta;
|
||||
map?: MapDefinition;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GENERAL_TURN_COMMAND_KEYS, isGeneralTurnCommandKey, type GeneralTurnCommandKey } from './general/index.js';
|
||||
import { NATION_TURN_COMMAND_KEYS, isNationTurnCommandKey, type NationTurnCommandKey } from './nation/index.js';
|
||||
import { asStringArray } from '@sammo-ts/common';
|
||||
import { asStringArray, isRecord } from '@sammo-ts/common';
|
||||
import { TurnCommandProfileInputSchema } from '../../resources/turnCommandSchema.js';
|
||||
|
||||
export interface TurnCommandProfile {
|
||||
@@ -8,31 +8,43 @@ export interface TurnCommandProfile {
|
||||
nation: NationTurnCommandKey[];
|
||||
}
|
||||
|
||||
const asStringArrayOrNull = (value: unknown): string[] | null => {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const list = asStringArray(value);
|
||||
return list.length > 0 ? list : null;
|
||||
};
|
||||
export interface TurnCommandGroup<Key extends string> {
|
||||
category: string;
|
||||
commands: Key[];
|
||||
}
|
||||
|
||||
export interface ScenarioTurnCommandProfileResolution {
|
||||
profile: TurnCommandProfile;
|
||||
generalGroups: Array<TurnCommandGroup<GeneralTurnCommandKey>> | null;
|
||||
nationGroups: Array<TurnCommandGroup<NationTurnCommandKey>> | null;
|
||||
}
|
||||
|
||||
const parseKeyList = <T extends string>(options: {
|
||||
raw: unknown;
|
||||
defaults: T[];
|
||||
isKey: (value: string) => value is T;
|
||||
label: string;
|
||||
}): T[] => {
|
||||
const rawList = asStringArrayOrNull(options.raw);
|
||||
if (!rawList) {
|
||||
return options.defaults;
|
||||
if (!Array.isArray(options.raw) || options.raw.length === 0) {
|
||||
throw new Error(`${options.label} command profile must be a non-empty array.`);
|
||||
}
|
||||
const parsed: T[] = [];
|
||||
for (const value of rawList) {
|
||||
const seen = new Set<T>();
|
||||
for (const value of asStringArray(options.raw)) {
|
||||
if (!options.isKey(value)) {
|
||||
throw new Error(`Unknown ${options.label} command key: ${value}`);
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
throw new Error(`Duplicate ${options.label} command key: ${value}`);
|
||||
}
|
||||
seen.add(value);
|
||||
parsed.push(value);
|
||||
}
|
||||
if (parsed.length !== options.raw.length) {
|
||||
throw new Error(`${options.label} command profile contains a non-string key.`);
|
||||
}
|
||||
if (!parsed.includes('휴식' as T)) {
|
||||
throw new Error(`${options.label} command profile must include 휴식.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
@@ -41,27 +53,101 @@ export const DEFAULT_TURN_COMMAND_PROFILE: TurnCommandProfile = {
|
||||
nation: [...NATION_TURN_COMMAND_KEYS],
|
||||
};
|
||||
|
||||
export const parseTurnCommandProfile = (
|
||||
raw: unknown,
|
||||
fallback: TurnCommandProfile = DEFAULT_TURN_COMMAND_PROFILE
|
||||
): TurnCommandProfile => {
|
||||
export const parseTurnCommandProfile = (raw: unknown): TurnCommandProfile => {
|
||||
const parsed = TurnCommandProfileInputSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return fallback;
|
||||
throw new Error(`Invalid turn command profile: ${parsed.error.message}`);
|
||||
}
|
||||
const data = parsed.data;
|
||||
return {
|
||||
general: parseKeyList({
|
||||
raw: data.general,
|
||||
defaults: fallback.general,
|
||||
isKey: isGeneralTurnCommandKey,
|
||||
label: 'general',
|
||||
}),
|
||||
nation: parseKeyList({
|
||||
raw: data.nation,
|
||||
defaults: fallback.nation,
|
||||
isKey: isNationTurnCommandKey,
|
||||
label: 'nation',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const parseScenarioCommandGroups = <Key extends string>(options: {
|
||||
raw: unknown;
|
||||
isKey: (value: string) => value is Key;
|
||||
label: string;
|
||||
}): Array<TurnCommandGroup<Key>> | null => {
|
||||
if (options.raw === undefined || options.raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(options.raw)) {
|
||||
throw new Error(`Scenario ${options.label} command groups must be an object.`);
|
||||
}
|
||||
|
||||
const groups: Array<TurnCommandGroup<Key>> = [];
|
||||
const seen = new Set<Key>();
|
||||
for (const [category, rawCommands] of Object.entries(options.raw)) {
|
||||
if (!category.trim()) {
|
||||
throw new Error(`Scenario ${options.label} command category must be non-empty.`);
|
||||
}
|
||||
if (!Array.isArray(rawCommands)) {
|
||||
throw new Error(`Scenario ${options.label} command category ${category} must be an array.`);
|
||||
}
|
||||
const commands: Key[] = [];
|
||||
for (const value of asStringArray(rawCommands)) {
|
||||
if (!options.isKey(value)) {
|
||||
throw new Error(`Unknown scenario ${options.label} command key: ${value}`);
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
throw new Error(`Duplicate scenario ${options.label} command key: ${value}`);
|
||||
}
|
||||
seen.add(value);
|
||||
commands.push(value);
|
||||
}
|
||||
if (commands.length !== rawCommands.length) {
|
||||
throw new Error(`Scenario ${options.label} command category ${category} contains a non-string key.`);
|
||||
}
|
||||
groups.push({ category, commands });
|
||||
}
|
||||
|
||||
const flattened = groups.flatMap((group) => group.commands);
|
||||
if (!flattened.includes('휴식' as Key)) {
|
||||
throw new Error(`Scenario ${options.label} command groups must include 휴식.`);
|
||||
}
|
||||
return groups;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ref replaces GameConst::$availableGeneralCommand/$availableChiefCommand with
|
||||
* the scenario const when present. Resolve that same public/executable profile
|
||||
* here so the API and daemon cannot silently use the default profile instead.
|
||||
*/
|
||||
export const resolveScenarioTurnCommandProfile = (
|
||||
scenarioConst: unknown,
|
||||
fallback: TurnCommandProfile
|
||||
): ScenarioTurnCommandProfileResolution => {
|
||||
if (scenarioConst !== undefined && scenarioConst !== null && !isRecord(scenarioConst)) {
|
||||
throw new Error('Scenario const must be an object.');
|
||||
}
|
||||
const config = isRecord(scenarioConst) ? scenarioConst : {};
|
||||
const generalGroups = parseScenarioCommandGroups({
|
||||
raw: config.availableGeneralCommand,
|
||||
isKey: isGeneralTurnCommandKey,
|
||||
label: 'general',
|
||||
});
|
||||
const nationGroups = parseScenarioCommandGroups({
|
||||
raw: config.availableChiefCommand,
|
||||
isKey: isNationTurnCommandKey,
|
||||
label: 'nation',
|
||||
});
|
||||
|
||||
return {
|
||||
profile: {
|
||||
general: generalGroups ? generalGroups.flatMap((group) => group.commands) : [...fallback.general],
|
||||
nation: nationGroups ? nationGroups.flatMap((group) => group.commands) : [...fallback.nation],
|
||||
},
|
||||
generalGroups,
|
||||
nationGroups,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export const processGeneralActionWithFallback = async <TriggerState extends Gene
|
||||
let currentArgs = initialArgs;
|
||||
let loopLimit = 5; // Prevent infinite loops
|
||||
const accumulatedLogs: LogEntryDraft[] = [];
|
||||
const accumulatedPostProgressionLogs: LogEntryDraft[] = [];
|
||||
|
||||
while (loopLimit > 0) {
|
||||
loopLimit--;
|
||||
@@ -32,6 +33,7 @@ export const processGeneralActionWithFallback = async <TriggerState extends Gene
|
||||
|
||||
if (resolution.alternative) {
|
||||
accumulatedLogs.push(...resolution.logs);
|
||||
accumulatedPostProgressionLogs.push(...resolution.postProgressionLogs);
|
||||
const { commandKey, args } = resolution.alternative;
|
||||
const nextResolver = await commandLoader.load(commandKey);
|
||||
|
||||
@@ -46,6 +48,9 @@ export const processGeneralActionWithFallback = async <TriggerState extends Gene
|
||||
if (accumulatedLogs.length > 0) {
|
||||
resolution.logs.unshift(...accumulatedLogs);
|
||||
}
|
||||
if (accumulatedPostProgressionLogs.length > 0) {
|
||||
resolution.postProgressionLogs.unshift(...accumulatedPostProgressionLogs);
|
||||
}
|
||||
return resolution;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ export class ActionResolver<
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
generalId: target.id,
|
||||
legacyFlushGroup: -1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,13 +140,8 @@ export class ActionDefinition<
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
context.addLog(`<Y>${general.name}</>${josaYi} <G><b>${cityName}</b></>에서 거병`, {
|
||||
scope: LogScope.NATION,
|
||||
nationId: newNationId,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
|
||||
// Ref queues the national history entry on the actor logger created
|
||||
// while nationID is still 0, so ActionLogger::flush discards it.
|
||||
tryApplyUniqueLottery(context, {
|
||||
acquireType: '아이템',
|
||||
reason: ACTION_NAME,
|
||||
|
||||
@@ -105,7 +105,7 @@ export class ActionDefinition<
|
||||
`군량 <C>${Math.round(buyAmount).toLocaleString()}</>을 사서 자금 <C>${Math.round(
|
||||
sellAmount
|
||||
).toLocaleString()}</>을 썼습니다.`,
|
||||
{ format: LogFormat.PLAIN }
|
||||
{ format: LogFormat.MONTH }
|
||||
);
|
||||
} else {
|
||||
const sellAmount = Math.min(args.amount, general.rice);
|
||||
@@ -118,7 +118,7 @@ export class ActionDefinition<
|
||||
`군량 <C>${Math.round(sellAmount).toLocaleString()}</>을 팔아 자금 <C>${Math.round(
|
||||
buyAmount
|
||||
).toLocaleString()}</>을 얻었습니다.`,
|
||||
{ format: LogFormat.PLAIN }
|
||||
{ format: LogFormat.MONTH }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@ import type {
|
||||
GeneralActionResolver,
|
||||
GeneralActionEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect, createLogEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||
import { createGeneralPatchEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory } from '@sammo-ts/logic/logging/types.js';
|
||||
import { z } from 'zod';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import { buildScoutMessageDraft } from '@sammo-ts/logic/messages/scoutMessage.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface EmployResolveContext<
|
||||
destGeneral?: General;
|
||||
env?: TurnCommandEnv;
|
||||
messageTime: Date;
|
||||
messageSharedIconBaseUrl?: string;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '등용';
|
||||
@@ -88,10 +89,9 @@ export class ActionResolver<
|
||||
> implements GeneralActionResolver<TriggerState, EmployArgs> {
|
||||
readonly key = ACTION_KEY;
|
||||
|
||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: EmployArgs): GeneralActionOutcome<TriggerState> {
|
||||
resolve(context: GeneralActionResolveContext<TriggerState>, _args: EmployArgs): GeneralActionOutcome<TriggerState> {
|
||||
const ctx = context as EmployResolveContext<TriggerState>;
|
||||
const general = ctx.general;
|
||||
const { destGeneralId } = args;
|
||||
|
||||
const destGeneral = ctx.destGeneral;
|
||||
if (!destGeneral) {
|
||||
@@ -137,45 +137,19 @@ export class ActionResolver<
|
||||
const destNation = ctx.worldView
|
||||
?.listNations?.()
|
||||
.find((candidate) => candidate.id === destGeneral.nationId);
|
||||
const josaRo = JosaUtil.pick(ctx.nation.name, '로');
|
||||
effects.push(
|
||||
createMessageEffect({
|
||||
msgType: 'private',
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: ctx.nation.id,
|
||||
nationName: ctx.nation.name,
|
||||
color: ctx.nation.color,
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: destGeneral.id,
|
||||
generalName: destGeneral.name,
|
||||
nationId: destGeneral.nationId,
|
||||
nationName: destNation?.name ?? '재야',
|
||||
color: destNation?.color ?? '#000000',
|
||||
icon: '',
|
||||
},
|
||||
text: `${ctx.nation.name}${josaRo} 망명 권유 서신`,
|
||||
time: ctx.messageTime,
|
||||
validUntil: new Date('9999-12-31T12:59:59.000Z'),
|
||||
option: { action: 'scout' },
|
||||
})
|
||||
);
|
||||
const message = buildScoutMessageDraft({
|
||||
srcGeneral: general,
|
||||
destGeneral,
|
||||
srcNation: ctx.nation,
|
||||
destNation: destNation ?? null,
|
||||
time: ctx.messageTime,
|
||||
...(ctx.messageSharedIconBaseUrl ? { sharedIconBaseUrl: ctx.messageSharedIconBaseUrl } : {}),
|
||||
});
|
||||
if (message) {
|
||||
effects.push(createMessageEffect(message));
|
||||
}
|
||||
}
|
||||
|
||||
effects.push(
|
||||
createLogEffect(
|
||||
`<Y>${general.name}</>(${ctx.nation?.name ?? '재야'})로 부터 등용 권유 서신이 도착했습니다.`,
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneralId,
|
||||
category: LogCategory.ACTION,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
@@ -249,10 +223,12 @@ export const actionContextBuilder = (base: ActionContextBase, options: ActionCon
|
||||
...base,
|
||||
destGeneral,
|
||||
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
||||
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
|
||||
messageTime:
|
||||
(base.general as General & { turnTime?: Date }).turnTime instanceof Date
|
||||
options.gameNow ??
|
||||
((base.general as General & { turnTime?: Date }).turnTime instanceof Date
|
||||
? (base.general as General & { turnTime: Date }).turnTime
|
||||
: options.world.lastTurnTime,
|
||||
: options.world.lastTurnTime),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -86,14 +86,14 @@ export class ActionResolver<
|
||||
// Self Log
|
||||
context.addLog(`<D>${destNationName}</>${josaRo} 망명하여 수도로 이동합니다.`, {
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
|
||||
// Global Log
|
||||
context.addLog(`<Y>${generalName}</>${josaYi} <D><b>${destNationName}</b></>${josaRo} <S>망명</>하였습니다.`, {
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.PLAIN,
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
|
||||
// 2. Recruiter Rewards
|
||||
@@ -143,6 +143,7 @@ export class ActionResolver<
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -164,6 +165,7 @@ export class ActionResolver<
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -283,18 +285,22 @@ export class ActionResolver<
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
context.addLog(`<Y>${generalName}</> 등용에 성공했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
});
|
||||
context.addLog(`<Y>${generalName}</> 등용에 성공`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
effects.push(
|
||||
createLogEffect(`<Y>${generalName}</> 등용에 성공했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createLogEffect(`<Y>${generalName}</> 등용에 성공`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
})
|
||||
);
|
||||
|
||||
const deletedTroopIds: number[] = [];
|
||||
if (general.troopId === general.id) {
|
||||
|
||||
@@ -14,11 +14,7 @@ import type {
|
||||
GeneralActionOutcome,
|
||||
GeneralActionResolveContext,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import {
|
||||
createGeneralPatchEffect,
|
||||
createLogEffect,
|
||||
createNationPatchEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect, 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 { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
@@ -76,11 +72,13 @@ export class ActionDefinition<
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}
|
||||
);
|
||||
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${lord.name}</>에게서 군주자리를 찬탈`, {
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
context.addLog('모반에 성공했습니다.', {
|
||||
scope: LogScope.GENERAL,
|
||||
@@ -90,6 +88,7 @@ export class ActionDefinition<
|
||||
context.addLog(`모반으로 <D><b>${nation.name}</b></>의 군주자리를 찬탈`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
|
||||
effects.push(
|
||||
@@ -97,7 +96,8 @@ export class ActionDefinition<
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: lord.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
format: LogFormat.MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createLogEffect(
|
||||
`<D><b>${general.name}</b></>의 모반으로 인해 <D><b>${nation.name}</b></>의 군주자리를 박탈당함`,
|
||||
@@ -105,7 +105,8 @@ export class ActionDefinition<
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: lord.id,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.PLAIN,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
),
|
||||
createGeneralPatchEffect(
|
||||
|
||||
@@ -7,11 +7,7 @@ import type {
|
||||
GeneralActionOutcome,
|
||||
GeneralActionResolveContext,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import {
|
||||
createGeneralPatchEffect,
|
||||
createLogEffect,
|
||||
createNationPatchEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect, 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 { z } from 'zod';
|
||||
@@ -107,11 +103,13 @@ export class ActionDefinition<
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}
|
||||
);
|
||||
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${destGeneral.name}</>에게 선양`, {
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
context.addLog(`<Y>${destGeneral.name}</>에게 군주의 자리를 물려줍니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
@@ -121,6 +119,7 @@ export class ActionDefinition<
|
||||
context.addLog(`<D><b>${nation.name}</b></>의 군주자리를 <Y>${destGeneral.name}</>에게 선양`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
|
||||
effects.push(
|
||||
@@ -128,13 +127,15 @@ export class ActionDefinition<
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
format: LogFormat.MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createLogEffect(`<D><b>${nation.name}</b></>의 군주자리를 물려 받음`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.PLAIN,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createGeneralPatchEffect(
|
||||
{
|
||||
|
||||
@@ -60,7 +60,7 @@ export class ActionResolver<
|
||||
context.addLog(`<Y>${general.name}</>${josaYi} <R>은퇴</>하고 그 자손이 유지를 이어받았습니다.`, {
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.RAWTEXT,
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
context.addLog('나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.', {
|
||||
category: LogCategory.ACTION,
|
||||
|
||||
@@ -13,8 +13,8 @@ import type {
|
||||
GeneralActionResolver,
|
||||
GeneralActionEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
@@ -113,6 +113,17 @@ export class ActionResolver<
|
||||
target.id
|
||||
)
|
||||
);
|
||||
if (!isSelf) {
|
||||
effects.push(
|
||||
createLogEffect(`방랑군 세력이 <G><b>${destCityName}</b></>${josaRo} 이동했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { effects };
|
||||
|
||||
@@ -493,6 +493,7 @@ export class ActionResolver<
|
||||
const meta: GeneralMeta = {
|
||||
killturn,
|
||||
npcType: NPC_TYPE,
|
||||
npc_org: NPC_TYPE,
|
||||
explevel: 0,
|
||||
dedlevel: 1,
|
||||
crewTypeId: this.env.defaultCrewTypeId,
|
||||
|
||||
@@ -179,6 +179,7 @@ export class ActionDefinition<
|
||||
context.addLog(`<D><b>${destNation.name}</b></>에 임관`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
context.addLog(`<Y>${general.name}</>${josaYi} <D><b>${destNation.name}</b></>에 <S>임관</>했습니다.`, {
|
||||
scope: LogScope.SYSTEM,
|
||||
|
||||
@@ -128,6 +128,7 @@ export class ActionDefinition<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: destGeneral.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createLogEffect(`<Y>${destGeneral.name}</>에게 ${resName} <C>${amountText}</>을 증여했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
|
||||
@@ -72,6 +72,7 @@ export class ActionDefinition<
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
generalId: member.id,
|
||||
legacyFlushGroup: -1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ export class ActionResolver<
|
||||
: '<C>↓</>미미';
|
||||
ctx.addLog(`【<span class='ev_notice'>${destNation.name}</span>】아국대비기술:${techText}`, {
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.RAWTEXT,
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
}
|
||||
} else if (distance === 2) {
|
||||
@@ -317,12 +317,7 @@ export class ActionDefinition<
|
||||
return [notOccupiedDestCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
||||
}
|
||||
|
||||
formatConstraintFailure(
|
||||
reason: string,
|
||||
_ctx: ConstraintContext,
|
||||
args: SpyArgs,
|
||||
view: StateView
|
||||
): string | null {
|
||||
formatConstraintFailure(reason: string, _ctx: ConstraintContext, args: SpyArgs, view: StateView): string | null {
|
||||
return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'location');
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
createCityPatchEffect,
|
||||
createDiplomacyPatchEffect,
|
||||
createGeneralPatchEffect,
|
||||
createMessageEffect,
|
||||
createNationPatchEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { JosaUtil, LiteHashDRBG } from '@sammo-ts/common';
|
||||
@@ -30,6 +31,7 @@ import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import type { WarAftermathConfig, WarEngineConfig, WarTimeContext } from '@sammo-ts/logic/war/types.js';
|
||||
import { resolveWarAftermath } from '@sammo-ts/logic/war/aftermath.js';
|
||||
import { resolveWarBattle } from '@sammo-ts/logic/war/engine.js';
|
||||
import { LegacyWarLogFlushSequence } from '@sammo-ts/logic/war/legacyFlushSequence.js';
|
||||
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
|
||||
import type { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
|
||||
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
||||
@@ -63,6 +65,8 @@ export interface DispatchResolveContext<
|
||||
seedBase: string;
|
||||
warConfig: WarEngineConfig;
|
||||
aftermathConfig: WarAftermathConfig;
|
||||
messageTime: Date;
|
||||
messageSharedIconBaseUrl?: string;
|
||||
}
|
||||
|
||||
export const orderDefenderGenerals = <TriggerState extends GeneralTriggerState>(
|
||||
@@ -70,6 +74,7 @@ export const orderDefenderGenerals = <TriggerState extends GeneralTriggerState>(
|
||||
): General<TriggerState>[] => [...generals].sort((left, right) => left.id - right.id);
|
||||
|
||||
const ACTION_NAME = '출병';
|
||||
const LEGACY_SORTIE_FLUSH_GROUP_START = Number.MIN_SAFE_INTEGER;
|
||||
const ARGS_SCHEMA = z.object({
|
||||
destCityId: z.number(),
|
||||
});
|
||||
@@ -512,16 +517,24 @@ export class ActionDefinition<
|
||||
};
|
||||
}
|
||||
|
||||
// Ref che_출병은 명령 내부에서도 General::applyDB()/ActionLogger::flush()를
|
||||
// 여러 번 수행한다. 외부 TurnExecutionHelper progression(group 0)과
|
||||
// 섞이지 않도록 명령 내부 epoch은 작은 음수부터 단조 증가시킨다.
|
||||
const legacyFlushSequence = new LegacyWarLogFlushSequence(LEGACY_SORTIE_FLUSH_GROUP_START);
|
||||
const preWarFlushGroup = legacyFlushSequence.claimGroup()!;
|
||||
|
||||
if (finalTargetCity.id !== destCity.id) {
|
||||
const josaRo = JosaUtil.pick(finalTargetCity.name, '로');
|
||||
const josaUl = JosaUtil.pick(destCity.name, '을');
|
||||
if (minDist === 0) {
|
||||
context.addLog(
|
||||
`<G><b>${finalTargetCity.name}</b></>${josaRo} 가기 위해 <G><b>${destCity.name}</b></>${josaUl} 거쳐야 합니다.`
|
||||
`<G><b>${finalTargetCity.name}</b></>${josaRo} 가기 위해 <G><b>${destCity.name}</b></>${josaUl} 거쳐야 합니다.`,
|
||||
{ legacyFlushGroup: preWarFlushGroup }
|
||||
);
|
||||
} else {
|
||||
context.addLog(
|
||||
`<G><b>${finalTargetCity.name}</b></>${josaRo} 가는 도중 <G><b>${destCity.name}</b></>${josaUl} 거치기로 합니다.`
|
||||
`<G><b>${finalTargetCity.name}</b></>${josaRo} 가는 도중 <G><b>${destCity.name}</b></>${josaUl} 거치기로 합니다.`,
|
||||
{ legacyFlushGroup: preWarFlushGroup }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -614,6 +627,7 @@ export class ActionDefinition<
|
||||
})),
|
||||
defenderCity,
|
||||
defenderNation,
|
||||
legacyFlushSequence,
|
||||
...(shouldTraceWar
|
||||
? {
|
||||
trace: (event) => {
|
||||
@@ -635,7 +649,9 @@ export class ActionDefinition<
|
||||
unitSet,
|
||||
config: context.aftermathConfig,
|
||||
time,
|
||||
messageTime: context.messageTime,
|
||||
hiddenSeed: context.seedBase,
|
||||
legacyFlushSequence,
|
||||
generalActionModules: this.generalModules,
|
||||
calcNationTechGain: ({ nation, baseGain }) => {
|
||||
const module = this.nationTraitModules.get(nation.typeCode);
|
||||
@@ -644,6 +660,7 @@ export class ActionDefinition<
|
||||
baseGain
|
||||
);
|
||||
},
|
||||
...(context.messageSharedIconBaseUrl ? { messageSharedIconBaseUrl: context.messageSharedIconBaseUrl } : {}),
|
||||
...(this.trace ? { trace: this.trace } : {}),
|
||||
});
|
||||
|
||||
@@ -676,12 +693,22 @@ export class ActionDefinition<
|
||||
}
|
||||
|
||||
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
||||
// processWar() 반환 후 StaticEvent/unique 로직이 끝나면 Ref line 259의
|
||||
// actor applyDB가 실행된다. 이 epoch은 command 반환 후 outer progression과 별개다.
|
||||
const finalActorFlushGroup = legacyFlushSequence.claimGroup()!;
|
||||
|
||||
for (const entry of battle.logs) {
|
||||
effects.push({ type: 'log', entry });
|
||||
}
|
||||
for (const entry of aftermath.logs) {
|
||||
effects.push({ type: 'log', entry });
|
||||
effects.push({
|
||||
type: 'log',
|
||||
entry:
|
||||
entry.legacyFlushGroup === undefined ? { ...entry, legacyFlushGroup: finalActorFlushGroup } : entry,
|
||||
});
|
||||
}
|
||||
for (const message of aftermath.conquest?.messages ?? []) {
|
||||
effects.push(createMessageEffect(message));
|
||||
}
|
||||
|
||||
const generalPatches = new Map<number, General<TriggerState>>();
|
||||
@@ -745,7 +772,20 @@ export class ActionDefinition<
|
||||
);
|
||||
}
|
||||
|
||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||
const addFinalActorLog: NonNullable<typeof context.addPostProgressionLog> = (message, options = {}) => {
|
||||
const sink = context.addPostProgressionLog ?? context.addLog;
|
||||
sink(message, {
|
||||
...options,
|
||||
legacyFlushGroup: finalActorFlushGroup,
|
||||
});
|
||||
};
|
||||
tryApplyUniqueLottery(
|
||||
{
|
||||
...context,
|
||||
addPostProgressionLog: addFinalActorLog,
|
||||
},
|
||||
{ acquireType: '아이템', reason: ACTION_NAME }
|
||||
);
|
||||
|
||||
return {
|
||||
effects,
|
||||
@@ -792,6 +832,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
seedBase: options.seedBase,
|
||||
warConfig,
|
||||
aftermathConfig,
|
||||
messageTime: options.gameNow ?? base.general.turnTime,
|
||||
...(options.messageSharedIconBaseUrl ? { messageSharedIconBaseUrl: options.messageSharedIconBaseUrl } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ export class ActionResolver<
|
||||
|
||||
// Penalty
|
||||
const betrayal = typeof general.meta.betray === 'number' ? general.meta.betray : 0;
|
||||
const belong = typeof general.meta.belong === 'number' ? general.meta.belong : 0;
|
||||
const maxBelong = typeof general.meta.max_belong === 'number' ? general.meta.max_belong : 0;
|
||||
const penaltyRatio = betrayal * 0.1;
|
||||
const nextExp = Math.round(general.experience * (1 - penaltyRatio));
|
||||
@@ -83,7 +82,7 @@ export class ActionResolver<
|
||||
context.addLog(`<Y>${general.name}</>${josaYi} <D><b>${nation.name}</b></>에서 <R>하야</>했습니다.`, {
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.RAWTEXT,
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
|
||||
effects.push(
|
||||
@@ -101,7 +100,9 @@ export class ActionResolver<
|
||||
...general.meta,
|
||||
betray: Math.min(9, betrayal + 1),
|
||||
belong: 0,
|
||||
...(general.npcState < 2 ? { max_belong: Math.max(belong, maxBelong) } : {}),
|
||||
// Ref resets belong before max_belong is refreshed here.
|
||||
// Preserve that ordering, including its legacy behavior.
|
||||
...(general.npcState < 2 ? { max_belong: Math.max(0, maxBelong) } : {}),
|
||||
makelimit: 12,
|
||||
officer_city: 0,
|
||||
permission: 'normal',
|
||||
|
||||
@@ -23,8 +23,7 @@ import type { GeneralTurnCommandSpec } from './index.js';
|
||||
const ACTION_NAME = '해산';
|
||||
const ACTION_KEY = 'che_해산';
|
||||
|
||||
const readMetaNumber = (value: unknown): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
const readMetaNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
||||
|
||||
export interface DisbandFactionArgs {}
|
||||
|
||||
@@ -77,7 +76,13 @@ export class ActionDefinition<
|
||||
const defaultGold = this.env.defaultNpcGold > 0 ? this.env.defaultNpcGold : 1000;
|
||||
const defaultRice = this.env.defaultNpcRice > 0 ? this.env.defaultNpcRice : 1000;
|
||||
|
||||
const nationGenerals = context.nationGenerals ?? [];
|
||||
const nationGenerals = [...(context.nationGenerals ?? [])].sort((left, right) => {
|
||||
const leftIsActor = left.id === general.id;
|
||||
const rightIsActor = right.id === general.id;
|
||||
if (leftIsActor !== rightIsActor) return leftIsActor ? 1 : -1;
|
||||
return left.id - right.id;
|
||||
});
|
||||
const nonActorCount = nationGenerals.filter((targetGeneral) => targetGeneral.id !== general.id).length;
|
||||
for (const targetGeneral of nationGenerals) {
|
||||
const isActor = targetGeneral.id === general.id;
|
||||
const belong = readMetaNumber(targetGeneral.meta.belong);
|
||||
@@ -148,6 +153,7 @@ export class ActionDefinition<
|
||||
context.addLog(`<D><b>${nation.name}</b></>${josaUl} 해산`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
|
||||
const josaUn = JosaUtil.pick(nation.name, '은');
|
||||
@@ -159,19 +165,22 @@ export class ActionDefinition<
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
})
|
||||
);
|
||||
for (const targetGeneral of nationGenerals) {
|
||||
for (const [targetIndex, targetGeneral] of nationGenerals.entries()) {
|
||||
const legacyFlushGroup = targetGeneral.id === general.id ? undefined : targetIndex - nonActorCount;
|
||||
effects.push(
|
||||
createLogEffect(`<D><b>${nation.name}</b></>${josaNationYi} <R>멸망</>했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
generalId: targetGeneral.id,
|
||||
...(legacyFlushGroup === undefined ? {} : { legacyFlushGroup }),
|
||||
}),
|
||||
createLogEffect(`<D><b>${nation.name}</b></>${josaNationYi} <R>멸망</>`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
generalId: targetGeneral.id,
|
||||
...(legacyFlushGroup === undefined ? {} : { legacyFlushGroup }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,8 +185,8 @@ export class ActionDefinition<
|
||||
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>하였습니다.`,
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
}
|
||||
),
|
||||
// Global History Log
|
||||
|
||||
@@ -115,28 +115,29 @@ export class ActionResolver<
|
||||
}),
|
||||
];
|
||||
|
||||
for (const target of context.friendlyGenerals) {
|
||||
if (target.id === general.id) {
|
||||
continue;
|
||||
}
|
||||
const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id);
|
||||
const firstLegacyFlushGroup = -(friendlyTargets.length + context.destNationGenerals.length + 1);
|
||||
for (const [index, target] of friendlyTargets.entries()) {
|
||||
effects.push(
|
||||
createLogEffect(broadcastMessage, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: firstLegacyFlushGroup + index,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const destBroadcast = `아국에 <M>${ACTION_NAME}</>${JosaUtil.pick(ACTION_NAME, '이')} 발동되었습니다.`;
|
||||
for (const target of context.destNationGenerals) {
|
||||
for (const [index, target] of context.destNationGenerals.entries()) {
|
||||
effects.push(
|
||||
createLogEffect(destBroadcast, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -148,12 +149,15 @@ export class ActionResolver<
|
||||
strategic_cmd_limit: globalDelay,
|
||||
};
|
||||
effects.push(
|
||||
createLogEffect(broadcastMessage, {
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: nation.id,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
})
|
||||
createLogEffect(
|
||||
`<Y>${generalName}</>${generalJosa} <D><b>${destNationName}</b></>에 <M>${ACTION_NAME}</>${actionJosa} 발동`,
|
||||
{
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: nation.id,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
effects.push(
|
||||
@@ -163,7 +167,8 @@ export class ActionResolver<
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: context.destNation.id,
|
||||
format: LogFormat.PLAIN,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: -1,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ import { clamp } from 'es-toolkit';
|
||||
import { z } from 'zod';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js';
|
||||
|
||||
const ARGS_SCHEMA = z.object({
|
||||
isGold: z.boolean(),
|
||||
@@ -43,6 +44,7 @@ export interface SeizureResolveContext<
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
destGeneral: General<TriggerState>;
|
||||
messageTime: Date;
|
||||
messageSharedIconBaseUrl?: string;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '몰수';
|
||||
@@ -67,16 +69,6 @@ const pickLegacyNpcMessage = (rng: GeneralActionResolveContext['rng']): string =
|
||||
return NPC_SEIZURE_MESSAGES[index]!;
|
||||
};
|
||||
|
||||
const resolveGeneralIcon = (general: General): string => {
|
||||
const runtimePicture = (general as General & { picture?: unknown }).picture;
|
||||
const rawPicture = runtimePicture ?? general.meta.picture;
|
||||
const picture =
|
||||
(typeof rawPicture === 'string' && rawPicture !== '') || typeof rawPicture === 'number'
|
||||
? String(rawPicture)
|
||||
: 'default.jpg';
|
||||
return `https://sam-image.hided.net/icons/${picture}`;
|
||||
};
|
||||
|
||||
export class ActionDefinition<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionDefinition<TriggerState, SeizureArgs, SeizureResolveContext<TriggerState>> {
|
||||
@@ -170,6 +162,7 @@ export class ActionDefinition<
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -183,7 +176,7 @@ export class ActionDefinition<
|
||||
nationId: nation.id,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: resolveGeneralIcon(destGeneral),
|
||||
icon: resolveMessageTargetIcon(destGeneral, context.messageSharedIconBaseUrl),
|
||||
};
|
||||
effects.push(
|
||||
createMessageEffect({
|
||||
@@ -214,7 +207,8 @@ export const actionContextBuilder: ActionContextBuilder<SeizureArgs> = (base, op
|
||||
return {
|
||||
...base,
|
||||
destGeneral,
|
||||
messageTime: base.general.turnTime,
|
||||
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
|
||||
messageTime: options.gameNow ?? base.general.turnTime,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -148,8 +148,8 @@ export class ActionDefinition<
|
||||
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaRo} <M>수도 이전</>하였습니다.`,
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
}
|
||||
),
|
||||
// Global History Log
|
||||
@@ -202,6 +202,7 @@ export class ActionDefinition<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: targetGeneral.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ export class ActionDefinition<
|
||||
nationId: destNation.id,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
),
|
||||
// General Action Log
|
||||
@@ -222,6 +223,7 @@ export class ActionDefinition<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: chief.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -234,6 +236,7 @@ export class ActionDefinition<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: chief.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ export class ActionResolver<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: destGeneral.id,
|
||||
format: LogFormat.MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
})
|
||||
);
|
||||
effects.push(
|
||||
|
||||
@@ -113,6 +113,7 @@ export class ActionResolver<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -136,12 +137,15 @@ export class ActionResolver<
|
||||
strategic_cmd_limit: globalDelay,
|
||||
};
|
||||
effects.push(
|
||||
createLogEffect(broadcastMessage, {
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: nation.id,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
})
|
||||
createLogEffect(
|
||||
`<Y>${generalName}</>${generalJosa} <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>을 발동`,
|
||||
{
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: nation.id,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,8 +90,9 @@ export class ActionDefinition<
|
||||
createLogEffect(`<Y>${general.name}</>에게 부대 탈퇴를 지시 받았습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
format: LogFormat.MONTH,
|
||||
generalId: destGeneral.id,
|
||||
legacyFlushGroup: 1,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { NationTurnCommandSpec } from './index.js';
|
||||
import { z } from 'zod';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { resolveDiplomacyMessageValidMinutes } from '../../../diplomacy/messageValidity.js';
|
||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js';
|
||||
|
||||
const ARGS_SCHEMA = z.object({
|
||||
destNationId: z.number().int().positive(),
|
||||
@@ -33,6 +34,7 @@ interface NonAggressionProposalContext<
|
||||
destNation: Nation;
|
||||
messageValidMinutes: number;
|
||||
messageTime: Date;
|
||||
messageSharedIconBaseUrl?: string;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '불가침 제의';
|
||||
@@ -161,7 +163,7 @@ export class ActionDefinition<
|
||||
nationId: nation.id,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: '',
|
||||
icon: resolveMessageTargetIcon(general, context.messageSharedIconBaseUrl),
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
@@ -169,7 +171,7 @@ export class ActionDefinition<
|
||||
nationId: destNation.id,
|
||||
nationName: destNation.name,
|
||||
color: destNation.color,
|
||||
icon: '',
|
||||
icon: resolveMessageTargetIcon(null, context.messageSharedIconBaseUrl),
|
||||
},
|
||||
text: `${nation.name}${josaWa} ${args.year}년 ${args.month}월까지 불가침 제의 서신`,
|
||||
time: context.messageTime,
|
||||
@@ -195,7 +197,8 @@ export const actionContextBuilder: ActionContextBuilder<NonAggressionProposalArg
|
||||
return {
|
||||
...base,
|
||||
destNation,
|
||||
messageTime: base.general.turnTime,
|
||||
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
|
||||
messageTime: options.gameNow ?? base.general.turnTime,
|
||||
messageValidMinutes: resolveDiplomacyMessageValidMinutes(options.world.tickSeconds),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo
|
||||
import type { NationTurnCommandSpec } from './index.js';
|
||||
import { z } from 'zod';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js';
|
||||
|
||||
const ARGS_SCHEMA = z.object({
|
||||
destNationId: z.number().int().positive(),
|
||||
@@ -30,6 +31,7 @@ interface NonAggressionCancelProposalContext<
|
||||
destNation: Nation;
|
||||
messageValidMinutes: number;
|
||||
messageTime: Date;
|
||||
messageSharedIconBaseUrl?: string;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '불가침 파기 제의';
|
||||
@@ -86,7 +88,7 @@ export class ActionDefinition<
|
||||
nationId: nation.id,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: '',
|
||||
icon: resolveMessageTargetIcon(general, context.messageSharedIconBaseUrl),
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
@@ -94,7 +96,7 @@ export class ActionDefinition<
|
||||
nationId: destNation.id,
|
||||
nationName: destNation.name,
|
||||
color: destNation.color,
|
||||
icon: '',
|
||||
icon: resolveMessageTargetIcon(null, context.messageSharedIconBaseUrl),
|
||||
},
|
||||
text: `${nation.name}의 불가침 파기 제의 서신`,
|
||||
time: context.messageTime,
|
||||
@@ -120,7 +122,8 @@ export const actionContextBuilder: ActionContextBuilder<NonAggressionCancelPropo
|
||||
return {
|
||||
...base,
|
||||
destNation,
|
||||
messageTime: base.general.turnTime,
|
||||
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
|
||||
messageTime: options.gameNow ?? base.general.turnTime,
|
||||
messageValidMinutes: Math.max(30, Math.floor((options.world.tickSeconds / 60) * 3)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { NationTurnCommandSpec } from './index.js';
|
||||
import { z } from 'zod';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js';
|
||||
|
||||
const ARGS_SCHEMA = z.object({
|
||||
destNationId: z.number().int().positive(),
|
||||
@@ -36,6 +37,7 @@ interface DeclareWarResolveContext<
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
messageTime: Date;
|
||||
messageSharedIconBaseUrl?: string;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '선전포고';
|
||||
@@ -152,6 +154,8 @@ export class ActionDefinition<
|
||||
nationId: args.destNationId,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
// Ref는 actor logger를 applyDB로 flush한 뒤 상대국 logger를 따로 flush한다.
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
// Global Action Log
|
||||
createLogEffect(
|
||||
@@ -179,7 +183,7 @@ export class ActionDefinition<
|
||||
nationId,
|
||||
nationName,
|
||||
color: context.nation?.color ?? '',
|
||||
icon: '',
|
||||
icon: resolveMessageTargetIcon(context.general, context.messageSharedIconBaseUrl),
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
@@ -187,7 +191,7 @@ export class ActionDefinition<
|
||||
nationId: args.destNationId,
|
||||
nationName: destNationName,
|
||||
color: context.destNation.color,
|
||||
icon: '',
|
||||
icon: resolveMessageTargetIcon(null, context.messageSharedIconBaseUrl),
|
||||
},
|
||||
text: `【외교】${context.currentYear}년 ${context.currentMonth}월:${nationName}에서 ${destNationName}에 선전포고`,
|
||||
time: context.messageTime,
|
||||
@@ -219,7 +223,8 @@ export const actionContextBuilder: ActionContextBuilder<DeclareWarArgs> = (base,
|
||||
destNation,
|
||||
currentYear: options.world.currentYear,
|
||||
currentMonth: options.world.currentMonth,
|
||||
messageTime: base.general.turnTime,
|
||||
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
|
||||
messageTime: options.gameNow ?? base.general.turnTime,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ export class ActionResolver<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -138,6 +139,7 @@ export class ActionResolver<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -180,6 +182,7 @@ export class ActionResolver<
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: context.destNation.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -376,7 +376,7 @@ export class ActionResolver<
|
||||
|
||||
const actionName = ACTION_NAME;
|
||||
context.addLog(`${actionName} 발동!`);
|
||||
context.addLog(`${actionName} 발동`, {
|
||||
context.addLog(`<M>${actionName}</>${actionJosa} 발동`, {
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
@@ -416,6 +416,7 @@ export class ActionResolver<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -518,6 +519,9 @@ export class ActionResolver<
|
||||
const meta: GeneralMeta = {
|
||||
killturn,
|
||||
npcType: NPC_TYPE,
|
||||
npc_org: NPC_TYPE,
|
||||
explevel: 0,
|
||||
dedlevel: 1,
|
||||
crewTypeId: this.env.defaultCrewTypeId,
|
||||
dex1: dex[0],
|
||||
dex2: dex[1],
|
||||
@@ -567,6 +571,7 @@ export class ActionResolver<
|
||||
}),
|
||||
turnTime,
|
||||
...(turnTick === undefined ? {} : { turnTick }),
|
||||
affinity,
|
||||
bornYear: birthYear,
|
||||
deadYear: deathYear,
|
||||
imageServer: candidate.imageServer ?? 0,
|
||||
|
||||
@@ -122,28 +122,29 @@ export class ActionResolver<
|
||||
}),
|
||||
];
|
||||
|
||||
for (const target of context.friendlyGenerals) {
|
||||
if (target.id === general.id) {
|
||||
continue;
|
||||
}
|
||||
const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id);
|
||||
const firstLegacyFlushGroup = -(friendlyTargets.length + context.destNationGenerals.length + 1);
|
||||
for (const [index, target] of friendlyTargets.entries()) {
|
||||
effects.push(
|
||||
createLogEffect(broadcastMessage, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: firstLegacyFlushGroup + index,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const destBroadcast = `<D><b>${nationName}</b></>${nationJosa} 아국에 <M>${ACTION_NAME}</>${actionJosa} 발동하였습니다.`;
|
||||
for (const target of context.destNationGenerals) {
|
||||
for (const [index, target] of context.destNationGenerals.entries()) {
|
||||
effects.push(
|
||||
createLogEffect(destBroadcast, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -155,12 +156,15 @@ export class ActionResolver<
|
||||
strategic_cmd_limit: globalDelay,
|
||||
};
|
||||
effects.push(
|
||||
createLogEffect(broadcastMessage, {
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: nation.id,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
})
|
||||
createLogEffect(
|
||||
`<Y>${generalName}</>${generalJosa} <D><b>${destNationName}</b></>에 <M>${ACTION_NAME}</>${actionJosa} 발동`,
|
||||
{
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: nation.id,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
effects.push(
|
||||
@@ -170,7 +174,8 @@ export class ActionResolver<
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: context.destNation.id,
|
||||
format: LogFormat.PLAIN,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: -1,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { NationTurnCommandSpec } from './index.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js';
|
||||
|
||||
const ARGS_SCHEMA = z.object({
|
||||
destNationId: z.number().int().positive(),
|
||||
@@ -30,6 +31,7 @@ interface StopWarProposalContext<
|
||||
destNation: Nation;
|
||||
messageValidMinutes: number;
|
||||
messageTime: Date;
|
||||
messageSharedIconBaseUrl?: string;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '종전 제의';
|
||||
@@ -81,7 +83,7 @@ export class ActionDefinition<
|
||||
nationId: nation.id,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: '',
|
||||
icon: resolveMessageTargetIcon(general, context.messageSharedIconBaseUrl),
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
@@ -89,7 +91,7 @@ export class ActionDefinition<
|
||||
nationId: destNation.id,
|
||||
nationName: destNation.name,
|
||||
color: destNation.color,
|
||||
icon: '',
|
||||
icon: resolveMessageTargetIcon(null, context.messageSharedIconBaseUrl),
|
||||
},
|
||||
text: `${nation.name}의 종전 제의 서신`,
|
||||
time: context.messageTime,
|
||||
@@ -115,7 +117,8 @@ export const actionContextBuilder: ActionContextBuilder<StopWarProposalArgs> = (
|
||||
return {
|
||||
...base,
|
||||
destNation,
|
||||
messageTime: base.general.turnTime,
|
||||
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
|
||||
messageTime: options.gameNow ?? base.general.turnTime,
|
||||
messageValidMinutes: Math.max(30, Math.floor((options.world.tickSeconds / 60) * 3)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -175,8 +175,8 @@ export class ActionDefinition<
|
||||
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>하였습니다.`,
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
}
|
||||
),
|
||||
// Global History Log
|
||||
|
||||
@@ -224,8 +224,8 @@ export class ActionDefinition<
|
||||
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaRo} <M>${ACTION_NAME}</>를 명령하였습니다.`,
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
}
|
||||
),
|
||||
// Global History Log
|
||||
|
||||
@@ -204,8 +204,8 @@ export class ActionDefinition<
|
||||
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>하였습니다.`,
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
}
|
||||
),
|
||||
createLogEffect(
|
||||
|
||||
@@ -138,6 +138,7 @@ export class ActionResolver<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: context.destGeneral.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
})
|
||||
);
|
||||
effects.push(
|
||||
|
||||
@@ -177,27 +177,28 @@ export class ActionResolver<
|
||||
'이'
|
||||
)} 발동되었습니다.`;
|
||||
|
||||
for (const target of context.friendlyGenerals) {
|
||||
if (target.id === general.id) {
|
||||
continue;
|
||||
}
|
||||
const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id);
|
||||
const firstLegacyFlushGroup = -(friendlyTargets.length + context.destNationGenerals.length + 1);
|
||||
for (const [index, target] of friendlyTargets.entries()) {
|
||||
effects.push(
|
||||
createLogEffect(broadcastMessage, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: firstLegacyFlushGroup + index,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (const target of context.destNationGenerals) {
|
||||
for (const [index, target] of context.destNationGenerals.entries()) {
|
||||
effects.push(
|
||||
createLogEffect(destBroadcastMessage, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -232,7 +233,8 @@ export class ActionResolver<
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: destNation.id,
|
||||
format: LogFormat.PLAIN,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: -1,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -112,10 +112,9 @@ export class ActionResolver<
|
||||
general.atmos = selfPatch.atmos;
|
||||
}
|
||||
|
||||
for (const target of context.nationGenerals) {
|
||||
if (target.id === general.id) {
|
||||
continue;
|
||||
}
|
||||
const nationTargets = context.nationGenerals.filter((target) => target.id !== general.id);
|
||||
const firstLegacyFlushGroup = -nationTargets.length;
|
||||
for (const [index, target] of nationTargets.entries()) {
|
||||
const patch = updateTrainAtmos(target);
|
||||
if (patch) {
|
||||
effects.push(createGeneralPatchEffect(patch, target.id));
|
||||
@@ -126,6 +125,7 @@ export class ActionResolver<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: firstLegacyFlushGroup + index,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export class ActionResolver<
|
||||
strategic_cmd_limit: globalDelay,
|
||||
};
|
||||
effects.push(
|
||||
createLogEffect(broadcastMessage, {
|
||||
createLogEffect(`<Y>${generalName}</>${generalJosa} <M>${ACTION_NAME}</>을 발동`, {
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: nation.id,
|
||||
|
||||
@@ -127,21 +127,25 @@ export class ActionResolver<
|
||||
|
||||
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
||||
|
||||
for (const target of context.friendlyGenerals) {
|
||||
if (target.id === general.id) {
|
||||
continue;
|
||||
}
|
||||
const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id);
|
||||
const firstLegacyFlushGroup = -(
|
||||
friendlyTargets.length +
|
||||
context.destCityGenerals.length +
|
||||
(context.destNation ? 1 : 0)
|
||||
);
|
||||
for (const [index, target] of friendlyTargets.entries()) {
|
||||
effects.push(
|
||||
createLogEffect(broadcastMessage, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: firstLegacyFlushGroup + index,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (const target of context.destCityGenerals) {
|
||||
for (const [index, target] of context.destCityGenerals.entries()) {
|
||||
const moveCityId = pickMoveCityId(context.rng, context.destCity.id, context.destNationSupplyCities);
|
||||
effects.push(
|
||||
createLogEffect(destBroadcastMessage, {
|
||||
@@ -149,6 +153,7 @@ export class ActionResolver<
|
||||
category: LogCategory.ACTION,
|
||||
generalId: target.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index,
|
||||
})
|
||||
);
|
||||
if (moveCityId !== target.cityId) {
|
||||
@@ -163,12 +168,15 @@ export class ActionResolver<
|
||||
strategic_cmd_limit: globalDelay,
|
||||
};
|
||||
effects.push(
|
||||
createLogEffect(broadcastMessage, {
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: nation.id,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
})
|
||||
createLogEffect(
|
||||
`<Y>${generalName}</>${generalJosa} <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>를 발동`,
|
||||
{
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: nation.id,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,6 +189,7 @@ export class ActionResolver<
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: context.destNation.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -101,7 +101,7 @@ export class ActionDefinition<
|
||||
const amount = clamp(args.amount, 0, available);
|
||||
|
||||
const cost = calcCost(this.env.develCost, args.amount);
|
||||
const amountText = amount.toLocaleString();
|
||||
const amountText = String(amount);
|
||||
const destCityName = destCity.name;
|
||||
const josaRo = JosaUtil.pick(destCityName, '로');
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ const buildNonAggressionEffects = <TriggerState extends GeneralTriggerState>(
|
||||
category: LogCategory.HISTORY,
|
||||
generalId: context.proposer.id,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
),
|
||||
createLogEffect(
|
||||
@@ -166,6 +167,7 @@ const buildNonAggressionEffects = <TriggerState extends GeneralTriggerState>(
|
||||
category: LogCategory.ACTION,
|
||||
generalId: context.proposer.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
),
|
||||
];
|
||||
@@ -226,12 +228,14 @@ const buildCancelNonAggressionEffects = <TriggerState extends GeneralTriggerStat
|
||||
category: LogCategory.HISTORY,
|
||||
generalId: context.proposer.id,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa}의 불가침 파기에 성공했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: context.proposer.id,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -297,18 +301,21 @@ const buildStopWarEffects = <TriggerState extends GeneralTriggerState>(
|
||||
generalId: context.proposer.id,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전에 성공했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: context.proposer.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전`, {
|
||||
scope: LogScope.NATION,
|
||||
nationId: proposerNation.id,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from './types.js';
|
||||
|
||||
const legacyFlushBucket = (entry: LogEntryDraft): number => {
|
||||
if (entry.scope === LogScope.GENERAL) {
|
||||
switch (entry.category) {
|
||||
case LogCategory.HISTORY:
|
||||
return 0;
|
||||
case LogCategory.ACTION:
|
||||
return 1;
|
||||
case LogCategory.BATTLE_BRIEF:
|
||||
return 2;
|
||||
case LogCategory.BATTLE_DETAIL:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
if (entry.scope === LogScope.NATION && entry.category === LogCategory.HISTORY) {
|
||||
return 4;
|
||||
}
|
||||
if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.HISTORY) {
|
||||
return 5;
|
||||
}
|
||||
if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY) {
|
||||
return 6;
|
||||
}
|
||||
return 7;
|
||||
};
|
||||
|
||||
const orderLegacyFlushGroup = (entries: readonly LogEntryDraft[]): LogEntryDraft[] => {
|
||||
const buckets = Array.from({ length: 8 }, () => [] as LogEntryDraft[]);
|
||||
for (const entry of entries) {
|
||||
buckets[legacyFlushBucket(entry)]!.push(entry);
|
||||
}
|
||||
return buckets.flat();
|
||||
};
|
||||
|
||||
/** Ref ActionLogger별 flush 순서와 그 안의 대상/분류별 버퍼 순서를 보존한다. */
|
||||
export const orderLegacyActionLoggerFlush = (entries: readonly LogEntryDraft[]): LogEntryDraft[] => {
|
||||
const groups = new Map<number, LogEntryDraft[]>();
|
||||
for (const entry of entries) {
|
||||
const group = entry.legacyFlushGroup ?? 0;
|
||||
const groupedEntries = groups.get(group);
|
||||
if (groupedEntries) {
|
||||
groupedEntries.push(entry);
|
||||
} else {
|
||||
groups.set(group, [entry]);
|
||||
}
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.flatMap(([, groupedEntries]) => orderLegacyFlushGroup(groupedEntries));
|
||||
};
|
||||
|
||||
export class ActionLogger {
|
||||
private readonly generalId: number | undefined;
|
||||
private readonly nationId: number | undefined;
|
||||
@@ -13,7 +63,7 @@ export class ActionLogger {
|
||||
// 장수/국가/전역 로그를 한번에 모아두고 외부에서 저장한다.
|
||||
public flush(): LogEntryDraft[] {
|
||||
const items = this.logs.splice(0, this.logs.length);
|
||||
return items;
|
||||
return orderLegacyActionLoggerFlush(items);
|
||||
}
|
||||
|
||||
public rollback(): LogEntryDraft[] {
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface LogEntryDraft {
|
||||
month?: number;
|
||||
/** 로그를 만든 논리 게임 시각. 생략하면 flush context의 시각을 사용한다. */
|
||||
occurredAt?: Date;
|
||||
/** 하나의 명령에서 별도 Ref ActionLogger로 저장한 순서. 작은 그룹을 먼저 flush한다. */
|
||||
legacyFlushGroup?: number;
|
||||
}
|
||||
|
||||
export interface LogEntryRecord {
|
||||
|
||||
@@ -2,6 +2,33 @@ export type MessageType = 'public' | 'private' | 'national' | 'diplomacy';
|
||||
|
||||
export const MESSAGE_MAILBOX_PUBLIC = 9999;
|
||||
export const MESSAGE_MAILBOX_NATIONAL_BASE = 9000;
|
||||
export const DEFAULT_MESSAGE_SHARED_ICON_BASE_URL = 'https://sam-image.hided.net/icons';
|
||||
|
||||
export interface MessageIconSource {
|
||||
picture?: unknown;
|
||||
imageServer?: unknown;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match Ref GetImageURL for message payloads. Shared icons are absolute;
|
||||
* user icons retain the legacy d_pic marker consumed by the frontend.
|
||||
*/
|
||||
export const resolveMessageTargetIcon = (
|
||||
source: MessageIconSource | null = null,
|
||||
sharedIconBaseUrl = DEFAULT_MESSAGE_SHARED_ICON_BASE_URL
|
||||
): string => {
|
||||
const rawPicture = source?.picture ?? source?.meta?.picture;
|
||||
const picture =
|
||||
(typeof rawPicture === 'string' && rawPicture.trim() !== '') || typeof rawPicture === 'number'
|
||||
? String(rawPicture)
|
||||
: 'default.jpg';
|
||||
const imageServer = source?.imageServer ?? source?.meta?.imageServer;
|
||||
if (typeof imageServer === 'number' && imageServer !== 0) {
|
||||
return `d_pic/${picture}`;
|
||||
}
|
||||
return `${sharedIconBaseUrl.replace(/\/+$/u, '')}/${picture}`;
|
||||
};
|
||||
|
||||
export interface MessageTarget {
|
||||
generalId: number;
|
||||
@@ -80,7 +107,7 @@ const buildPayload = (draft: MessageDraft, optionOverride?: MessageOption | null
|
||||
src: draft.src,
|
||||
dest: draft.dest,
|
||||
text: draft.text,
|
||||
option: optionOverride ?? draft.option ?? {},
|
||||
option: optionOverride !== undefined ? optionOverride : (draft.option ?? {}),
|
||||
});
|
||||
|
||||
const buildRecord = (
|
||||
@@ -110,15 +137,18 @@ const buildRecord = (
|
||||
};
|
||||
};
|
||||
|
||||
const buildSenderOption = (draft: MessageDraft, receiverId: number): MessageOption => {
|
||||
const buildSenderOption = (draft: MessageDraft, receiverId: number): MessageOption | null => {
|
||||
const option = {
|
||||
...(draft.option ?? {}),
|
||||
receiverMessageID: receiverId,
|
||||
};
|
||||
|
||||
if (draft.msgType === 'diplomacy' && 'action' in option) {
|
||||
const { action: _action, ...rest } = option;
|
||||
return rest;
|
||||
// Ref Message::sendToSender temporarily replaces the entire actionable
|
||||
// diplomacy option with null. Keeping year/month/deletable or the
|
||||
// receiver row id would make the sender copy actionable in a way Ref is
|
||||
// deliberately not.
|
||||
return null;
|
||||
}
|
||||
|
||||
return option;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { General, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||
import { resolveMessageTargetIcon, type MessageDraft } from './message.js';
|
||||
|
||||
type ScoutGeneral = Pick<General, 'id' | 'name' | 'nationId' | 'officerLevel'>;
|
||||
type ScoutNation = Pick<Nation, 'id' | 'name' | 'color'>;
|
||||
|
||||
export interface ScoutMessageDraftInput {
|
||||
srcGeneral: ScoutGeneral;
|
||||
destGeneral: ScoutGeneral;
|
||||
srcNation: ScoutNation | null;
|
||||
destNation: ScoutNation | null;
|
||||
time: Date;
|
||||
sharedIconBaseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure equivalent of Ref ScoutMessage::buildScoutMessage(). Ref constructs
|
||||
* both targets without a general picture, so MessageTarget supplies the shared
|
||||
* default icon, and every caller persists the receiver copy only via send(true).
|
||||
*/
|
||||
export const buildScoutMessageDraft = (input: ScoutMessageDraftInput): MessageDraft | null => {
|
||||
const { srcGeneral, destGeneral, srcNation, destNation } = input;
|
||||
if (
|
||||
srcGeneral.id === destGeneral.id ||
|
||||
destGeneral.officerLevel === 12 ||
|
||||
srcGeneral.nationId === 0 ||
|
||||
srcGeneral.nationId === destGeneral.nationId ||
|
||||
!srcNation ||
|
||||
srcNation.id !== srcGeneral.nationId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedDestNation =
|
||||
destGeneral.nationId === 0
|
||||
? { id: 0, name: '재야', color: '#000000' }
|
||||
: destNation?.id === destGeneral.nationId
|
||||
? destNation
|
||||
: null;
|
||||
if (!resolvedDestNation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const josaRo = JosaUtil.pick(srcNation.name, '로');
|
||||
const icon = resolveMessageTargetIcon(null, input.sharedIconBaseUrl);
|
||||
return {
|
||||
msgType: 'private',
|
||||
src: {
|
||||
generalId: srcGeneral.id,
|
||||
generalName: srcGeneral.name,
|
||||
nationId: srcGeneral.nationId,
|
||||
nationName: srcNation.name,
|
||||
color: srcNation.color,
|
||||
icon,
|
||||
},
|
||||
dest: {
|
||||
generalId: destGeneral.id,
|
||||
generalName: destGeneral.name,
|
||||
nationId: destGeneral.nationId,
|
||||
nationName: resolvedDestNation.name,
|
||||
color: resolvedDestNation.color,
|
||||
icon,
|
||||
},
|
||||
text: `${srcNation.name}${josaRo} 망명 권유 서신`,
|
||||
time: input.time,
|
||||
validUntil: new Date('9999-12-31T12:59:59.000Z'),
|
||||
option: { action: 'scout' },
|
||||
sendDestOnly: true,
|
||||
};
|
||||
};
|
||||
@@ -334,27 +334,28 @@ const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralT
|
||||
const itemRawName = itemModule.rawName;
|
||||
const josaYi = JosaUtil.pick(generalName, '이');
|
||||
const josaUl = JosaUtil.pick(itemRawName, '을');
|
||||
const addLog = context.addPostProgressionLog ?? context.addLog;
|
||||
|
||||
equipNewItem(general, itemModule.slot, itemModule.key, {
|
||||
...(itemModule.initialCharges === undefined ? {} : { charges: itemModule.initialCharges }),
|
||||
});
|
||||
|
||||
context.addLog(`<C>${itemName}</>${josaUl} 습득했습니다!`, {
|
||||
addLog(`<C>${itemName}</>${josaUl} 습득했습니다!`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
context.addLog(`<C>${itemName}</>${josaUl} 습득`, {
|
||||
addLog(`<C>${itemName}</>${josaUl} 습득`, {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
context.addLog(`<Y>${generalName}</>${josaYi} <C>${itemName}</>${josaUl} 습득했습니다!`, {
|
||||
addLog(`<Y>${generalName}</>${josaYi} <C>${itemName}</>${josaUl} 습득했습니다!`, {
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
context.addLog(
|
||||
addLog(
|
||||
`<C><b>【${acquireType}】</b></><D><b>${nationName}</b></>의 <Y>${generalName}</>${josaYi} <C>${itemName}</>${josaUl} 습득했습니다!`,
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
@@ -376,6 +377,6 @@ export const tryApplyUniqueLottery = <TriggerState extends GeneralTriggerState =
|
||||
if (!itemModule) {
|
||||
return false;
|
||||
}
|
||||
applyUniqueItemGain(context, itemModule, request.acquireType, request.nationName);
|
||||
applyUniqueItemGain(context, itemModule, request.acquireType, request.nationName ?? context.legacyStaticNationName);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic
|
||||
import { createGeneralActionEvent } from '@sammo-ts/logic/actionModules/events.js';
|
||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||
import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
|
||||
import { LogFormat, type LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
|
||||
import { buildScoutMessageDraft } from '@sammo-ts/logic/messages/scoutMessage.js';
|
||||
import { buildCrewTypeIndex, getTechCost, getTechLevel } from '@sammo-ts/logic/world/unitSet.js';
|
||||
import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js';
|
||||
import type { WarUnitReport } from './types.js';
|
||||
@@ -26,17 +27,45 @@ import {
|
||||
simpleSerialize,
|
||||
sortConflictEntries,
|
||||
} from './utils.js';
|
||||
import { LegacyWarLogFlushSequence } from './legacyFlushSequence.js';
|
||||
|
||||
const META_DEAD = 'dead';
|
||||
const MAX_DEDICATION_LEVEL = 30;
|
||||
|
||||
const updateLegacyProgressionLevels = (general: General): void => {
|
||||
const updateLegacyProgressionLevels = (general: General, logger: ActionLogger): void => {
|
||||
const previousExpLevel = getMetaNumber(general.meta, 'explevel', 0);
|
||||
const previousDedLevel = getMetaNumber(general.meta, 'dedlevel', 0);
|
||||
const expLevel =
|
||||
general.experience < 1_000
|
||||
? Math.trunc(general.experience / 100)
|
||||
: Math.trunc(Math.sqrt(general.experience / 10));
|
||||
general.meta.explevel = clamp(expLevel, 0, LEGACY_DEFAULT_MAX_LEVEL);
|
||||
general.meta.dedlevel = clamp(Math.ceil(Math.sqrt(general.dedication) / 10), 0, MAX_DEDICATION_LEVEL);
|
||||
const nextExpLevel = clamp(expLevel, 0, LEGACY_DEFAULT_MAX_LEVEL);
|
||||
const nextDedLevel = clamp(Math.ceil(Math.sqrt(general.dedication) / 10), 0, MAX_DEDICATION_LEVEL);
|
||||
general.meta.explevel = nextExpLevel;
|
||||
general.meta.dedlevel = nextDedLevel;
|
||||
|
||||
if (nextExpLevel !== previousExpLevel) {
|
||||
const josaRo = JosaUtil.pick(String(nextExpLevel), '로');
|
||||
logger.pushGeneralActionLog(
|
||||
nextExpLevel > previousExpLevel
|
||||
? `<C>Lv ${nextExpLevel}</>${josaRo} <C>레벨업</>!`
|
||||
: `<C>Lv ${nextExpLevel}</>${josaRo} <R>레벨다운</>!`,
|
||||
LogFormat.PLAIN
|
||||
);
|
||||
}
|
||||
|
||||
if (nextDedLevel !== previousDedLevel) {
|
||||
const dedicationLevelText = nextDedLevel === 0 ? '무품관' : `${MAX_DEDICATION_LEVEL - nextDedLevel + 1}품관`;
|
||||
const billText = (nextDedLevel * 200 + 400).toLocaleString('en-US');
|
||||
const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로');
|
||||
const josaRoBill = JosaUtil.pick(billText, '로');
|
||||
logger.pushGeneralActionLog(
|
||||
nextDedLevel > previousDedLevel
|
||||
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
|
||||
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
|
||||
LogFormat.PLAIN
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport) => boolean): WarUnitReport | null => {
|
||||
@@ -207,16 +236,19 @@ const findNextCapital = (
|
||||
})[0]!.city;
|
||||
};
|
||||
|
||||
const pushLoggers = (loggers: ActionLogger[], logs: LogEntryDraft[]): void => {
|
||||
for (const logger of loggers) {
|
||||
logs.push(...logger.flush());
|
||||
}
|
||||
const pushLogger = (
|
||||
logger: ActionLogger,
|
||||
logs: LogEntryDraft[],
|
||||
legacyFlushSequence: LegacyWarLogFlushSequence
|
||||
): void => {
|
||||
logs.push(...legacyFlushSequence.flush(logger));
|
||||
};
|
||||
|
||||
// 도시 점령 이후의 국가 붕괴/수도 이동/도시 리셋 처리.
|
||||
const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
input: WarAftermathInput<TriggerState>,
|
||||
rng: RandUtil
|
||||
rng: RandUtil,
|
||||
legacyFlushSequence: LegacyWarLogFlushSequence
|
||||
): ConquerCityOutcome<TriggerState> => {
|
||||
const { attackerNation, defenderNation, defenderCity, cities, generals, config } = input;
|
||||
const attacker = input.battle.attacker;
|
||||
@@ -257,12 +289,11 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
`<Y>${attackerGeneralName}</>${josaYiGen} ${defenderNationDecoration} <G><b>${cityName}</b></> ${josaUl} <S>점령</>`
|
||||
);
|
||||
|
||||
if (defenderNationId) {
|
||||
const defenderNationLogger = new ActionLogger({ nationId: defenderNationId });
|
||||
const defenderNationLogger = defenderNationId ? new ActionLogger({ nationId: defenderNationId }) : null;
|
||||
if (defenderNationLogger) {
|
||||
defenderNationLogger.pushNationHistoryLog(
|
||||
`<D><b>${attackerNationName}</b></>의 <Y>${attackerGeneralName}</>에 의해 <G><b>${cityName}</b></>${josaYiCity} <O>함락</>`
|
||||
);
|
||||
pushLoggers([defenderNationLogger], logs);
|
||||
}
|
||||
|
||||
const defenderCityCount = defenderNationId ? cities.filter((city) => city.nationId === defenderNationId).length : 0;
|
||||
@@ -270,32 +301,44 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
|
||||
let collapseRewardGold = 0;
|
||||
let collapseRewardRice = 0;
|
||||
const messages: ConquerCityOutcome<TriggerState>['messages'] = [];
|
||||
const ruinedNpcJoinPlans: ConquerCityOutcome<TriggerState>['ruinedNpcJoinPlans'] = [];
|
||||
|
||||
// 국가 붕괴 시 자원 손실과 포상 정산.
|
||||
if (nationCollapsed && defenderNation) {
|
||||
const defenderGenerals = generals
|
||||
.filter((general) => general.nationId === defenderNationId)
|
||||
.sort((lhs, rhs) => {
|
||||
// deleteNation() reads the non-lord rows in primary-key order,
|
||||
// then appends the lord object to the returned PHP array.
|
||||
const lhsIsLord = lhs.id === defenderNation.chiefGeneralId;
|
||||
const rhsIsLord = rhs.id === defenderNation.chiefGeneralId;
|
||||
if (lhsIsLord !== rhsIsLord) {
|
||||
return lhsIsLord ? 1 : -1;
|
||||
}
|
||||
return lhs.id - rhs.id;
|
||||
});
|
||||
const defenderNationGenerals = generals.filter((general) => general.nationId === defenderNationId);
|
||||
const collapseLord =
|
||||
defenderNationGenerals.find((general) => general.officerLevel === 12) ??
|
||||
(defenderNation.chiefGeneralId === null
|
||||
? undefined
|
||||
: defenderNationGenerals.find((general) => general.id === defenderNation.chiefGeneralId));
|
||||
if (!collapseLord) {
|
||||
throw new Error(`Collapsed nation ${defenderNationId} has no lord general.`);
|
||||
}
|
||||
const collapseLordId = collapseLord.id;
|
||||
const defenderGenerals = defenderNationGenerals.sort((lhs, rhs) => {
|
||||
// deleteNation() reads the non-lord rows in primary-key order,
|
||||
// then appends the lord object to the returned PHP array.
|
||||
const lhsIsLord = lhs.id === collapseLordId;
|
||||
const rhsIsLord = rhs.id === collapseLordId;
|
||||
if (lhsIsLord !== rhsIsLord) {
|
||||
return lhsIsLord ? 1 : -1;
|
||||
}
|
||||
return lhs.id - rhs.id;
|
||||
});
|
||||
let totalGoldLoss = 0;
|
||||
let totalRiceLoss = 0;
|
||||
|
||||
const defenderNationJosaUl = JosaUtil.pick(defenderNationName, '을');
|
||||
const defenderNationJosaUn = JosaUtil.pick(defenderNationName, '은');
|
||||
const defenderNationJosaYi = JosaUtil.pick(defenderNationName, '이');
|
||||
// Ref는 도시 수비 장수들의 onArbitraryAction/applyDB 뒤 이 순서로
|
||||
// defender nation logger와 attacker logger를 각각 flush한다.
|
||||
if (defenderNationLogger) {
|
||||
pushLogger(defenderNationLogger, logs, legacyFlushSequence);
|
||||
}
|
||||
attackerLogger.pushNationHistoryLog(`<D><b>${defenderNationName}</b></>${defenderNationJosaUl} 정복`);
|
||||
attackerLogger.pushGlobalHistoryLog(
|
||||
`<R><b>【멸망】</b></><D><b>${defenderNationName}</b></>${defenderNationJosaUn} <R>멸망</>했습니다.`
|
||||
);
|
||||
pushLogger(attackerLogger, logs, legacyFlushSequence);
|
||||
|
||||
for (const general of defenderGenerals) {
|
||||
// Legacy Util::toInt truncates these losses rather than rounding.
|
||||
@@ -305,7 +348,6 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
general.rice = clampMin(general.rice - loseRice, 0);
|
||||
general.experience = round(general.experience * 0.9);
|
||||
general.dedication = round(general.dedication * 0.5);
|
||||
updateLegacyProgressionLevels(general);
|
||||
|
||||
totalGoldLoss += loseGold;
|
||||
totalRiceLoss += loseRice;
|
||||
@@ -323,13 +365,46 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
`도주하며 금<C>${loseGold}</> 쌀<C>${loseRice}</>을 분실했습니다.`,
|
||||
LogFormat.PLAIN
|
||||
);
|
||||
pushLoggers([generalLogger], logs);
|
||||
// Ref calls addExperience()/addDedication() after the loss action
|
||||
// and before this former general's applyDB(), so any level/rank
|
||||
// notices belong to the same logger/flush epoch.
|
||||
updateLegacyProgressionLevels(general, generalLogger);
|
||||
if (general.id === collapseLordId) {
|
||||
// deleteNation()은 멸망 전역사를 군주의 logger에 먼저 넣고,
|
||||
// 군주가 배열의 마지막에서 applyDB될 때 같은 epoch으로 저장한다.
|
||||
generalLogger.pushGlobalHistoryLog(
|
||||
`<R><b>【멸망】</b></><D><b>${defenderNationName}</b></>${defenderNationJosaUn} <R>멸망</>했습니다.`
|
||||
);
|
||||
}
|
||||
pushLogger(generalLogger, logs, legacyFlushSequence);
|
||||
affectedGenerals.add(general);
|
||||
|
||||
if (config.joinMode !== 'onlyRandom') {
|
||||
// Ref attempts to build/send a scout message after every loss.
|
||||
// Message availability does not affect this draw.
|
||||
rng.nextBool(0.5);
|
||||
// deleteNation() has already persisted every former member as
|
||||
// an unaffiliated officer before this draw and message build.
|
||||
// The snapshot in the receiver-only ScoutMessage must
|
||||
// therefore be neutral even though Core removes the nation
|
||||
// after applying the command outcome.
|
||||
if (rng.nextBool(0.5)) {
|
||||
const message = buildScoutMessageDraft({
|
||||
srcGeneral: attacker,
|
||||
destGeneral: {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
nationId: 0,
|
||||
officerLevel: 0,
|
||||
},
|
||||
srcNation: attackerNation,
|
||||
destNation: null,
|
||||
time: input.messageTime,
|
||||
...(input.messageSharedIconBaseUrl
|
||||
? { sharedIconBaseUrl: input.messageSharedIconBaseUrl }
|
||||
: {}),
|
||||
});
|
||||
if (message) {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
const eligibleNpc = general.npcState >= 2 && general.npcState <= 8 && general.npcState !== 5;
|
||||
if (eligibleNpc && rng.nextBool(config.joinRuinedNpcProbability ?? 0.1)) {
|
||||
@@ -365,7 +440,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
nationId: attackerNation.id,
|
||||
});
|
||||
chiefLogger.pushGeneralActionLog(resourceLog, LogFormat.PLAIN);
|
||||
pushLoggers([chiefLogger], logs);
|
||||
pushLogger(chiefLogger, logs, legacyFlushSequence);
|
||||
}
|
||||
|
||||
defenderNation.meta.collapsed = true;
|
||||
@@ -402,9 +477,11 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
});
|
||||
defenderLogger.pushGeneralActionLog(moveLog, LogFormat.PLAIN);
|
||||
if (general.officerLevel >= 5) {
|
||||
defenderLogger.pushGeneralActionLog(gatherLog, LogFormat.PLAIN);
|
||||
// Ref omits the explicit PLAIN argument for the chief
|
||||
// gathering notice, so ActionLogger's MONTH format applies.
|
||||
defenderLogger.pushGeneralActionLog(gatherLog);
|
||||
}
|
||||
pushLoggers([defenderLogger], logs);
|
||||
pushLogger(defenderLogger, logs, legacyFlushSequence);
|
||||
|
||||
general.atmos = round(general.atmos * 0.8);
|
||||
if (general.officerLevel >= 5) {
|
||||
@@ -422,12 +499,13 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
? attackerNation
|
||||
: (input.nations.find((nation) => nation.id === conquerNationId) ?? attackerNation);
|
||||
|
||||
let conquerNationLogger: ActionLogger | null = null;
|
||||
if (conquerNationId === attackerNation.id) {
|
||||
attacker.cityId = defenderCity.id;
|
||||
affectedGenerals.add(attacker);
|
||||
} else {
|
||||
const conquerNationName = conquerNation.name;
|
||||
const conquerNationLogger = new ActionLogger({ nationId: conquerNationId });
|
||||
conquerNationLogger = new ActionLogger({ nationId: conquerNationId });
|
||||
const josaUl = JosaUtil.pick(cityName, '을');
|
||||
const josaYi = JosaUtil.pick(conquerNationName, '이');
|
||||
|
||||
@@ -440,7 +518,6 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
attackerLogger.pushNationHistoryLog(
|
||||
`<G><b>${cityName}</b></>${josaUl} <D><b>${conquerNationName}</b></>에 <Y>양도</>`
|
||||
);
|
||||
pushLoggers([conquerNationLogger], logs);
|
||||
}
|
||||
|
||||
// 점령 후 도시 상태를 방어 기본 상태로 되돌린다.
|
||||
@@ -465,7 +542,16 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
affectedCities.add(defenderCity);
|
||||
affectedNations.add(conquerNation);
|
||||
|
||||
pushLoggers([attackerLogger], logs);
|
||||
// 비멸망 경로의 defender/conquer logger는 ConquerCity() 함수가 끝날 때
|
||||
// 생성 순서대로 destruct/flush된다. attacker logger는 외부 General이
|
||||
// 소유하므로 여기서 그룹을 소비하지 않고 che_출병 line 259 epoch으로 넘긴다.
|
||||
if (!nationCollapsed && defenderNationLogger) {
|
||||
pushLogger(defenderNationLogger, logs, legacyFlushSequence);
|
||||
}
|
||||
if (conquerNationLogger) {
|
||||
pushLogger(conquerNationLogger, logs, legacyFlushSequence);
|
||||
}
|
||||
logs.push(...attackerLogger.flush());
|
||||
|
||||
return {
|
||||
conquerNationId,
|
||||
@@ -476,6 +562,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
nations: Array.from(affectedNations),
|
||||
cities: Array.from(affectedCities),
|
||||
generals: Array.from(affectedGenerals),
|
||||
messages,
|
||||
ruinedNpcJoinPlans,
|
||||
};
|
||||
};
|
||||
@@ -484,6 +571,7 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
|
||||
input: WarAftermathInput<TriggerState>
|
||||
): WarAftermathOutcome<TriggerState> => {
|
||||
const logs: LogEntryDraft[] = [];
|
||||
const legacyFlushSequence = input.legacyFlushSequence ?? new LegacyWarLogFlushSequence();
|
||||
const diplomacyDeltas: WarDiplomacyDelta[] = [];
|
||||
const affectedNations = new Set<Nation>();
|
||||
const affectedCities = new Set<City>();
|
||||
@@ -591,13 +679,19 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
|
||||
// nation-collapse RNG is consumed. Keep that order and the same RNG
|
||||
// object instead of opening a second random stream.
|
||||
const pipeline = new GeneralActionPipeline(input.generalActionModules ?? []);
|
||||
const cityDefenders = input.generals.filter(
|
||||
(general) =>
|
||||
general.nationId !== 0 &&
|
||||
general.nationId === input.defenderCity.nationId &&
|
||||
general.cityId === input.defenderCity.id
|
||||
);
|
||||
const cityDefenders = input.generals
|
||||
.filter(
|
||||
(general) =>
|
||||
general.nationId !== 0 &&
|
||||
general.nationId === input.defenderCity.nationId &&
|
||||
general.cityId === input.defenderCity.id
|
||||
)
|
||||
.sort((left, right) => left.id - right.id);
|
||||
for (const general of cityDefenders) {
|
||||
const generalLogger = new ActionLogger({
|
||||
generalId: general.id,
|
||||
nationId: general.nationId,
|
||||
});
|
||||
pipeline.dispatch(
|
||||
{
|
||||
general,
|
||||
@@ -610,25 +704,20 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
|
||||
listNations: () => input.nations,
|
||||
},
|
||||
log: {
|
||||
push: (text) =>
|
||||
logs.push({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
generalId: general.id,
|
||||
nationId: general.nationId,
|
||||
text,
|
||||
}),
|
||||
push: (text) => generalLogger.pushGeneralActionLog(text, LogFormat.MONTH),
|
||||
},
|
||||
},
|
||||
createGeneralActionEvent('city.conquered', {
|
||||
attacker: input.battle.attacker,
|
||||
})
|
||||
);
|
||||
// Ref는 ID 오름차순 city defender마다 onArbitraryAction 직후
|
||||
// General::applyDB()를 호출한다. 로그가 없어도 epoch은 하나 소비한다.
|
||||
pushLogger(generalLogger, logs, legacyFlushSequence);
|
||||
affectedGenerals.add(general);
|
||||
}
|
||||
|
||||
conquest = resolveConquerCity(input, rng);
|
||||
conquest = resolveConquerCity(input, rng, legacyFlushSequence);
|
||||
logs.push(...conquest.logs);
|
||||
|
||||
conquest.nations.forEach((nation) => affectedNations.add(nation));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
|
||||
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { LogFormat, type LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
|
||||
import { buildCrewTypeIndex as buildCrewTypeDefinitionIndex } from '@sammo-ts/logic/world/unitSet.js';
|
||||
import { WarActionPipeline, type WarActionModule } from './actions.js';
|
||||
import { createCrewTypeWarTriggerRegistry } from './crewTypeTriggers.js';
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
WarGeneralInput,
|
||||
WarUnitReport,
|
||||
} from './types.js';
|
||||
import { LegacyWarLogFlushSequence } from './legacyFlushSequence.js';
|
||||
import { getMetaNumber } from './utils.js';
|
||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.js';
|
||||
|
||||
@@ -240,20 +241,13 @@ const buildTraceUnitSnapshot = (unit: WarUnit, defenderCity: City): WarBattleTra
|
||||
};
|
||||
};
|
||||
|
||||
const flushLoggers = (loggers: ActionLogger[]): Array<ReturnType<ActionLogger['flush']>[number]> => {
|
||||
const logs: Array<ReturnType<ActionLogger['flush']>[number]> = [];
|
||||
for (const logger of loggers) {
|
||||
logs.push(...logger.flush());
|
||||
}
|
||||
return logs;
|
||||
};
|
||||
|
||||
export const resolveWarBattle = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||
input: WarBattleInput<TriggerState>
|
||||
): WarBattleOutcome<TriggerState> => {
|
||||
// process_war.php 전투 루프를 순수 로직으로 이식한다.
|
||||
const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? ''));
|
||||
const loggerFactory = input.loggerFactory ?? defaultLoggerFactory;
|
||||
const legacyFlushSequence = input.legacyFlushSequence ?? new LegacyWarLogFlushSequence();
|
||||
const triggerRegistry: WarTriggerRegistry = {
|
||||
...createCrewTypeWarTriggerRegistry(),
|
||||
...(input.triggerRegistry ?? {}),
|
||||
@@ -340,11 +334,21 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
);
|
||||
|
||||
const iter = defenderUnits.values();
|
||||
const logs: LogEntryDraft[] = [];
|
||||
|
||||
const getNextDefender = (
|
||||
_prevDefender: WarUnit<TriggerState> | null,
|
||||
prevDefender: WarUnit<TriggerState> | null,
|
||||
reqNext: boolean
|
||||
): WarUnit<TriggerState> | null => {
|
||||
if (prevDefender instanceof WarUnitGeneral) {
|
||||
// Ref getNextDefender()는 다음 상대를 고르기 전에 직전 수비 장수의
|
||||
// General::applyDB()를 호출해 그 logger만 먼저 flush한다.
|
||||
logs.push(...legacyFlushSequence.flush(prevDefender.getLogger()));
|
||||
} else if (prevDefender instanceof WarUnitCity) {
|
||||
// WarUnitCity::applyDB()는 도시 logger를 rollback하므로 저장하지 않는다.
|
||||
legacyFlushSequence.discard(prevDefender.getLogger());
|
||||
}
|
||||
|
||||
if (!reqNext) {
|
||||
return null;
|
||||
}
|
||||
@@ -698,9 +702,11 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
);
|
||||
}
|
||||
}
|
||||
getNextDefender(defender, false);
|
||||
emitTrace('battle_end', defender, { conquered: conquerCity });
|
||||
|
||||
const logs = flushLoggers([attackerLogger, ...defenderGenerals.map((unit) => unit.getLogger())]);
|
||||
// processWar()는 마지막 수비자 applyDB 뒤 공격자 applyDB를 별도 epoch으로 수행한다.
|
||||
logs.push(...legacyFlushSequence.flush(attackerLogger));
|
||||
|
||||
const reports: WarUnitReport[] = [
|
||||
resolveUnitReport(attackerUnit),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { orderLegacyActionLoggerFlush } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||
import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||
import type { LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
|
||||
|
||||
/**
|
||||
* Ref의 한 `ActionLogger::flush()`/`General::applyDB()` 경계를 로그 정렬 그룹 하나로 보존한다.
|
||||
* 시작값을 생략하면 그룹 표식 없이 logger별 bucket 순서만 유지한다.
|
||||
*/
|
||||
export class LegacyWarLogFlushSequence {
|
||||
public constructor(private nextLegacyFlushGroup?: number) {}
|
||||
|
||||
public claimGroup(): number | undefined {
|
||||
if (this.nextLegacyFlushGroup === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const group = this.nextLegacyFlushGroup;
|
||||
this.nextLegacyFlushGroup += 1;
|
||||
return group;
|
||||
}
|
||||
|
||||
public flush(logger: ActionLogger): LogEntryDraft[] {
|
||||
return this.flushEntries(logger.flush());
|
||||
}
|
||||
|
||||
public flushEntries(entries: readonly LogEntryDraft[]): LogEntryDraft[] {
|
||||
const ordered = orderLegacyActionLoggerFlush(entries);
|
||||
const group = this.claimGroup();
|
||||
if (group === undefined) {
|
||||
return ordered;
|
||||
}
|
||||
return ordered.map((entry) => ({ ...entry, legacyFlushGroup: group }));
|
||||
}
|
||||
|
||||
/** WarUnitCity::applyDB()처럼 logger 내용을 버리지만 flush epoch은 소비하는 경계. */
|
||||
public discard(logger: ActionLogger): void {
|
||||
logger.rollback();
|
||||
this.claimGroup();
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic
|
||||
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||
import type { LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
|
||||
import type { MessageDraft } from '@sammo-ts/logic/messages/message.js';
|
||||
import type { TracePort } from '@sammo-ts/logic/ports/trace.js';
|
||||
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import type { WarActionModule } from './actions.js';
|
||||
import type { WarTriggerRegistry } from './triggers.js';
|
||||
import type { LegacyWarLogFlushSequence } from './legacyFlushSequence.js';
|
||||
|
||||
export interface WarArmTypes {
|
||||
footman?: number;
|
||||
@@ -58,6 +60,8 @@ export interface WarBattleInput<TriggerState extends GeneralTriggerState = Gener
|
||||
defenderNation: Nation | null;
|
||||
triggerRegistry?: WarTriggerRegistry;
|
||||
loggerFactory?: (options: { generalId?: number; nationId?: number }) => ActionLogger;
|
||||
/** Ref processWar의 logger/applyDB epoch을 명령 전체에서 이어 주는 순서 cursor. */
|
||||
legacyFlushSequence?: LegacyWarLogFlushSequence;
|
||||
trace?: (event: WarBattleTraceEvent) => void;
|
||||
}
|
||||
|
||||
@@ -177,6 +181,7 @@ export interface ConquerCityOutcome<TriggerState extends GeneralTriggerState = G
|
||||
nations: Nation[];
|
||||
cities: City[];
|
||||
generals: General<TriggerState>[];
|
||||
messages: MessageDraft[];
|
||||
ruinedNpcJoinPlans: RuinedNpcJoinPlan[];
|
||||
}
|
||||
|
||||
@@ -192,9 +197,14 @@ export interface WarAftermathInput<TriggerState extends GeneralTriggerState = Ge
|
||||
unitSet: UnitSetDefinition;
|
||||
config: WarAftermathConfig;
|
||||
time: WarTimeContext;
|
||||
/** Ref Message::gameNow() at the command transaction's logical tick. */
|
||||
messageTime: Date;
|
||||
messageSharedIconBaseUrl?: string;
|
||||
hiddenSeed?: string;
|
||||
rng?: RandUtil;
|
||||
generalActionModules?: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>;
|
||||
/** 전투에서 시작한 Ref logger/applyDB epoch 순서를 점령 후처리까지 이어 간다. */
|
||||
legacyFlushSequence?: LegacyWarLogFlushSequence;
|
||||
calcNationTechGain?: (context: WarAftermathTechContext) => number;
|
||||
trace?: TracePort;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user