fix: 커맨드 차등 생명주기와 로그 그래프를 보강

장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다.

실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
2026-08-23 21:48:29 +00:00
parent 85591c68ad
commit c63a49bd07
128 changed files with 8615 additions and 848 deletions
@@ -7,6 +7,7 @@ import {
finalizeLogEntry,
LogFormat,
MESSAGE_MAILBOX_NATIONAL_BASE,
orderLegacyActionLoggerFlush,
resolveInstantDiplomacyResponse,
sendMessage,
type GeneralActionEffect,
@@ -121,7 +122,7 @@ const persistEffects = async (
logs.push(effect.entry);
}
}
await persistLogs(db, logs, year, month, at);
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at);
};
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
+23 -2
View File
@@ -13,8 +13,9 @@ import {
} from '../../turns/commandTable.js';
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
import {
assertReservedTurnActionAvailable,
buildEquipmentTradeItemOptions,
parseReservedTurnArgs,
parseRegisteredTurnArgs,
TURN_COMMAND_NATION_COLORS,
type TurnCommandInputOptions,
} from '../../turns/commandInput.js';
@@ -65,7 +66,7 @@ const buildBulkEntrySchema = (turnList: z.ZodType<number[]>) =>
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
try {
return await parseReservedTurnArgs(scope, action, args);
return await parseRegisteredTurnArgs(scope, action, args);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -75,6 +76,22 @@ const parseCommandArgs = async (scope: 'general' | 'nation', action: string, arg
}
};
const assertScenarioCommandAvailable = async (
scope: 'general' | 'nation',
action: string,
worldState: WorldStateRow
): Promise<void> => {
try {
await assertReservedTurnActionAvailable(scope, action, asRecord(worldState.config).const);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: error instanceof Error ? error.message : 'Unavailable turn command.',
cause: error,
});
}
};
const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> => {
try {
return await mutation();
@@ -413,6 +430,7 @@ export const turnsRouter = router({
const general = await getOwnedGeneral(ctx, input.generalId);
const args = await parseCommandArgs('general', input.action, input.args);
const worldState = await getReservationWorldState(ctx);
await assertScenarioCommandAvailable('general', input.action, worldState);
await assertReservedTurnPermission(worldState, general, 'general', input.action, args);
const snapshot = await mutateReservedTurns(() =>
@@ -476,6 +494,7 @@ export const turnsRouter = router({
);
const worldState = await getReservationWorldState(ctx);
for (const update of updates) {
await assertScenarioCommandAvailable('general', update.action, worldState);
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
}
const snapshot = await mutateReservedTurns(() =>
@@ -515,6 +534,7 @@ export const turnsRouter = router({
}
const args = await parseCommandArgs('nation', input.action, input.args);
const worldState = await getReservationWorldState(ctx);
await assertScenarioCommandAvailable('nation', input.action, worldState);
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
const snapshot = await mutateReservedTurns(() =>
@@ -628,6 +648,7 @@ export const turnsRouter = router({
);
const worldState = await getReservationWorldState(ctx);
for (const update of updates) {
await assertScenarioCommandAvailable('nation', update.action, worldState);
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
}
const snapshot = await mutateReservedTurns(() =>
+43 -7
View File
@@ -1,4 +1,6 @@
import {
isGeneralTurnCommandKey,
isNationTurnCommandKey,
loadGeneralTurnCommandSpecs,
loadNationTurnCommandSpecs,
type GeneralTurnCommandSpec,
@@ -9,7 +11,7 @@ import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { z } from 'zod';
import { loadTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
export type TurnCommandOptionValue = string | number;
@@ -345,22 +347,35 @@ export const buildTurnCommandInputFields = (
return Object.entries(properties).map(([key, schema]) => buildField(key, schema, required.has(key)));
};
export const loadTurnCommandSpecs = async () => {
const profile = await loadTurnCommandProfile();
export const loadTurnCommandSpecs = async (scenarioConst?: unknown) => {
const resolution = await loadScenarioTurnCommandProfile({ scenarioConst });
const profile = resolution.profile;
const [general, nation] = await Promise.all([
loadGeneralTurnCommandSpecs(profile.general),
loadNationTurnCommandSpecs(profile.nation),
]);
return { general, nation };
return {
general,
nation,
generalGroups: resolution.generalGroups,
nationGroups: resolution.nationGroups,
};
};
export const parseReservedTurnArgs = async (
export const parseRegisteredTurnArgs = async (
scope: 'general' | 'nation',
action: string,
rawArgs: unknown
): Promise<Record<string, unknown>> => {
const specs = await loadTurnCommandSpecs();
const spec = specs[scope].find((entry) => entry.key === action);
const specs =
scope === 'general'
? isGeneralTurnCommandKey(action)
? await loadGeneralTurnCommandSpecs([action])
: []
: isNationTurnCommandKey(action)
? await loadNationTurnCommandSpecs([action])
: [];
const spec = specs[0];
if (!spec) {
throw new Error(`Unknown ${scope} turn command: ${action}`);
}
@@ -369,3 +384,24 @@ export const parseReservedTurnArgs = async (
}
return spec.argsSchema.parse(rawArgs);
};
export const assertReservedTurnActionAvailable = async (
scope: 'general' | 'nation',
action: string,
scenarioConst?: unknown
): Promise<void> => {
const specs = await loadTurnCommandSpecs(scenarioConst);
if (!specs[scope].some((entry) => entry.key === action)) {
throw new Error(`Unknown ${scope} turn command: ${action}`);
}
};
export const parseReservedTurnArgs = async (
scope: 'general' | 'nation',
action: string,
rawArgs: unknown,
scenarioConst?: unknown
): Promise<Record<string, unknown>> => {
await assertReservedTurnActionAvailable(scope, action, scenarioConst);
return parseRegisteredTurnArgs(scope, action, rawArgs);
};
+28 -46
View File
@@ -142,15 +142,6 @@ const REF_GENERAL_COMMAND_GROUPS = [
commands: ReadonlyArray<GeneralTurnCommandKey>;
}>;
const REF_GENERAL_CATEGORY_ORDER = new Map<string, number>(
REF_GENERAL_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const)
);
const REF_GENERAL_COMMAND_POSITION = new Map<string, { category: string; index: number }>(
REF_GENERAL_COMMAND_GROUPS.flatMap(({ category, commands }) =>
commands.map((command, index) => [command, { category, index }] as const)
)
);
const REF_NATION_COMMAND_GROUPS = [
{
category: '휴식',
@@ -190,15 +181,6 @@ const REF_NATION_COMMAND_GROUPS = [
commands: ReadonlyArray<NationTurnCommandKey>;
}>;
const REF_NATION_CATEGORY_ORDER = new Map<string, number>(
REF_NATION_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const)
);
const REF_NATION_COMMAND_POSITION = new Map<string, { category: string; index: number }>(
REF_NATION_COMMAND_GROUPS.flatMap(({ category, commands }) =>
commands.map((command, index) => [command, { category, index }] as const)
)
);
const INPUT_REQUIREMENT_KINDS = new Set<RequirementKey['kind']>([
'destGeneral',
'destCity',
@@ -746,34 +728,22 @@ const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: Stat
}));
};
const projectRefGeneralCommandGroups = (entries: CommandEntry[]): CommandEntry[] =>
entries
.map((entry, profileIndex) => {
const refPosition = REF_GENERAL_COMMAND_POSITION.get(entry.definition.key as GeneralTurnCommandKey);
return {
entry: refPosition ? { ...entry, category: refPosition.category } : entry,
categoryIndex:
REF_GENERAL_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER,
commandIndex: refPosition?.index ?? profileIndex,
profileIndex,
};
})
.sort(
(left, right) =>
left.categoryIndex - right.categoryIndex ||
left.commandIndex - right.commandIndex ||
left.profileIndex - right.profileIndex
)
.map(({ entry }) => entry);
type CommandGroupLayout = ReadonlyArray<{ category: string; commands: ReadonlyArray<string> }>;
const projectRefNationCommandGroups = (entries: CommandEntry[]): CommandEntry[] =>
entries
const projectCommandGroups = (entries: CommandEntry[], layout: CommandGroupLayout): CommandEntry[] => {
const categoryOrder = new Map<string, number>(layout.map(({ category }, index) => [category, index] as const));
const commandPosition = new Map<string, { category: string; index: number }>(
layout.flatMap(({ category, commands }) =>
commands.map((command, index) => [command, { category, index }] as const)
)
);
return entries
.map((entry, profileIndex) => {
const refPosition = REF_NATION_COMMAND_POSITION.get(entry.definition.key as NationTurnCommandKey);
const refPosition = commandPosition.get(entry.definition.key);
return {
entry: refPosition ? { ...entry, category: refPosition.category } : entry,
categoryIndex:
REF_NATION_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER,
categoryIndex: categoryOrder.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER,
commandIndex: refPosition?.index ?? profileIndex,
profileIndex,
};
@@ -785,6 +755,7 @@ const projectRefNationCommandGroups = (entries: CommandEntry[]): CommandEntry[]
left.profileIndex - right.profileIndex
)
.map(({ entry }) => entry);
};
export const buildTurnCommandTable = async (options: {
worldState: WorldStateRow;
@@ -814,7 +785,13 @@ export const buildTurnCommandTable = async (options: {
};
const env = buildCommandEnv(options.worldState);
const { general: generalSpecs, nation: nationSpecs } = await loadTurnCommandSpecs();
const scenarioConst = asRecord(options.worldState.config).const;
const {
general: generalSpecs,
nation: nationSpecs,
generalGroups,
nationGroups,
} = await loadTurnCommandSpecs(scenarioConst);
const generalEntries = buildEntries(env, generalSpecs, {
foundingAvailable:
options.realNationCount === undefined
@@ -824,8 +801,12 @@ export const buildTurnCommandTable = async (options: {
const nationEntries = buildEntries(env, nationSpecs);
return {
general: buildGroups(projectRefGeneralCommandGroups(generalEntries), ctx, view),
nation: buildGroups(projectRefNationCommandGroups(nationEntries), ctx, view),
general: buildGroups(
projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS),
ctx,
view
),
nation: buildGroups(projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), ctx, view),
inputOptions: options.inputOptions ?? {
cities: [],
nations: [],
@@ -850,7 +831,8 @@ export const evaluateReservedTurnPermission = async (options: {
action: string;
args: Record<string, unknown>;
}): Promise<ConstraintResult> => {
const specs = await loadTurnCommandSpecs();
const scenarioConst = asRecord(options.worldState.config).const;
const specs = await loadTurnCommandSpecs(scenarioConst);
const spec = specs[options.scope].find((entry) => entry.key === options.action);
if (!spec) {
throw new Error(`Unknown ${options.scope} turn command: ${options.action}`);
+47
View File
@@ -4,6 +4,7 @@ import {
loadGeneralTurnCommandSpecs,
loadNationTurnCommandSpecs,
} from '@sammo-ts/logic';
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
import { describe, expect, it } from 'vitest';
import {
@@ -98,6 +99,52 @@ describe('turn command argument input', () => {
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
});
it('accepts and rejects reserved commands from the real 904/905/910/912 world config', async () => {
const scenarioConsts = Object.fromEntries(
await Promise.all(
[904, 905, 910, 912].map(async (scenarioId) => [
scenarioId,
(await loadScenarioDefinitionById(scenarioId)).config.const,
])
)
) as Record<number, Record<string, unknown>>;
await expect(parseReservedTurnArgs('general', 'che_거병', {}, scenarioConsts[904])).rejects.toThrow(
'Unknown general turn command: che_거병'
);
await expect(
parseReservedTurnArgs('nation', 'che_선전포고', { destNationId: 2 }, scenarioConsts[904])
).rejects.toThrow('Unknown nation turn command: che_선전포고');
await expect(
parseReservedTurnArgs(
'general',
'che_무작위건국',
{ nationName: '신국', nationType: 'che_도적', colorType: 1 },
scenarioConsts[905]
)
).resolves.toEqual({ nationName: '신국', nationType: 'che_도적', colorType: 1 });
await expect(parseReservedTurnArgs('nation', 'che_무작위수도이전', {}, scenarioConsts[905])).resolves.toEqual(
{}
);
await expect(parseReservedTurnArgs('general', 'cr_맹훈련', {}, scenarioConsts[905])).rejects.toThrow(
'Unknown general turn command: cr_맹훈련'
);
await expect(parseReservedTurnArgs('general', 'cr_맹훈련', {}, scenarioConsts[910])).resolves.toEqual({});
await expect(
parseReservedTurnArgs('nation', 'cr_인구이동', { destCityId: 7, amount: 1234 }, scenarioConsts[910])
).resolves.toEqual({ destCityId: 7, amount: 1234 });
await expect(parseReservedTurnArgs('nation', 'che_무작위수도이전', {}, scenarioConsts[910])).rejects.toThrow(
'Unknown nation turn command: che_무작위수도이전'
);
await expect(parseReservedTurnArgs('nation', 'event_대검병연구', {}, scenarioConsts[912])).resolves.toEqual({});
await expect(parseReservedTurnArgs('nation', 'cr_인구이동', {}, scenarioConsts[912])).rejects.toThrow(
'Unknown nation turn command: cr_인구이동'
);
});
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
const items = buildEquipmentTradeItemOptions({
configConst: {},
+86 -1
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest';
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../src/context.js';
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
import type { GeneralActionModule, MapDefinition, UnitSetDefinition } from '@sammo-ts/logic';
import { buildRecruitmentCommandInfo, buildTurnCommandTable } from '../src/turns/commandTable.js';
const buildWorldState = (joinMode = 'full'): WorldStateRow =>
const buildWorldState = (joinMode = 'full', constOverrides: Record<string, unknown> = {}): WorldStateRow =>
({
id: 1,
scenarioCode: 'default',
@@ -17,6 +18,7 @@ const buildWorldState = (joinMode = 'full'): WorldStateRow =>
baseGold: 1000,
baseRice: 1000,
develCost: 100,
...constOverrides,
},
},
meta: {
@@ -190,6 +192,89 @@ describe('buildTurnCommandTable', () => {
});
});
it('projects scenario-specific command categories instead of the default profile', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState('full', {
availableGeneralCommand: {
: ['휴식'],
: ['che_물자조달'],
: ['cr_맹훈련'],
},
availableChiefCommand: {
: ['휴식'],
: ['cr_인구이동'],
: ['event_대검병연구', 'event_화륜차연구'],
},
}),
general: buildGeneral(),
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
expect(table.general.map(({ category }) => category)).toEqual(['개인', '내정', '군사']);
expect(table.general.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([
'휴식',
'che_물자조달',
'cr_맹훈련',
]);
expect(table.nation.map(({ category }) => category)).toEqual(['휴식', '특수', '연구']);
expect(table.nation.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([
'휴식',
'cr_인구이동',
'event_대검병연구',
'event_화륜차연구',
]);
});
it('projects the real 904/905/910/912 world command profiles into the API table', async () => {
const buildScenarioTable = async (scenarioId: number) => {
const scenario = await loadScenarioDefinitionById(scenarioId);
return buildTurnCommandTable({
worldState: buildWorldState('full', scenario.config.const),
general: buildGeneral(),
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
};
const commandCategory = (groups: Awaited<ReturnType<typeof buildScenarioTable>>['general'], key: string) =>
groups.find((group) => group.values.some((command) => command.key === key))?.category;
const nationCommandCategory = (groups: Awaited<ReturnType<typeof buildScenarioTable>>['nation'], key: string) =>
groups.find((group) => group.values.some((command) => command.key === key))?.category;
const [scenario904, scenario905, scenario910, scenario912] = await Promise.all(
[904, 905, 910, 912].map(buildScenarioTable)
);
expect(commandCategory(scenario904.general, 'che_물자조달')).toBe('내정');
expect(commandCategory(scenario904.general, 'che_거병')).toBeUndefined();
expect(nationCommandCategory(scenario904.nation, 'che_피장파장')).toBe('기타');
expect(nationCommandCategory(scenario904.nation, 'che_선전포고')).toBeUndefined();
expect(nationCommandCategory(scenario904.nation, 'che_부대탈퇴지시')).toBeUndefined();
expect(commandCategory(scenario905.general, 'che_무작위건국')).toBe('국가');
expect(nationCommandCategory(scenario905.nation, 'che_무작위수도이전')).toBe('기타');
expect(commandCategory(scenario910.general, 'cr_맹훈련')).toBe('군사');
expect(commandCategory(scenario910.general, 'cr_건국')).toBe('국가');
expect(nationCommandCategory(scenario910.nation, 'cr_인구이동')).toBe('특수');
expect(nationCommandCategory(scenario912.nation, 'event_대검병연구')).toBe('연구');
expect(
scenario912.nation.find((group) => group.category === '연구')?.values.map((command) => command.key)
).toEqual([
'event_대검병연구',
'event_극병연구',
'event_화시병연구',
'event_원융노병연구',
'event_산저병연구',
'event_음귀병연구',
'event_무희연구',
'event_상병연구',
'event_화륜차연구',
]);
});
it('keeps every default general and chief argument command inside the shared frontend field contract', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
+1 -12
View File
@@ -49,7 +49,7 @@ import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLea
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
import type { TurnGeneral } from './types.js';
import { buildPersistedRankRows } from './rankData.js';
import { buildInitialRankRows, buildPersistedRankRows } from './rankData.js';
import { persistUnificationFinalization } from './unificationPersistence.js';
import { buildOldNationArchiveData } from './oldNationArchive.js';
import { persistYearbookSnapshot } from './yearbookPersistence.js';
@@ -769,17 +769,6 @@ const buildPersistedGeneralMeta = (
return asJson(meta);
};
const buildInitialRankRows = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): Array<{ generalId: number; nationId: number; type: string; value: number }> =>
buildPersistedRankRows(general).map((row) => ({
...row,
nationId: 0,
// Ref Join은 전체 rank_data를 0으로 만든 직후 장수 생성에 사용한
// 유산 포인트만 inherit_spent_dyn에 반영한다.
value: row.type === 'inherit_spent_dyn' ? row.value : 0,
}));
const RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
const upsertRankRows = async (
+9 -1
View File
@@ -1933,9 +1933,17 @@ export class InMemoryTurnWorld {
continue;
}
delete conflict[key];
// Ref decodes a non-empty JSON object into a PHP array. Removing
// its last nation key and encoding that value persists `[]`, not
// `{}`. Preserve that observable storage shape until the next
// world load (where an empty conflict is normalized for logic).
const persistedConflict =
Object.keys(conflict).length === 0
? ([] as unknown as City['conflict'])
: (conflict as City['conflict']);
this.cities.set(city.id, {
...city,
conflict: conflict as City['conflict'],
conflict: persistedConflict,
});
this.dirtyCityIds.add(city.id);
}
+24 -2
View File
@@ -64,12 +64,34 @@ export const buildPersistedRankRows = (general: RankedGeneralState): PersistedRa
});
};
/**
* Ref GeneralBuilder/Join initializes every rank row in nation 0 with value 0.
* The one exception is a user-creation inheritance debit already carried in
* `inherit_spent_dyn`. Keep this persistence boundary shared by the database
* hooks and differential projection.
*/
export const buildInitialRankRows = (general: RankedGeneralState): PersistedRankRow[] =>
buildPersistedRankRows(general).map((row) => ({
...row,
nationId: 0,
value: row.type === 'inherit_spent_dyn' ? row.value : 0,
}));
export const buildLegacyComparableInitialRankRows = (
general: RankedGeneralState
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
return buildInitialRankRows(general).filter((row): row is PersistedRankRow & { type: LegacyRankDataType } =>
legacyTypes.has(row.type)
);
};
export const buildLegacyComparableRankRows = (
general: RankedGeneralState
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
return buildPersistedRankRows(general).filter(
(row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type)
return buildPersistedRankRows(general).filter((row): row is PersistedRankRow & { type: LegacyRankDataType } =>
legacyTypes.has(row.type)
);
};
+110 -26
View File
@@ -36,6 +36,7 @@ import {
getNextTurnAt,
getBillByLevel,
LEGACY_DEFAULT_MAX_LEVEL,
orderLegacyActionLoggerFlush,
type ItemModule,
type UniqueLotteryRunner,
} from '@sammo-ts/logic';
@@ -121,6 +122,42 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
'che_전투태세',
]);
// 아래 Ref 커맨드는 성공 로그보다 addExperience/addDedication을 먼저 호출한다.
// 이 차이는 같은 ActionLogger의 GENERAL/ACTION 버퍼 안에서 보이며, 등용수락은
// 메시지 즉시 실행과 예약 턴 차등 fixture 모두 같은 순서를 사용한다.
const LEGACY_PROGRESSION_BEFORE_ACTION_LOGS = new Set([
'che_등용수락',
'che_감축',
'che_국기변경',
'che_국호변경',
'che_무작위수도이전',
'che_증축',
'che_천도',
'che_초토화',
'cr_인구이동',
'event_극병연구',
'event_대검병연구',
'event_무희연구',
'event_산저병연구',
'event_상병연구',
'event_원융노병연구',
'event_음귀병연구',
'event_화륜차연구',
'event_화시병연구',
]);
const orderLegacyCommandLogs = (
actionKey: string,
actionLogs: readonly LogEntryDraft[],
progressionLogs: readonly LogEntryDraft[],
postProgressionLogs: readonly LogEntryDraft[]
): LogEntryDraft[] =>
orderLegacyActionLoggerFlush(
LEGACY_PROGRESSION_BEFORE_ACTION_LOGS.has(actionKey)
? [...progressionLogs, ...actionLogs, ...postProgressionLogs]
: [...actionLogs, ...progressionLogs, ...postProgressionLogs]
);
export const applyLegacyGeneralProgression = (
general: TurnGeneral,
previousGeneral: TurnGeneral,
@@ -152,36 +189,49 @@ export const applyLegacyGeneralProgression = (
actionKey === 'che_선양' ||
actionKey === 'che_출병' ||
actionKey === 'che_물자조달';
if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) {
const preservesResolvedProcurementLevel = actionKey === 'che_물자조달';
if (
preservesResolvedProcurementLevel ||
(!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience))
) {
const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0);
const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel);
meta.explevel = expLevel;
if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) {
const josaRo = JosaUtil.pick(String(expLevel), '로');
const nextExpLevel = preservesResolvedProcurementLevel ? actionResolvedExpLevel : expLevel;
meta.explevel = nextExpLevel;
if (
nextExpLevel !== previousExpLevel &&
(preservesResolvedProcurementLevel || actionResolvedExpLevel !== nextExpLevel)
) {
const josaRo = JosaUtil.pick(String(nextExpLevel), '로');
logs.push(
createGeneralActionLog(
general.id,
expLevel > previousExpLevel
? `<C>Lv ${expLevel}</>${josaRo} <C>레벨업</>!`
: `<C>Lv ${expLevel}</>${josaRo} <R>레벨다운</>!`,
nextExpLevel > previousExpLevel
? `<C>Lv ${nextExpLevel}</>${josaRo} <C>레벨업</>!`
: `<C>Lv ${nextExpLevel}</>${josaRo} <R>레벨다운</>!`,
{ format: LogFormat.PLAIN }
)
);
}
}
if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) {
if (
preservesResolvedProcurementLevel ||
(!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication))
) {
const previousDedicationLevel = readMetaNumber(previousGeneral.meta, 'dedlevel', 0);
meta.dedlevel = dedicationLevel;
if (dedicationLevel !== previousDedicationLevel) {
const actionResolvedDedicationLevel = readMetaNumber(general.meta, 'dedlevel', previousDedicationLevel);
const nextDedicationLevel = preservesResolvedProcurementLevel ? actionResolvedDedicationLevel : dedicationLevel;
meta.dedlevel = nextDedicationLevel;
if (nextDedicationLevel !== previousDedicationLevel) {
const dedicationLevelText =
dedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - dedicationLevel + 1}품관`;
const billText = getBillByLevel(dedicationLevel).toLocaleString('en-US');
nextDedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - nextDedicationLevel + 1}품관`;
const billText = getBillByLevel(nextDedicationLevel).toLocaleString('en-US');
const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로');
const josaRoBill = JosaUtil.pick(billText, '로');
logs.push(
createGeneralActionLog(
general.id,
dedicationLevel > previousDedicationLevel
nextDedicationLevel > previousDedicationLevel
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
{ format: LogFormat.PLAIN }
@@ -778,8 +828,14 @@ const createGeneralActionLog = (
const resolveDefinition = (
actionKey: string,
definitions: Map<string, GeneralActionDefinition>,
fallback: GeneralActionDefinition
): GeneralActionDefinition => definitions.get(actionKey) ?? fallback;
kind: 'general' | 'nation'
): GeneralActionDefinition => {
const definition = definitions.get(actionKey);
if (!definition) {
throw new Error(`Unknown reserved ${kind} turn command: ${actionKey}`);
}
return definition;
};
export const createReservedTurnHandler = async (options: {
reservedTurns: InMemoryReservedTurnStore;
@@ -790,6 +846,8 @@ export const createReservedTurnHandler = async (options: {
getWorld: () => InMemoryTurnWorld | null;
commandProfile?: TurnCommandProfile;
commandEnv?: TurnCommandEnv;
now?: () => Date;
messageSharedIconBaseUrl?: string;
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
calculateNpcNationFinance?: (
@@ -957,6 +1015,9 @@ export const createReservedTurnHandler = async (options: {
let currentGeneral = context.general;
let currentCity = context.city;
let currentNation = context.nation ?? null;
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
// 같은 장수 lifecycle의 국호변경은 뒤이은 유니크 획득 로그의 국호를 바꾸지 않는다.
const legacyStaticNationName = currentNation?.name ?? '재야';
const runAction = (
kind: 'nation' | 'general',
@@ -973,7 +1034,7 @@ export const createReservedTurnHandler = async (options: {
completed: boolean;
blockedReason?: string;
} => {
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
const resolvedDefinition = resolveDefinition(command.action, definitionMap, kind);
const rawArgs = extractArgsRecord(command.args);
const parsedArgs = resolvedDefinition.parseArgs(rawArgs);
let definition = resolvedDefinition;
@@ -1098,12 +1159,15 @@ export const createReservedTurnHandler = async (options: {
time: actionTime,
maxTechLevel: env.maxTechLevel,
uniqueLottery,
legacyStaticNationName,
};
let specificContext = buildActionContext(
actionKey,
baseContext,
{
world: context.world,
gameNow: options.now?.() ?? currentGeneral.turnTime,
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
scenarioConfig: options.scenarioConfig,
scenarioMeta: options.scenarioMeta,
map: options.map,
@@ -1280,15 +1344,24 @@ export const createReservedTurnHandler = async (options: {
};
}
}
const progressionLogs: LogEntryDraft[] = [];
if (!resolution.alternative && !usedFallback && resolution.completed) {
currentGeneral = applyLegacyGeneralProgression(
currentGeneral,
generalBeforeExecution,
actionKey,
env,
logs
progressionLogs
);
}
logs.push(
...orderLegacyCommandLogs(
actionKey,
resolution.logs,
progressionLogs,
resolution.postProgressionLogs
)
);
if (
!resolution.alternative &&
kind === 'nation' &&
@@ -1418,7 +1491,6 @@ export const createReservedTurnHandler = async (options: {
};
}
logs.push(...resolution.logs);
for (const nationId of resolution.destroyedNationIds ?? []) {
destroyedNationIds.add(nationId);
}
@@ -1535,10 +1607,9 @@ export const createReservedTurnHandler = async (options: {
if (resolution.created?.generals) {
const newGenerals = resolution.created.generals as TurnGeneral[];
createdGenerals.push(...newGenerals);
if (worldOverlay) {
for (const general of newGenerals) {
worldOverlay.syncGeneral(general);
}
for (const general of newGenerals) {
worldOverlay?.syncGeneral(general);
options.reservedTurns.ensureGeneralTurns(general.id);
}
}
if (resolution.created?.nations) {
@@ -1990,7 +2061,7 @@ export const createReservedTurnHandler = async (options: {
src: messageTarget,
dest: messageTarget,
text: npcMessage,
time: new Date(context.world.lastTurnTime),
time: options.now?.() ?? new Date(context.world.lastTurnTime),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
option: {},
});
@@ -2438,6 +2509,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
},
maxTechLevel: env.maxTechLevel,
uniqueLottery,
legacyStaticNationName: nation?.name ?? '재야',
};
const actionContext =
buildActionContext(
@@ -2476,7 +2548,10 @@ export const createImmediateGeneralActionExecutor = async (options: {
);
if (input.actionKey === 'che_접경귀환' && (resolution.general as TurnGeneral).cityId === general.cityId) {
for (const log of resolution.logs) {
for (const log of orderLegacyActionLoggerFlush([
...resolution.logs,
...resolution.postProgressionLogs,
])) {
options.world.pushLog(log, general.turnTime);
}
return { ok: false, reason: '가까운 아국 도시가 없습니다.' };
@@ -2514,7 +2589,11 @@ export const createImmediateGeneralActionExecutor = async (options: {
},
};
}
if (input.actionKey === 'che_거병') {
// Ref's immediate uprising and recruitment-accept commands both
// finish their actor addExperience/addDedication calls before the
// actor logger is applied. Keep the same level/rank state and logs
// outside the ordinary reserved-turn lifecycle.
if (input.actionKey === 'che_거병' || input.actionKey === 'che_등용수락') {
nextGeneral = applyLegacyGeneralProgression(
{
...nextGeneral,
@@ -2566,7 +2645,12 @@ export const createImmediateGeneralActionExecutor = async (options: {
for (const troopId of resolution.deletedTroopIds ?? []) {
options.world.removeTroop(troopId);
}
for (const log of [...resolution.logs, ...progressionLogs]) {
for (const log of orderLegacyCommandLogs(
input.actionKey,
resolution.logs,
progressionLogs,
resolution.postProgressionLogs
)) {
options.world.pushLog(log, general.turnTime);
}
options.world.updateGeneral(input.generalId, nextGeneral);
+15 -10
View File
@@ -1,7 +1,12 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic';
import {
parseTurnCommandProfile,
resolveScenarioTurnCommandProfile,
type ScenarioTurnCommandProfileResolution,
type TurnCommandProfile,
} from '@sammo-ts/logic';
import { resolveWorkspaceRoot } from '../paths.js';
@@ -10,6 +15,7 @@ const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands
export interface TurnCommandProfileOptions {
filePath?: string;
scenarioConst?: unknown;
}
const readCommandProfile = async (filePath: string): Promise<TurnCommandProfile> => {
@@ -17,14 +23,13 @@ const readCommandProfile = async (filePath: string): Promise<TurnCommandProfile>
return parseTurnCommandProfile(JSON.parse(raw) as unknown);
};
export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise<TurnCommandProfile> => {
export const loadScenarioTurnCommandProfile = async (
options?: TurnCommandProfileOptions
): Promise<ScenarioTurnCommandProfileResolution> => {
const filePath = options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH;
try {
return await readCommandProfile(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return DEFAULT_TURN_COMMAND_PROFILE;
}
throw error;
}
const fallback = await readCommandProfile(filePath);
return resolveScenarioTurnCommandProfile(options?.scenarioConst, fallback);
};
export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise<TurnCommandProfile> =>
(await loadScenarioTurnCommandProfile(options)).profile;
+8 -5
View File
@@ -663,11 +663,10 @@ const createTurnDaemonRuntimeWithLease = async (
});
const commandProfile =
options.commandProfile ??
(options.commandProfilePath
? await loadTurnCommandProfile({
filePath: options.commandProfilePath,
})
: await loadTurnCommandProfile());
(await loadTurnCommandProfile({
...(options.commandProfilePath ? { filePath: options.commandProfilePath } : {}),
scenarioConst: snapshot.scenarioConfig.const,
}));
let worldRef: InMemoryTurnWorld | null = null;
let redisConnector: RedisConnector | null = null;
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
@@ -730,6 +729,10 @@ const createTurnDaemonRuntimeWithLease = async (
map: snapshot.map,
unitSet: snapshot.unitSet,
getWorld: () => worldRef,
now: () => {
const wallNow = new Date(clock.nowMs());
return worldRef?.getGameNow(wallNow) ?? wallNow;
},
commandProfile,
commandEnv: monthlyCommandEnv,
calculateNpcNationFinance: (financeWorld, nation, currentMonth) =>
@@ -375,7 +375,7 @@ describe('unique auction inheritance log compatibility', () => {
);
});
it('builds all four Ref award logs with the original formats and labels', () => {
it('builds all four Ref award logs in the original flush order with the original formats and labels', () => {
const bidder = {
id: 7,
name: '관우',
@@ -390,13 +390,6 @@ describe('unique auction inheritance log compatibility', () => {
itemRawName: '칠성검',
})
).toEqual([
expect.objectContaining({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
generalId: 7,
text: '<C>칠성검(+12)</>을 습득했습니다!',
}),
expect.objectContaining({
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
@@ -405,10 +398,11 @@ describe('unique auction inheritance log compatibility', () => {
text: '<C>칠성검(+12)</>을 습득',
}),
expect.objectContaining({
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
text: '<Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
generalId: 7,
text: '<C>칠성검(+12)</>을 습득했습니다!',
}),
expect.objectContaining({
scope: LogScope.SYSTEM,
@@ -416,6 +410,12 @@ describe('unique auction inheritance log compatibility', () => {
format: LogFormat.YEAR_MONTH,
text: '<C><b>【보물수배】</b></><D><b>촉</b></>의 <Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
}),
expect.objectContaining({
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
text: '<Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
}),
]);
});
@@ -194,7 +194,7 @@ describe('scenario general pool within one reserved turn', () => {
state: buildState(),
schedule,
map,
reservedTurnStoreOptions: { maxGeneralTurns: 10, maxNationTurns: 12 },
reservedTurnStoreOptions: { maxGeneralTurns: 30, maxNationTurns: 12 },
commandRngFactory: ({ actionKey }) =>
actionKey === 'che_의병모집'
? new RandUtil(new SequenceRNG([0, 0.26, 0.51, 0.76]))
@@ -210,5 +210,29 @@ describe('scenario general pool within one reserved turn', () => {
expect(created.map((general) => general.npcState).sort()).toEqual([3, 4, 4, 4]);
expect(claims.every(Boolean)).toBe(true);
expect(new Set(claims.map((claim) => claim?.poolEntryId))).toEqual(new Set([1, 2, 3, 4]));
const createdIds = created.map((general) => general.id);
expect(harness.reservedTurnStore.peekDirtyState().generalInitializationIds).toEqual(createdIds);
for (const generalId of createdIds) {
expect(harness.reservedTurnStore.getGeneralTurns(generalId)).toEqual(
Array.from({ length: 30 }, () => ({ action: '휴식', args: {} }))
);
}
await harness.reservedTurnStore.flushChanges();
const persistedRows = harness.mockPrisma.generalTurn.createMany.mock.calls.flatMap(([input]) => input.data);
const persistedCreatedRows = persistedRows.filter((row) => createdIds.includes(row.generalId));
expect(persistedCreatedRows).toHaveLength(createdIds.length * 30);
for (const generalId of createdIds) {
expect(persistedCreatedRows.filter((row) => row.generalId === generalId)).toEqual(
Array.from({ length: 30 }, (_, turnIdx) => ({
generalId,
turnIdx,
actionCode: '휴식',
arg: {},
}))
);
}
});
});
@@ -193,6 +193,34 @@ describe('legacy general-turn execution contract', () => {
expect(resolved.meta).toMatchObject({ explevel: 25, dedlevel: 8 });
});
it('preserves procurement-computed levels while emitting their Ref progression logs', () => {
const previous = makeGeneral({
experience: 995,
dedication: 899,
meta: { killturn: 24, explevel: 9, dedlevel: 3 },
});
const afterProcurement = makeGeneral({
experience: 1_005,
dedication: 901,
meta: { killturn: 24, explevel: 10, dedlevel: 4 },
});
const logs: Array<{ text: string }> = [];
const resolved = applyLegacyGeneralProgression(
afterProcurement,
previous,
'che_물자조달',
{ maxStatLevel: 255, maxDedicationLevel: 30 } as never,
logs as never
);
expect(resolved.meta).toMatchObject({ explevel: 10, dedlevel: 4 });
expect(logs.map((entry) => entry.text)).toEqual([
'<C>Lv 10</>으로 <C>레벨업</>!',
'<Y>27품관</>으로 <C>승급</>하여 봉록이 <C>1,200</>으로 <C>상승</>했습니다!',
]);
});
it('quantizes integer general columns at each in-memory DB mutation boundary', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(makeGeneral()),
@@ -218,6 +246,23 @@ describe('legacy general-turn execution contract', () => {
});
});
it('fails closed instead of silently resting on an unknown queued command', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(makeGeneral()),
state: makeState(),
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'unknown-command', args: {} };
const beforeGeneral = structuredClone(harness.world.getGeneralById(1));
const beforeTurns = structuredClone(harness.reservedTurnStore.getGeneralTurns(1));
await expect(harness.runOneTick()).rejects.toThrow('Unknown reserved general turn command: unknown-command');
expect(harness.world.getGeneralById(1)).toEqual(beforeGeneral);
expect(harness.reservedTurnStore.getGeneralTurns(1)).toEqual(beforeTurns);
expect(harness.world.peekDirtyState()).toMatchObject({ logs: [], messages: [] });
});
it('keeps fractional nation rewards in the same general object until the following command is persisted', async () => {
const twoCityMap = {
...map,
@@ -200,6 +200,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
return {
world,
worldRef,
mockPrisma,
reservedTurnStore,
handler,
processor,
@@ -1,7 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { MapDefinition, ScenarioEffectKey, TurnSchedule } from '@sammo-ts/logic';
import {
LogCategory,
LogFormat,
LogScope,
type MapDefinition,
type ScenarioEffectKey,
type TurnSchedule,
} from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
@@ -562,6 +569,25 @@ describe('my information world commands', () => {
experience: recruiter.experience + 100,
dedication: recruiter.dedication + 100,
});
const actionLogs = fixture.world
.consumeDirtyState()
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
expect(actionLogs.map((log) => log.text)).toEqual([
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('망명하여 수도로'),
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('등용에 성공했습니다.'),
]);
expect(actionLogs.map((log) => log.format)).toEqual([
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
]);
});
it('preserves the Ref uprising precheck order and messages after the game starts', async () => {
@@ -90,6 +90,8 @@ describe('도시 점령 시 국가 멸망 처리', () => {
strongFrontCity.supplyState = 1;
const conflictCity = cities.find((city) => city.id === 3)!;
conflictCity.conflict = { 2: 100, 1: 50 };
const emptiedConflictCity = cities.find((city) => city.id === 4)!;
emptiedConflictCity.conflict = { 2: 25 };
const unitSet: UnitSetDefinition = {
id: 'test_unit_set',
@@ -291,6 +293,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
expect(world.listEvents('destroy_nation')).toEqual([]);
expect(world.getState().meta.block_change_scout).toBeUndefined();
expect(world.getCityById(conflictCity.id)?.conflict).toEqual({ 1: 50 });
expect(world.getCityById(emptiedConflictCity.id)?.conflict).toEqual([]);
const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
expect(updatedWeakGeneral?.nationId).toBe(0);
@@ -251,6 +251,7 @@ describe('NPC 일반 내정 턴', () => {
});
await reservedTurnStore.loadAll();
const logicalGameNow = addMinutes(mockDate, 3);
const wrapper = { world: null as InMemoryTurnWorld | null };
const handler = await createReservedTurnHandler({
reservedTurns: reservedTurnStore,
@@ -259,6 +260,7 @@ describe('NPC 일반 내정 턴', () => {
map: MINIMAL_MAP as any,
unitSet: snapshot.unitSet,
getWorld: () => wrapper.world,
now: () => logicalGameNow,
});
const world = new InMemoryTurnWorld(state, snapshot, {
@@ -296,6 +298,7 @@ describe('NPC 일반 내정 턴', () => {
msgType: 'public',
text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다',
src: expect.objectContaining({ generalId: 1, generalName: 'NPC_무장', nationId: 1 }),
time: logicalGameNow,
})
);
});
+34 -6
View File
@@ -3,6 +3,8 @@ import { LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common';
import {
applyPersistedRankRowsToMeta,
buildInitialRankRows,
buildLegacyComparableInitialRankRows,
buildLegacyComparableRankRows,
buildPersistedRankRows,
rankMetaKey,
@@ -34,13 +36,39 @@ describe('rank data projection', () => {
{ generalId: 7, nationId: 2, type: 'dex1', value: 12 },
])
);
expect(buildLegacyComparableRankRows({
id: 7,
expect(
buildLegacyComparableRankRows({
id: 7,
nationId: 2,
experience: 10.5,
dedication: 20.49,
meta: {},
})
).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
const initialRows = buildInitialRankRows({
id: 8,
nationId: 2,
experience: 10.5,
dedication: 20.49,
meta: {},
})).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
experience: 100,
dedication: 200,
meta: { rank_warnum: 9, inherit_spent_dyn: 7 },
});
expect(initialRows).toEqual(
expect.arrayContaining([
{ generalId: 8, nationId: 0, type: 'experience', value: 0 },
{ generalId: 8, nationId: 0, type: 'warnum', value: 0 },
{ generalId: 8, nationId: 0, type: 'inherit_spent_dyn', value: 7 },
])
);
expect(
buildLegacyComparableInitialRankRows({
id: 8,
nationId: 2,
experience: 100,
dedication: 200,
meta: {},
})
).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
});
it('loads persisted rows into the same raw and prefixed meta keys used by commands', () => {
@@ -0,0 +1,281 @@
import { describe, expect, it } from 'vitest';
import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js';
import { loadScenarioTurnCommandProfile } from '../src/turn/turnCommandProfile.js';
type CommandGroupManifest = ReadonlyArray<{
category: string;
commands: readonly string[];
}>;
const defaultGeneralProfileManifest = [
'che_거병',
'che_임관',
'che_장수대상임관',
'che_랜덤임관',
'che_귀환',
'che_건국',
'che_훈련',
'che_단련',
'che_숙련전환',
'che_사기진작',
'che_요양',
'che_견문',
'che_은퇴',
'che_내정특기초기화',
'che_전투특기초기화',
'che_장비매매',
'che_출병',
'che_주민선정',
'che_정착장려',
'che_농지개간',
'che_상업투자',
'che_기술연구',
'che_치안강화',
'che_수비강화',
'che_성벽보수',
'che_선동',
'che_탈취',
'che_파괴',
'che_화계',
'che_집합',
'che_인재탐색',
'che_등용',
'che_징병',
'che_모병',
'che_소집해제',
'che_첩보',
'che_군량매매',
'che_물자조달',
'che_증여',
'che_헌납',
'che_이동',
'che_강행',
'che_하야',
'che_선양',
'che_해산',
'휴식',
] as const;
const generalPersonalCommands = [
'휴식',
'che_요양',
'che_단련',
'che_숙련전환',
'che_견문',
'che_은퇴',
'che_장비매매',
'che_군량매매',
'che_내정특기초기화',
'che_전투특기초기화',
] as const;
const generalDomesticCommands = [
'che_농지개간',
'che_상업투자',
'che_기술연구',
'che_수비강화',
'che_성벽보수',
'che_치안강화',
'che_정착장려',
'che_주민선정',
'che_물자조달',
] as const;
const generalMilitaryCommands = [
'che_징병',
'che_모병',
'che_훈련',
'che_사기진작',
'che_출병',
'che_집합',
'che_소집해제',
'che_첩보',
] as const;
const generalPersonnelCommands = ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_랜덤임관'] as const;
const generalSchemeCommands = ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'] as const;
const nationDiplomacyCommands = [
'che_물자원조',
'che_불가침제의',
'che_선전포고',
'che_종전제의',
'che_불가침파기제의',
] as const;
const scenarioProfileManifest: Record<
number,
{ generalGroups: CommandGroupManifest | null; nationGroups: CommandGroupManifest }
> = {
904: {
generalGroups: [
{ category: '개인', commands: generalPersonalCommands },
{ category: '내정', commands: generalDomesticCommands },
{ category: '군사', commands: generalMilitaryCommands },
{ category: '인사', commands: generalPersonnelCommands },
{ category: '계략', commands: generalSchemeCommands },
{ category: '국가', commands: ['che_증여', 'che_헌납', 'che_하야'] },
],
nationGroups: [
{ category: '휴식', commands: ['휴식'] },
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] },
{ category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] },
{
category: '전략',
commands: [
'che_필사즉생',
'che_백성동원',
'che_수몰',
'che_허보',
'che_의병모집',
'che_이호경식',
'che_급습',
],
},
{ category: '기타', commands: ['che_피장파장', 'che_국기변경', 'che_국호변경'] },
],
},
905: {
generalGroups: [
{ category: '개인', commands: generalPersonalCommands },
{ category: '내정', commands: generalDomesticCommands },
{ category: '군사', commands: generalMilitaryCommands },
{ category: '인사', commands: generalPersonnelCommands },
{ category: '계략', commands: generalSchemeCommands },
{
category: '국가',
commands: ['che_증여', 'che_헌납', 'che_하야', 'che_거병', 'che_무작위건국', 'che_선양', 'che_해산'],
},
],
nationGroups: [
{ category: '휴식', commands: ['휴식'] },
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] },
{ category: '외교', commands: nationDiplomacyCommands },
{ category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] },
{
category: '전략',
commands: [
'che_필사즉생',
'che_백성동원',
'che_수몰',
'che_허보',
'che_의병모집',
'che_이호경식',
'che_급습',
'che_피장파장',
],
},
{ category: '기타', commands: ['che_국기변경', 'che_국호변경', 'che_무작위수도이전'] },
],
},
910: {
generalGroups: [
{ category: '개인', commands: generalPersonalCommands },
{ category: '내정', commands: generalDomesticCommands },
{
category: '군사',
commands: [
'che_징병',
'che_모병',
'che_훈련',
'che_사기진작',
'cr_맹훈련',
'che_출병',
'che_집합',
'che_소집해제',
'che_첩보',
],
},
{ category: '인사', commands: generalPersonnelCommands },
{ category: '계략', commands: generalSchemeCommands },
{
category: '국가',
commands: ['che_증여', 'che_헌납', 'che_하야', 'che_거병', 'cr_건국', 'che_선양', 'che_해산'],
},
],
nationGroups: [
{ category: '휴식', commands: ['휴식'] },
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] },
{ category: '외교', commands: nationDiplomacyCommands },
{ category: '특수', commands: ['che_초토화', 'che_천도', 'cr_인구이동'] },
{
category: '전략',
commands: [
'che_필사즉생',
'che_백성동원',
'che_수몰',
'che_허보',
'che_의병모집',
'che_이호경식',
'che_급습',
],
},
{ category: '기타', commands: ['che_피장파장', 'che_국기변경', 'che_국호변경'] },
],
},
912: {
generalGroups: null,
nationGroups: [
{ category: '휴식', commands: ['휴식'] },
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] },
{ category: '외교', commands: nationDiplomacyCommands },
{ category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] },
{
category: '전략',
commands: [
'che_필사즉생',
'che_백성동원',
'che_수몰',
'che_허보',
'che_의병모집',
'che_이호경식',
'che_급습',
'che_피장파장',
],
},
{ category: '기타', commands: ['che_국기변경', 'che_국호변경'] },
{
category: '연구',
commands: [
'event_대검병연구',
'event_극병연구',
'event_화시병연구',
'event_원융노병연구',
'event_산저병연구',
'event_음귀병연구',
'event_무희연구',
'event_상병연구',
'event_화륜차연구',
],
},
],
},
};
const loadScenarioProfile = async (scenarioId: number) => {
const scenario = await loadScenarioDefinitionById(scenarioId);
return loadScenarioTurnCommandProfile({ scenarioConst: scenario.config.const });
};
describe('scenario command profile resources', () => {
it('fails closed when the configured base profile file is missing', async () => {
await expect(
loadScenarioTurnCommandProfile({ filePath: '/tmp/core2026-command-profile-does-not-exist.json' })
).rejects.toMatchObject({ code: 'ENOENT' });
});
it.each(Object.entries(scenarioProfileManifest))(
'preserves scenario %s general/chief group order and the exact flattened product profile',
async (scenarioId, manifest) => {
const result = await loadScenarioProfile(Number(scenarioId));
const expectedGeneral = manifest.generalGroups
? manifest.generalGroups.flatMap((group) => group.commands)
: defaultGeneralProfileManifest;
const expectedNation = manifest.nationGroups.flatMap((group) => group.commands);
expect(result.generalGroups).toEqual(manifest.generalGroups);
expect(result.nationGroups).toEqual(manifest.nationGroups);
expect(result.profile.general).toEqual(expectedGeneral);
expect(result.profile.nation).toEqual(expectedNation);
expect(new Set(result.profile.general).size).toBe(result.profile.general.length);
expect(new Set(result.profile.nation).size).toBe(result.profile.nation.length);
}
);
});
@@ -39,7 +39,11 @@ const buildGeneral = (id: number): TurnGeneral => ({
describe('unique lottery on general commands', () => {
it('awards a unique item for eligible commands', async () => {
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const generals = [buildGeneral(1)];
const lotteryGeneral = buildGeneral(1);
lotteryGeneral.experience = 995;
lotteryGeneral.dedication = 899;
lotteryGeneral.meta = { killturn: 24, explevel: 9, dedlevel: 3 };
const generals = [lotteryGeneral];
const snapshot: TurnWorldSnapshot = {
generals: generals as any,
cities: [
@@ -172,6 +176,14 @@ describe('unique lottery on general commands', () => {
expect(result.general?.role.items.weapon).toBe('che_무기_12_칠성검');
const logTexts = (result.logs ?? []).map((entry) => entry.text);
expect(logTexts.some((text) => text.includes('【아이템】'))).toBe(true);
const actionIndex = logTexts.findIndex((text) => text.includes('훈련'));
const levelIndex = logTexts.findIndex((text) => text.includes('레벨업'));
const dedicationIndex = logTexts.findIndex((text) => text.includes('승급'));
const uniqueIndex = logTexts.findIndex((text) => text.includes('습득했습니다'));
expect([actionIndex, levelIndex, dedicationIndex, uniqueIndex].every((index) => index >= 0)).toBe(true);
expect(actionIndex).toBeLessThan(levelIndex);
expect(levelIndex).toBeLessThan(dedicationIndex);
expect(dedicationIndex).toBeLessThan(uniqueIndex);
});
it('does not award a unique item reserved by an active auction', async () => {
@@ -54,8 +54,16 @@ pnpm check:legacy:nation
즉시 외교 3종의 core log는 명령 파일이 아니라 공통
`packages/logic/src/diplomacy/instantResponse.ts`에서 생성됩니다.
정적 log 비교의 이 예외는 명령별 allowlist로 제한하며,
`instantDiplomacyReference.integration.test.ts`에서 실제 ref/core 결과를
별도로 비교합니다.
`instantDiplomacyCoreReference.integration.test.ts`에서 실제 Ref API entry와 Core
router 결과를 별도로 비교합니다.
장수·수뇌 registry 전수 성공 case는 각각
`turnCommandGeneralMatrix.integration.test.ts`
`turnCommandNationMatrix.integration.test.ts`가 registry와 exact-set으로 닫습니다.
두 matrix의 `includeLifecycle`은 요청 scope의 queue shift와 tail lifecycle까지이며,
같은 actor의 수뇌→장수 제품 outer loop를 뜻하지 않습니다. 결합 outer lifecycle은
`turnCommandFullLifecycle.integration.test.ts`, 실제 PostgreSQL flush/reload는
`turnCommandFullLifecyclePersistence.integration.test.ts`가 대표 fixture로 검증합니다.
기본 integration:
@@ -82,17 +90,25 @@ pnpm --filter @sammo-ts/integration-tests test:integration
Canonical 비교값은 의미 단위로 정규화합니다.
- general: 위치, 소속, 자원, 병력, 능력치, 경험·공헌, penalty, aux, turn time
- nation: 자원, level, 수도, 외교, 정책, aux
- general: 위치, 소속, owner identity, 자원, 병력, 능력치, 경험·공헌·rank mirror,
affinity·NPC origin/state, penalty, aux, turn time
- nation: 자원, level, 수도, 외교, 정책, flag·spy·rate·bill·secret limit, 저장 `gennum`, aux
- city: 소유권, 인구·내정·방어, conflict, supply와 인접 상태
- troop과 실행 중 새로 생긴 general/city/nation/troop의 created-entity closure
- queue: command, args, term, next available, revision
- output: 개인·국가·역사 log, message, 공개 문구와 error
- output: `general_record`/`world_history`별 물리 순서, log year·month·format·text,
strict message payload·option·icon·lifetime·unread timeline, 공개 문구와 error
- execution: RNG trace, handler 순서, input event와 checkpoint
DB auto ID, 생성 시각처럼 의미 없는 차이는 comparator에서 이름과 이유를
명시합니다. 의미 field를 ignore하거나 숫자 허용 범위를 넓혀 mismatch를
숨기지 않습니다.
JSON missing·`{}`·`[]`는 snapshot 원형에서 구분합니다. 일반 message option 부재의
Ref `[]`와 Core `{}`만 의미상 같게 보며, actionable diplomacy의 `option=null` sentinel은
부재로 합치지 않습니다. Ref log prefix와 Core format은 독립적으로 해석하고, 서로 다른
`ActionLogger::flush()`/`applyDB()` 경계는 고유 `legacyFlushGroup`으로 보존합니다.
## RNG
Gameplay RNG는 `LiteHashDRBG`, `RNG`, `RandUtil` 경로를 추적합니다.
+10 -1
View File
@@ -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;
+106 -20
View File
@@ -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,
}),
];
};
+51 -1
View File
@@ -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[] {
+2
View File
@@ -33,6 +33,8 @@ export interface LogEntryDraft {
month?: number;
/** 로그를 만든 논리 게임 시각. 생략하면 flush context의 시각을 사용한다. */
occurredAt?: Date;
/** 하나의 명령에서 별도 Ref ActionLogger로 저장한 순서. 작은 그룹을 먼저 flush한다. */
legacyFlushGroup?: number;
}
export interface LogEntryRecord {
+34 -4
View File
@@ -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,
};
};
+6 -5
View File
@@ -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;
};
+143 -54
View File
@@ -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));
+17 -11
View File
@@ -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();
}
}
+10
View File
@@ -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;
}
@@ -116,5 +116,8 @@ describe('appointment global summary log format', () => {
);
expectMonthlySummary(logs);
expect(
logs.find((entry) => entry.scope === LogScope.GENERAL && entry.category === LogCategory.HISTORY)?.format
).toBe(LogFormat.YEAR_MONTH);
});
});
@@ -27,18 +27,21 @@ describe('processGeneralActionWithFallback', () => {
const fallbackResolver: GeneralActionResolver = {
key: 'fallbackCmd',
resolve: () => ({
effects: [
{
type: 'log',
entry: { text: 'Primary failed', scope: 'general', category: 'action', format: 'month' },
} as any,
],
alternative: {
commandKey: 'alternativeCmd',
args: { foo: 'bar' },
},
}),
resolve: (context) => {
context.addPostProgressionLog?.('Primary post-progression');
return {
effects: [
{
type: 'log',
entry: { text: 'Primary failed', scope: 'general', category: 'action', format: 'month' },
} as any,
],
alternative: {
commandKey: 'alternativeCmd',
args: { foo: 'bar' },
},
};
},
};
const alternativeResolver: GeneralActionResolver = {
@@ -100,18 +103,8 @@ describe('processGeneralActionWithFallback', () => {
mockLoader
);
// It should eventually execute alternativeResolver
// BUT resolveGeneralAction creates a FRESH resolution from the FINAL resolver.
// It does NOT merge logs currently. (As per my implementation comment)
// Wait, did I implement log merging? No.
// I implemented a simple loop that re-runs `resolveGeneralAction`.
// So the final resolution comes from `alternativeResolver`.
// Let's verify what we expect.
// If we want legacy parity, we might expect logs from the first attempt too.
// But for now, let's verify the loop works.
expect(resolution.logs).toHaveLength(2); // 'Primary failed' + 'Alternative executed...'
expect(resolution.postProgressionLogs.map((entry) => entry.text)).toEqual(['Primary post-progression']);
expect(resolution.alternative).toBeUndefined(); // The final one succeeded
expect(mockLoader.load).toHaveBeenCalledWith('alternativeCmd');
});
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '../../../src/domain/entities.js';
import type { GeneralActionResolveContext } from '../../../src/actions/engine.js';
import { ActionDefinition as TradeAction } from '../../../src/actions/turn/general/che_군량매매.js';
import { ActionResolver as ResignAction } from '../../../src/actions/turn/general/che_하야.js';
import { ActionResolver as RetireAction } from '../../../src/actions/turn/general/che_은퇴.js';
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
import { finalizeLogEntry } from '../../../src/logging/entries.js';
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js';
const makeGeneral = (overrides: Partial<General> = {}): General =>
({
id: 1,
name: '검증장수',
nationId: 2,
cityId: 3,
troopId: 0,
npcState: 0,
officerLevel: 1,
experience: 100,
dedication: 100,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 1,
train: 100,
atmos: 100,
injury: 0,
age: 60,
stats: { leadership: 70, strength: 60, intelligence: 50 },
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {},
...overrides,
}) as General;
const nation = {
id: 2,
name: '검증국',
color: '#ff0000',
capitalCityId: 3,
chiefGeneralId: 1,
gold: 10_000,
rice: 10_000,
power: 0,
level: 1,
typeCode: 'che_def',
meta: { gennum: 1 },
} satisfies Nation;
const city = {
id: 3,
name: '검증도시',
nationId: nation.id,
level: 1,
state: 0,
population: 20_000,
populationMax: 50_000,
agriculture: 500,
agricultureMax: 1_000,
commerce: 500,
commerceMax: 1_000,
security: 500,
securityMax: 1_000,
defence: 300,
defenceMax: 1_000,
wall: 300,
wallMax: 1_000,
supplyState: 1,
frontState: 0,
meta: { trade: 100 },
} satisfies City;
const createLogSink =
(logs: LogEntryDraft[]): GeneralActionResolveContext['addLog'] =>
(text, options = {}) => {
const entry: LogEntryDraft = {
scope: options.scope ?? LogScope.GENERAL,
category: options.category ?? LogCategory.ACTION,
text,
...options,
};
if (entry.scope === LogScope.GENERAL && entry.generalId === undefined) {
entry.generalId = 1;
}
logs.push(entry);
};
const expectMonthlyPersistence = (entry: LogEntryDraft, legacyWrongFormat: LogFormat): void => {
expect(entry.format).toBe(LogFormat.MONTH);
const persisted = finalizeLogEntry(entry, { year: 186, month: 9 });
const mutant = finalizeLogEntry({ ...entry, format: legacyWrongFormat }, { year: 186, month: 9 });
expect(persisted?.text).toMatch(/^<C><\/>9:/u);
expect(mutant?.text).not.toBe(persisted?.text);
expect(mutant?.text).not.toMatch(/^<C><\/>9:/u);
};
describe('general command Ref log format parity', () => {
it.each([
{ buyRice: true, amount: 100 },
{ buyRice: false, amount: 100 },
])('keeps che_군량매매 action logs on the Ref monthly format for $buyRice', (args) => {
const logs: LogEntryDraft[] = [];
const action = new TradeAction();
action.resolve(
{
general: makeGeneral(),
city,
nation: { ...nation },
rng: { nextFloat1: () => 0 },
addLog: createLogSink(logs),
} as unknown as Parameters<typeof action.resolve>[0],
args
);
expect(logs).toHaveLength(1);
expectMonthlyPersistence(logs[0]!, LogFormat.PLAIN);
});
it('keeps the che_하야 system summary on the Ref monthly format', () => {
const logs: LogEntryDraft[] = [];
const action = new ResignAction({ defaultNpcGold: 1_000, defaultNpcRice: 1_000 } as TurnCommandEnv);
action.resolve(
{
general: makeGeneral(),
nation,
troopMembers: [],
rng: {},
addLog: createLogSink(logs),
} as unknown as Parameters<typeof action.resolve>[0],
{}
);
const summary = logs.find((entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY);
expect(summary).toBeDefined();
expectMonthlyPersistence(summary!, LogFormat.RAWTEXT);
});
it('keeps the che_은퇴 system summary on the Ref monthly format', () => {
const logs: LogEntryDraft[] = [];
const action = new RetireAction();
action.resolve(
{
general: makeGeneral(),
rng: {},
addLog: createLogSink(logs),
} as unknown as Parameters<typeof action.resolve>[0],
{}
);
const summary = logs.find((entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY);
expect(summary).toBeDefined();
expectMonthlyPersistence(summary!, LogFormat.RAWTEXT);
});
});
@@ -0,0 +1,312 @@
import { describe, expect, it } from 'vitest';
import type { City, General, GeneralTriggerState, Nation } from '../../../src/domain/entities.js';
import {
resolveGeneralAction,
type GeneralActionResolveInputContext,
type GeneralActionResolver,
} from '../../../src/actions/engine.js';
import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn/nation/che_천도.js';
import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js';
import { ActionDefinition as ReduceCityAction } from '../../../src/actions/turn/nation/che_감축.js';
import { ActionDefinition as RandomMoveCapitalAction } from '../../../src/actions/turn/nation/che_무작위수도이전.js';
import { ActionDefinition as ScorchedEarthAction } from '../../../src/actions/turn/nation/che_초토화.js';
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '../../../src/logging/types.js';
import type { TurnSchedule } from '../../../src/turn/calendar.js';
import type { MapDefinition } from '../../../src/world/types.js';
const ENV: TurnCommandEnv = {
develCost: 100,
trainDelta: 30,
atmosDelta: 30,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
sabotageDefaultProb: 0.5,
sabotageProbCoefByStat: 0.1,
sabotageDefenceCoefByGeneralCount: 0.1,
sabotageDamageMin: 10,
sabotageDamageMax: 30,
openingPartYear: 3,
maxGeneral: 500,
defaultNpcGold: 1_000,
defaultNpcRice: 1_000,
defaultCrewTypeId: 1_100,
defaultSpecialDomestic: null,
defaultSpecialWar: null,
initialNationGenLimit: 10,
maxTechLevel: 12,
baseGold: 1_000,
baseRice: 2_000,
maxResourceActionAmount: 10_000,
};
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 60 }] };
const rng = {
nextFloat1: () => 0,
nextBool: () => false,
nextInt: () => 0,
};
const makeGeneral = (id: number, name = '운영자'): General => ({
id,
name,
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 80, strength: 70, intelligence: 60 },
experience: 1_000,
dedication: 1_000,
officerLevel: id === 1 ? 12 : 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 1_100,
train: 100,
atmos: 100,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, betray: 0 },
});
const makeNation = (): Nation => ({
id: 1,
name: '위',
color: '#111111',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 1_000_000,
rice: 1_000_000,
power: 1_000,
level: 1,
typeCode: 'che_명가',
meta: { capset: 0, can_무작위수도이전: 1, surlimit: 0 },
});
const makeCity = (id: number, name: string, nationId: number): City => ({
id,
name,
nationId,
level: 5,
state: 0,
population: 200_000,
populationMax: 300_000,
agriculture: 3_000,
agricultureMax: 4_000,
commerce: 3_000,
commerceMax: 4_000,
security: 3_000,
securityMax: 4_000,
supplyState: 1,
frontState: 0,
defence: 3_000,
defenceMax: 4_000,
wall: 3_000,
wallMax: 4_000,
conflict: {},
meta: { trust: 80, trade: 100 },
});
const mapStats = {
population: 200_000,
agriculture: 3_000,
commerce: 3_000,
security: 3_000,
defence: 3_000,
wall: 3_000,
};
const map: MapDefinition = {
id: 'nation-capital-log-parity',
name: '국가 명령 로그',
cities: [
{
id: 1,
name: '허창',
level: 5,
region: 1,
position: { x: 0, y: 0 },
connections: [2],
initial: mapStats,
max: mapStats,
},
{
id: 2,
name: '낙양',
level: 5,
region: 1,
position: { x: 1, y: 0 },
connections: [1],
initial: mapStats,
max: mapStats,
},
],
};
const resolveLogs = <Args>(
resolver: GeneralActionResolver<GeneralTriggerState, Args>,
context: GeneralActionResolveInputContext & Record<string, unknown>,
args: Args
): LogEntryDraft[] =>
orderLegacyActionLoggerFlush(
resolveGeneralAction(resolver, context, { now: new Date('2026-08-23T00:00:00.000Z'), schedule }, args).logs
);
const projectLogs = (logs: readonly LogEntryDraft[]) =>
logs.map((log) => [
log.scope,
log.category,
log.generalId ?? null,
log.nationId ?? null,
log.format,
log.legacyFlushGroup ?? 0,
log.text,
]);
const expectedActorLoggerFlush = (params: {
actorId?: number;
nationId?: number;
generalHistory: string;
generalAction: string;
nationHistory: string;
globalHistory: string;
globalSummary: string;
}) => [
[LogScope.GENERAL, LogCategory.HISTORY, params.actorId ?? 1, null, LogFormat.YEAR_MONTH, 0, params.generalHistory],
[LogScope.GENERAL, LogCategory.ACTION, params.actorId ?? 1, null, LogFormat.MONTH, 0, params.generalAction],
[LogScope.NATION, LogCategory.HISTORY, null, params.nationId ?? 1, LogFormat.YEAR_MONTH, 0, params.nationHistory],
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0, params.globalHistory],
[LogScope.SYSTEM, LogCategory.SUMMARY, null, null, LogFormat.MONTH, 0, params.globalSummary],
];
describe('nation capital command Ref ActionLogger parity', () => {
it('che_천도', () => {
const general = makeGeneral(1);
const nation = makeNation();
const capitalCity = makeCity(1, '허창', 1);
const destCity = makeCity(2, '낙양', 1);
const logs = resolveLogs(
new MoveCapitalAction(ENV),
{ general, city: capitalCity, nation, destCity, map, nationCities: [capitalCity, destCity], rng },
{ destCityID: destCity.id }
);
expect(projectLogs(logs)).toEqual(
expectedActorLoggerFlush({
generalHistory: '<G><b>낙양</b></>으로 <M>천도</>명령',
generalAction: '<G><b>낙양</b></>으로 천도했습니다.',
nationHistory: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>천도</> 명령',
globalHistory: '<S><b>【천도】</b></><D><b>위</b></>가 <G><b>낙양</b></>으로 <M>천도</>하였습니다.',
globalSummary: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>천도</>를 명령하였습니다.',
})
);
});
it.each([
{
action: '증축',
resolver: new ExpandCityAction(ENV),
globalHistoryPrefix: '<C><b>【증축】</b></>',
},
{
action: '감축',
resolver: new ReduceCityAction(ENV),
globalHistoryPrefix: '<M><b>【감축】</b></>',
},
])('che_$action', ({ action, resolver, globalHistoryPrefix }) => {
const general = makeGeneral(1);
const nation = makeNation();
const capitalCity = makeCity(1, '낙양', 1);
const logs = resolveLogs(resolver, { general, city: capitalCity, nation, capitalCity, rng }, {});
expect(projectLogs(logs)).toEqual(
expectedActorLoggerFlush({
generalHistory: `<G><b>낙양</b></>을 <M>${action}</>`,
generalAction: `<G><b>낙양</b></>을 ${action}했습니다.`,
nationHistory: `<Y>운영자</>가 <G><b>낙양</b></>을 <M>${action}</>`,
globalHistory: `${globalHistoryPrefix}<D><b>위</b></>가 <G><b>낙양</b></>을 <M>${action}</>하였습니다.`,
globalSummary: `<Y>운영자</>가 <G><b>낙양</b></>을 <M>${action}</>하였습니다.`,
})
);
});
it('che_무작위수도이전', () => {
const general = makeGeneral(1);
const follower = makeGeneral(2, '부하');
const nation = makeNation();
const capitalCity = makeCity(1, '허창', 1);
const destCity = makeCity(2, '낙양', 0);
const logs = resolveLogs(
new RandomMoveCapitalAction(),
{
general,
city: capitalCity,
nation,
neutralCandidateCities: [destCity],
nationGenerals: [general, follower],
oldCapitalCity: capitalCity,
rng,
},
{}
);
expect(projectLogs(logs)).toEqual([
[
LogScope.GENERAL,
LogCategory.ACTION,
follower.id,
null,
LogFormat.PLAIN,
-1,
'국가 수도를 <G><b>낙양</b></>으로 옮겼습니다.',
],
...expectedActorLoggerFlush({
generalHistory: '<G><b>낙양</b></>으로 <M>무작위 수도 이전</>',
generalAction: '<G><b>낙양</b></>으로 국가를 옮겼습니다.',
nationHistory: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>무작위 수도 이전</>',
globalHistory:
'<S><b>【무작위 수도 이전】</b></><D><b>위</b></>가 <G><b>낙양</b></>으로 <M>수도 이전</>하였습니다.',
globalSummary: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>수도 이전</>하였습니다.',
}),
]);
});
it('che_초토화', () => {
const general = makeGeneral(1);
const follower = makeGeneral(2, '부하');
const nation = makeNation();
const capitalCity = makeCity(1, '허창', 1);
const destCity = makeCity(2, '낙양', 1);
const logs = resolveLogs(
new ScorchedEarthAction(),
{
general,
city: capitalCity,
nation,
destCity,
destNation: nation,
friendlyGenerals: [general, follower],
rng,
},
{ destCityId: destCity.id }
);
expect(projectLogs(logs)).toEqual(
expectedActorLoggerFlush({
generalHistory: '<G><b>낙양</b></>을 <M>초토화</> 명령',
generalAction: '<G><b>낙양</b></>을 초토화했습니다.',
nationHistory: '<Y>운영자</>가 <G><b>낙양</b></>을 <M>초토화</> 명령',
globalHistory: '<S><b>【초토화】</b></><D><b>위</b></>가 <G><b>낙양</b></>을 <M>초토화</>하였습니다.',
globalSummary: '<Y>운영자</>가 <G><b>낙양</b></>을 <M>초토화</>하였습니다.',
})
);
});
});
@@ -0,0 +1,267 @@
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '../../../src/domain/entities.js';
import { resolveGeneralAction, type TurnScheduleContext } from '../../../src/actions/engine.js';
import { ActionDefinition as TroopKickAction } from '../../../src/actions/turn/nation/che_부대탈퇴지시.js';
import { ActionDefinition as PopulationMoveAction } from '../../../src/actions/turn/nation/cr_인구이동.js';
import { ActionResolver as MobilizePeopleAction } from '../../../src/actions/turn/nation/che_백성동원.js';
import {
ActionResolver as VolunteerRecruitAction,
type VolunteerRecruitEnvironment,
} from '../../../src/actions/turn/nation/che_의병모집.js';
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import { finalizeLogEntry } from '../../../src/logging/entries.js';
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js';
const scheduleContext: TurnScheduleContext = {
now: new Date('2026-08-23T00:00:00.000Z'),
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
};
const makeGeneral = (overrides: Partial<General> = {}): General =>
({
id: 1,
name: '군주',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
officerLevel: 12,
experience: 1_000,
dedication: 1_000,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 1,
train: 100,
atmos: 100,
injury: 0,
age: 30,
stats: { leadership: 70, strength: 60, intelligence: 50 },
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {},
...overrides,
}) as General;
const makeNation = (overrides: Partial<Nation> = {}): Nation => ({
id: 1,
name: '검증국',
color: '#ff0000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 10_000,
rice: 10_000,
power: 0,
level: 1,
typeCode: 'che_def',
meta: { gennum: 2, strategic_cmd_limit: 0 },
...overrides,
});
const makeCity = (overrides: Partial<City> = {}): City => ({
id: 1,
name: '성도',
nationId: 1,
level: 1,
state: 0,
population: 50_000,
populationMax: 100_000,
agriculture: 500,
agricultureMax: 1_000,
commerce: 500,
commerceMax: 1_000,
security: 500,
securityMax: 1_000,
defence: 300,
defenceMax: 1_000,
wall: 300,
wallMax: 1_000,
supplyState: 1,
frontState: 0,
meta: {},
...overrides,
});
const makeRng = (): RandUtil => new RandUtil(new ConstantRNG(0));
const expectMonthlyPersistence = (entry: LogEntryDraft, wrongFormat: LogFormat): void => {
expect(entry.format).toBe(LogFormat.MONTH);
const persisted = finalizeLogEntry(entry, { year: 186, month: 9 });
const mutant = finalizeLogEntry({ ...entry, format: wrongFormat }, { year: 186, month: 9 });
expect(persisted?.text).toMatch(/^<C><\/>9:/u);
expect(mutant?.text).not.toBe(persisted?.text);
expect(mutant?.text).not.toMatch(/^<C><\/>9:/u);
};
describe('nation command Ref log parity', () => {
it('flushes che_부대탈퇴지시 actor then target with the Ref monthly format', () => {
const actor = makeGeneral();
const target = makeGeneral({ id: 2, name: '부대원', troopId: 3 });
const resolution = resolveGeneralAction(
new TroopKickAction(),
{
general: actor,
nation: makeNation(),
city: makeCity(),
destGeneral: target,
rng: makeRng(),
} as never,
scheduleContext,
{ destGeneralId: target.id }
);
const logs = orderLegacyActionLoggerFlush(resolution.logs);
expect(logs).toHaveLength(2);
expect(logs.map((entry) => entry.generalId)).toEqual([actor.id, target.id]);
expect(logs.map((entry) => entry.text)).toEqual([
'<Y>부대원</>에게 부대 탈퇴를 지시했습니다.',
'<Y>군주</>에게 부대 탈퇴를 지시 받았습니다.',
]);
expect(logs[1]?.legacyFlushGroup).toBe(1);
expectMonthlyPersistence(logs[1]!, LogFormat.PLAIN);
});
it('keeps cr_인구이동 population text ungrouped like PHP integer interpolation', () => {
const actor = makeGeneral();
const source = makeCity();
const destination = makeCity({ id: 2, name: '락양', population: 10_000 });
const resolution = resolveGeneralAction(
new PopulationMoveAction({ develCost: 100, baseGold: 1_000, baseRice: 1_000 } as TurnCommandEnv),
{
general: actor,
nation: makeNation(),
city: source,
destCity: destination,
destNation: makeNation(),
rng: makeRng(),
} as never,
scheduleContext,
{ destCityId: destination.id, amount: 10_000 }
);
const [entry] = resolution.logs;
expect(entry).toMatchObject({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: actor.id,
format: LogFormat.MONTH,
text: '<G><b>락양</b></>으로 인구 <C>10000</>명을 옮겼습니다.',
});
expect(entry?.text).not.toBe('<G><b>락양</b></>으로 인구 <C>10,000</>명을 옮겼습니다.');
});
it('keeps che_백성동원 notification and history streams distinct in Ref flush order', () => {
const actor = makeGeneral();
const target = makeGeneral({ id: 2, name: '동료' });
const nation = makeNation();
const destination = makeCity();
const resolution = resolveGeneralAction(
new MobilizePeopleAction([], 10),
{
general: actor,
nation,
city: makeCity(),
destCity: destination,
friendlyGenerals: [actor, target],
rng: makeRng(),
} as never,
scheduleContext,
{ destCityId: destination.id }
);
const logs = orderLegacyActionLoggerFlush(resolution.logs);
expect(logs.map((entry) => [entry.scope, entry.category, entry.generalId, entry.nationId])).toEqual([
[LogScope.GENERAL, LogCategory.ACTION, target.id, undefined],
[LogScope.GENERAL, LogCategory.HISTORY, actor.id, undefined],
[LogScope.GENERAL, LogCategory.ACTION, actor.id, undefined],
[LogScope.NATION, LogCategory.HISTORY, undefined, nation.id],
]);
expect(logs[0]).toMatchObject({
text: '<Y>군주</>가 <G><b>성도</b></>에 <M>백성동원</>을 하였습니다.',
format: LogFormat.PLAIN,
legacyFlushGroup: -1,
});
expect(logs[3]).toMatchObject({
text: '<Y>군주</>가 <G><b>성도</b></>에 <M>백성동원</>을 발동',
format: LogFormat.YEAR_MONTH,
});
expect(logs[3]?.text).not.toBe(logs[0]?.text);
});
it('preserves che_의병모집 actor history markup and Ref flush order', () => {
const actor = makeGeneral();
const target = makeGeneral({ id: 2, name: '동료' });
const nation = makeNation();
const environment: VolunteerRecruitEnvironment = {
openingPartYear: 0,
initialNationGenLimit: 10,
defaultNpcGold: 1_000,
defaultNpcRice: 1_000,
defaultCrewTypeId: 1,
defaultSpecialDomestic: null,
defaultSpecialWar: null,
createCountBase: 0,
createCountDivisor: 8,
};
const resolution = resolveGeneralAction(
new VolunteerRecruitAction([], environment),
{
general: actor,
nation,
city: makeCity(),
rng: makeRng(),
currentYear: 190,
currentMonth: 1,
startYear: 180,
centennialRules: {
defaultStatMin: 15,
defaultStatMax: 80,
defaultStatTotal: 165,
maxStatLevel: 255,
defaultSpecialDomestic: null,
dexLimit: 1_000_000,
},
centennialNpcDexTargetRatio: 0.4,
averageNationGeneralCount: 0,
nationAverageStats: { leadership: 50, strength: 50, intelligence: 50 },
nationAverageExperience: 0,
nationAverageDedication: 0,
nationAverageDex: [100, 100, 100, 100, 100],
friendlyGenerals: [actor, target],
createGeneralId: () => 3,
turnTermSeconds: 60,
turnTimeBase: new Date('0190-01-01T00:00:00.000Z'),
ticksPerSecond: 1,
} as never,
scheduleContext,
{}
);
const logs = orderLegacyActionLoggerFlush(resolution.logs);
expect(logs.map((entry) => [entry.scope, entry.category, entry.generalId, entry.nationId])).toEqual([
[LogScope.GENERAL, LogCategory.ACTION, target.id, undefined],
[LogScope.GENERAL, LogCategory.HISTORY, actor.id, undefined],
[LogScope.GENERAL, LogCategory.ACTION, actor.id, undefined],
[LogScope.NATION, LogCategory.HISTORY, undefined, nation.id],
]);
expect(logs[0]).toMatchObject({
text: '<Y>군주</>가 <M>의병모집</>을 발동하였습니다.',
format: LogFormat.PLAIN,
legacyFlushGroup: -1,
});
expect(logs[1]).toMatchObject({
text: '<M>의병모집</>을 발동',
format: LogFormat.YEAR_MONTH,
});
expect(logs[1]?.text).not.toBe('의병모집 발동');
});
});
@@ -0,0 +1,402 @@
import { describe, expect, it } from 'vitest';
import type { RandomGenerator } from '@sammo-ts/common';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '../../../src/actions/engine.js';
import type { City, General, Nation } from '../../../src/domain/entities.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js';
import {
ActionResolver as DegradeRelationsResolver,
type DegradeRelationsResolveContext,
} from '../../../src/actions/turn/nation/che_이호경식.js';
import { ActionResolver as RaidResolver, type RaidResolveContext } from '../../../src/actions/turn/nation/che_급습.js';
import {
ActionResolver as LastStandResolver,
type DesperateFightResolveContext,
} from '../../../src/actions/turn/nation/che_필사즉생.js';
import {
ActionResolver as DeceptionResolver,
type DeceptionResolveContext,
} from '../../../src/actions/turn/nation/che_허보.js';
import {
ActionResolver as CounterStrategyResolver,
type CounterStrategyResolveContext,
} from '../../../src/actions/turn/nation/che_피장파장.js';
const rng: RandomGenerator = {
nextFloat1: () => 0.5,
nextBool: () => false,
nextInt: (minInclusive) => minInclusive,
};
const buildGeneral = (id: number, nationId: number, cityId: number, name: string): General => ({
id,
name,
nationId,
cityId,
troopId: 0,
stats: { leadership: 80, strength: 70, intelligence: 60 },
experience: 100,
dedication: 100,
officerLevel: id === 1 ? 12 : 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 1,
train: 80,
atmos: 80,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
});
const buildNation = (id: number, name: string, chiefGeneralId: number | null): Nation => ({
id,
name,
color: '#000000',
capitalCityId: id * 100,
chiefGeneralId,
gold: 10_000,
rice: 10_000,
power: 100,
level: 1,
typeCode: 'test',
meta: { gennum: 3, strategic_cmd_limit: 0 },
});
const buildCity = (id: number, nationId: number, name: string): City => ({
id,
name,
nationId,
level: 1,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 500,
agricultureMax: 1_000,
commerce: 500,
commerceMax: 1_000,
security: 500,
securityMax: 1_000,
supplyState: 1,
frontState: 0,
defence: 300,
defenceMax: 1_000,
wall: 300,
wallMax: 1_000,
meta: {},
});
const buildFixture = () => {
const actor = buildGeneral(1, 10, 100, '가람');
const friendlyTargets = [buildGeneral(2, 10, 100, '아군일'), buildGeneral(3, 10, 100, '아군이')];
const destTargets = [buildGeneral(4, 20, 200, '적군일'), buildGeneral(5, 20, 200, '적군이')];
return {
actor,
friendlyTargets,
destTargets,
nation: buildNation(10, '촉', actor.id),
destNation: buildNation(20, '위', destTargets[0]!.id),
destCity: buildCity(200, 20, '업'),
safeDestCity: buildCity(201, 20, '평원'),
};
};
const createActorLogSink = (actorId: number, logs: LogEntryDraft[]): GeneralActionResolveContext['addLog'] => {
return (text, options = {}) => {
const entry: LogEntryDraft = {
scope: options.scope ?? LogScope.GENERAL,
category: options.category ?? LogCategory.ACTION,
text,
format: options.format ?? LogFormat.MONTH,
...options,
};
if (entry.scope === LogScope.GENERAL && entry.generalId === undefined) {
entry.generalId = actorId;
}
logs.push(entry);
};
};
const collectLogs = (
actorId: number,
resolve: (addLog: GeneralActionResolveContext['addLog']) => GeneralActionOutcome
): LogEntryDraft[] => {
const logs: LogEntryDraft[] = [];
const outcome = resolve(createActorLogSink(actorId, logs));
for (const effect of outcome.effects) {
if (effect.type === 'log') {
logs.push(effect.entry);
}
}
return logs;
};
interface RefFlushExpectation {
actorId: number;
sourceNationId: number;
destNationId?: number;
friendlyTargetIds: number[];
destTargetIds: number[];
friendlyText: string;
destText?: string;
destNationText?: string;
destNationFormat?: LogFormat;
actorHistoryText: string;
actorActionText: string;
sourceNationText: string;
}
const projectLog = (entry: LogEntryDraft) => ({
scope: entry.scope,
category: entry.category,
owner:
entry.generalId !== undefined
? `general:${entry.generalId}`
: entry.nationId !== undefined
? `nation:${entry.nationId}`
: 'none',
text: entry.text,
format: entry.format,
group: entry.legacyFlushGroup ?? 0,
});
const expectRefFlush = (logs: LogEntryDraft[], expected: RefFlushExpectation): void => {
const internalEpochCount =
expected.friendlyTargetIds.length + expected.destTargetIds.length + (expected.destNationText ? 1 : 0);
const firstInternalGroup = -internalEpochCount;
const expectedLogs = [
...expected.friendlyTargetIds.map((generalId, index) => ({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
owner: `general:${generalId}`,
text: expected.friendlyText,
format: LogFormat.PLAIN,
group: firstInternalGroup + index,
})),
...expected.destTargetIds.map((generalId, index) => ({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
owner: `general:${generalId}`,
text: expected.destText,
format: LogFormat.PLAIN,
group: firstInternalGroup + expected.friendlyTargetIds.length + index,
})),
...(expected.destNationText
? [
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
owner: `nation:${expected.destNationId}`,
text: expected.destNationText,
format: expected.destNationFormat,
group: -1,
},
]
: []),
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
owner: `general:${expected.actorId}`,
text: expected.actorHistoryText,
format: LogFormat.YEAR_MONTH,
group: 0,
},
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
owner: `general:${expected.actorId}`,
text: expected.actorActionText,
format: LogFormat.MONTH,
group: 0,
},
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
owner: `nation:${expected.sourceNationId}`,
text: expected.sourceNationText,
format: LogFormat.YEAR_MONTH,
group: 0,
},
];
expect(orderLegacyActionLoggerFlush(logs).map(projectLog)).toEqual(expectedLogs);
};
describe('nation deception command Ref log parity', () => {
it('preserves che_이호경식 logger epochs, texts, categories, and formats', () => {
const fixture = buildFixture();
const logs = collectLogs(fixture.actor.id, (addLog) =>
new DegradeRelationsResolver([]).resolve(
{
general: fixture.actor,
nation: fixture.nation,
destNation: fixture.destNation,
diplomacy: { state: 0, term: 3 },
reverseDiplomacy: { state: 0, term: 3 },
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
destNationGenerals: fixture.destTargets,
rng,
addLog,
} satisfies DegradeRelationsResolveContext,
{ destNationId: fixture.destNation.id }
)
);
expectRefFlush(logs, {
actorId: fixture.actor.id,
sourceNationId: fixture.nation.id,
destNationId: fixture.destNation.id,
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
destTargetIds: fixture.destTargets.map((general) => general.id),
friendlyText: '<Y>가람</>이 <G><b>위</b></>에 <M>이호경식</>을 발동하였습니다.',
destText: '<D><b>촉</b></>이 아국에 <M>이호경식</>을 발동하였습니다.',
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국에 <M>이호경식</>을 발동',
destNationFormat: LogFormat.YEAR_MONTH,
actorHistoryText: '<D><b>위</b></>에 <M>이호경식</>을 발동',
actorActionText: '이호경식 발동!',
sourceNationText: '<Y>가람</>이 <D><b>위</b></>에 <M>이호경식</>을 발동',
});
});
it('preserves che_급습 logger epochs, texts, categories, and formats', () => {
const fixture = buildFixture();
const logs = collectLogs(fixture.actor.id, (addLog) =>
new RaidResolver([]).resolve(
{
general: fixture.actor,
nation: fixture.nation,
destNation: fixture.destNation,
diplomacy: { state: 1, term: 18 },
reverseDiplomacy: { state: 1, term: 18 },
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
destNationGenerals: fixture.destTargets,
rng,
addLog,
} satisfies RaidResolveContext,
{ destNationId: fixture.destNation.id }
)
);
expectRefFlush(logs, {
actorId: fixture.actor.id,
sourceNationId: fixture.nation.id,
destNationId: fixture.destNation.id,
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
destTargetIds: fixture.destTargets.map((general) => general.id),
friendlyText: '<Y>가람</>이 <G><b>위</b></>에 <M>급습</>을 발동하였습니다.',
destText: '아국에 <M>급습</>이 발동되었습니다.',
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국에 <M>급습</>을 발동',
destNationFormat: LogFormat.YEAR_MONTH,
actorHistoryText: '<D><b>위</b></>에 <M>급습</>을 발동',
actorActionText: '급습 발동!',
sourceNationText: '<Y>가람</>이 <D><b>위</b></>에 <M>급습</>을 발동',
});
});
it('preserves che_필사즉생 target applyDB epochs before the actor logger', () => {
const fixture = buildFixture();
const logs = collectLogs(fixture.actor.id, (addLog) =>
new LastStandResolver([]).resolve(
{
general: fixture.actor,
nation: fixture.nation,
nationGenerals: [fixture.actor, ...fixture.friendlyTargets],
rng,
addLog,
} satisfies DesperateFightResolveContext,
{}
)
);
expectRefFlush(logs, {
actorId: fixture.actor.id,
sourceNationId: fixture.nation.id,
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
destTargetIds: [],
friendlyText: '<Y>가람</>이 <M>필사즉생</>을 발동하였습니다.',
actorHistoryText: '<M>필사즉생</>을 발동',
actorActionText: '필사즉생 발동!',
sourceNationText: '<Y>가람</>이 <M>필사즉생</>을 발동',
});
});
it('preserves che_허보 per-general applyDB epochs and plain target-nation history', () => {
const fixture = buildFixture();
const deceptionRng: RandomGenerator = { ...rng, nextInt: () => 1 };
const logs = collectLogs(fixture.actor.id, (addLog) =>
new DeceptionResolver([]).resolve(
{
general: fixture.actor,
nation: fixture.nation,
destNation: fixture.destNation,
destCity: fixture.destCity,
destCityGenerals: fixture.destTargets,
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
destNationSupplyCities: [fixture.destCity, fixture.safeDestCity],
rng: deceptionRng,
addLog,
} satisfies DeceptionResolveContext,
{ destCityId: fixture.destCity.id }
)
);
expectRefFlush(logs, {
actorId: fixture.actor.id,
sourceNationId: fixture.nation.id,
destNationId: fixture.destNation.id,
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
destTargetIds: fixture.destTargets.map((general) => general.id),
friendlyText: '<Y>가람</>이 <G><b>업</b></>에 <M>허보</>를 발동하였습니다.',
destText: '상대의 <M>허보</>에 당했다!',
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국의 <G><b>업</b></>에 <M>허보</>를 발동',
destNationFormat: LogFormat.PLAIN,
actorHistoryText: '<G><b>업</b></>에 <M>허보</>를 발동',
actorActionText: '허보 발동!',
sourceNationText: '<Y>가람</>이 <G><b>업</b></>에 <M>허보</>를 발동',
});
});
it('preserves che_피장파장 logger epochs and year-month target-nation history', () => {
const fixture = buildFixture();
const logs = collectLogs(fixture.actor.id, (addLog) =>
new CounterStrategyResolver([]).resolve(
{
general: fixture.actor,
nation: fixture.nation,
destNation: fixture.destNation,
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
destNationGenerals: fixture.destTargets,
currentYearMonth: 2_231,
rng,
addLog,
} satisfies CounterStrategyResolveContext,
{ destNationId: fixture.destNation.id, commandType: 'che_허보' }
)
);
expectRefFlush(logs, {
actorId: fixture.actor.id,
sourceNationId: fixture.nation.id,
destNationId: fixture.destNation.id,
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
destTargetIds: fixture.destTargets.map((general) => general.id),
friendlyText: '<Y>가람</>이 <G><b>위</b></>에 <G><b>허보</b></> 전략의 <M>피장파장</>을 발동하였습니다.',
destText: '아국에 <G><b>허보</b></> 전략의 <M>피장파장</>이 발동되었습니다.',
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국에 <G><b>허보</b></> <M>피장파장</>을 발동',
destNationFormat: LogFormat.YEAR_MONTH,
actorHistoryText: '<D><b>위</b></>에 <G><b>허보</b></> <M>피장파장</>을 발동',
actorActionText: '<G><b>허보</b></> 전략의 피장파장 발동!',
sourceNationText: '<Y>가람</>이 <D><b>위</b></>에 <G><b>허보</b></> <M>피장파장</>을 발동',
});
});
});
@@ -3,6 +3,7 @@ import type { City, General, Nation } from '../../../src/domain/entities.js';
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
import { evaluateConstraints } from '../../../src/constraints/evaluate.js';
import { resolveGeneralAction } from '../../../src/actions/engine.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import type { MapDefinition } from '../../../src/world/types.js';
import type { TurnSchedule } from '../../../src/turn/calendar.js';
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
@@ -355,6 +356,8 @@ describe('Nation Missing Actions', () => {
expect(definition.parseArgs({ destNationId: 2, amountList: [-1, 10] })).toBeNull();
const general = buildGeneral(1, 1, 1);
const sourceChief = buildGeneral(2, 1, 1, 'SourceChief');
const destChief = buildGeneral(3, 2, 2, 'DestChief');
const nation = { ...buildNation(1), gold: 1000, rice: 1000 };
const destNation = { ...buildNation(2), gold: 100, rice: 100 };
const resolution = resolveGeneralAction(
@@ -364,8 +367,8 @@ describe('Nation Missing Actions', () => {
city: buildCity(1, 1),
nation,
destNation,
friendlyChiefs: [general],
destNationChiefs: [],
friendlyChiefs: [general, sourceChief],
destNationChiefs: [destChief],
rng: {} as any,
addLog: () => {},
} as any,
@@ -388,6 +391,16 @@ describe('Nation Missing Actions', () => {
}),
}),
});
const orderedLogs = orderLegacyActionLoggerFlush(resolution.logs);
expect(orderedLogs.map((log) => log.legacyFlushGroup ?? 0)).toEqual([-1, -1, 0, 0, 0, 0, 0, 1]);
expect(orderedLogs.slice(0, 2).map((log) => log.generalId)).toEqual([sourceChief.id, destChief.id]);
expect(orderedLogs.at(-1)).toEqual(
expect.objectContaining({
nationId: destNation.id,
legacyFlushGroup: 1,
})
);
});
it('che_초토화: blocks when diplomacy limit exists', () => {
@@ -109,16 +109,21 @@ describe('nation volunteer recruitment lifespan', () => {
return;
}
const created = createdEffect.general as General & { bornYear?: number; deadYear?: number };
const created = createdEffect.general as General & { affinity?: number; bornYear?: number; deadYear?: number };
expect(created).toMatchObject({
name: 'ⓖ장수',
affinity: 1,
bornYear: 170,
deadYear: 200,
experience: 2_000,
dedication: 2_000,
meta: {
affinity: 1,
birthYear: 170,
deathYear: 200,
npc_org: 4,
explevel: 0,
dedlevel: 1,
},
});
});
@@ -130,6 +130,7 @@ describe('talent scout scenario general pool', () => {
imageServer: 1,
role: { specialDomestic: null, specialWar: null },
meta: {
npc_org: 3,
dex1: 12,
dex2: 24,
dex3: 36,
@@ -73,6 +73,7 @@ describe('instant diplomatic response parity', () => {
const logs = result.effects.filter((effect) => effect.type === 'log');
expect(logs).toHaveLength(4);
expect(logs.map((effect) => effect.entry.generalId)).toEqual([11, 11, 22, 22]);
expect(logs.map((effect) => effect.entry.legacyFlushGroup ?? 0)).toEqual([0, 0, 1, 1]);
});
it('counts an accepted pact down through every month and returns both directions to trade', () => {
@@ -123,6 +124,7 @@ describe('instant diplomatic response parity', () => {
expect(result.actionKey).toBe('che_불가침파기수락');
expect(result.refreshFront).toBe(false);
expect(logs).toHaveLength(6);
expect(logs.map((effect) => effect.entry.legacyFlushGroup ?? 0)).toEqual([0, 0, 0, 0, 1, 1]);
expect(logs).toContainEqual(
expect.objectContaining({
entry: expect.objectContaining({
@@ -135,6 +137,7 @@ describe('instant diplomatic response parity', () => {
it('creates both nation histories and requests front refresh for stop-war', () => {
const result = resolveInstantDiplomacyResponse(context, { action: 'stopWar' });
const logs = result.effects.filter((effect) => effect.type === 'log');
const nationLogIds = result.effects.flatMap((effect) =>
effect.type === 'log' && effect.entry.scope === LogScope.NATION ? [effect.entry.nationId] : []
);
@@ -142,6 +145,7 @@ describe('instant diplomatic response parity', () => {
expect(result.actionKey).toBe('che_종전수락');
expect(result.refreshFront).toBe(true);
expect(nationLogIds).toEqual([1, 2]);
expect(logs.map((effect) => effect.entry.legacyFlushGroup ?? 0)).toEqual([0, 0, 0, 0, 0, 1, 1, 1]);
});
it('recomputes only requested nation fronts with legacy priority', () => {
+64 -2
View File
@@ -7,6 +7,9 @@ import type { DispatchResolveContext } from '../src/actions/turn/general/che_출
import type { TurnSchedule } from '../src/turn/calendar.js';
import type { WarAftermathConfig, WarEngineConfig } from '../src/war/types.js';
import type { UnitSetDefinition } from '../src/world/types.js';
import type { ItemModule } from '../src/items/types.js';
import { orderLegacyActionLoggerFlush } from '../src/logging/actionLogger.js';
import { LogCategory, LogFormat, LogScope } from '../src/logging/types.js';
const buildGeneral = (id: number, nationId: number, cityId: number): General => ({
id,
@@ -175,6 +178,19 @@ const schedule: TurnSchedule = {
entries: [{ startMinute: 0, tickMinutes: 60 }],
};
const uniqueItem: ItemModule = {
key: 'test_unique',
name: '테스트 유니크',
rawName: '테스트 유니크',
info: 'test',
slot: 'item',
cost: null,
buyable: false,
consumable: false,
reqSecu: 0,
unique: true,
};
describe('che_출병', () => {
it('runs war battle and emits patches/logs', () => {
const attackerNation = buildNation(1);
@@ -183,14 +199,18 @@ describe('che_출병', () => {
const defenderCity = buildCity(2, defenderNation.id);
const neutralCity = buildCity(3, 0);
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
attacker.officerLevel = 12;
attacker.turnTime = new Date('2000-01-01T00:00:00Z');
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
defender.officerLevel = 12;
defender.crew = 0;
defenderCity.defence = 0;
defenderCity.wall = 0;
const definition = new ActionDefinition();
const context: Omit<DispatchResolveContext, 'addLog'> = {
const context: Omit<DispatchResolveContext, 'addLog'> & {
uniqueLottery: () => ItemModule;
} = {
general: attacker,
city: attackerCity,
nation: attackerNation,
@@ -246,9 +266,12 @@ describe('che_출병', () => {
month: 1,
startYear: 180,
},
seedBase: 'test-seed',
seedBase: 'test-seed-2',
warConfig,
aftermathConfig,
messageTime: new Date('2000-01-01T00:00:00.000Z'),
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
uniqueLottery: () => uniqueItem,
};
const resolution = resolveGeneralAction(
definition,
@@ -278,6 +301,44 @@ describe('che_출병', () => {
effect.destNationId === defenderNation.id
)
).toBe(true);
expect(resolution.effects.find((effect) => effect.type === 'message:add')).toEqual({
type: 'message:add',
draft: expect.objectContaining({
msgType: 'private',
dest: expect.objectContaining({
generalId: defender.id,
nationId: 0,
nationName: '재야',
icon: 'https://ref.example/image/icons/default.jpg',
}),
text: 'Nation1로 망명 권유 서신',
option: { action: 'scout' },
sendDestOnly: true,
}),
});
const uniqueGroups = new Set(resolution.postProgressionLogs.map((log) => log.legacyFlushGroup));
expect(uniqueGroups.size).toBe(1);
const [internalFinalGroup] = uniqueGroups;
expect(internalFinalGroup).toBeTypeOf('number');
expect(internalFinalGroup).toBeLessThan(0);
expect(resolution.logs.find((log) => log.text.includes('정복으로 금'))?.legacyFlushGroup).toBe(
internalFinalGroup
);
const outerProgressionLog = {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
generalId: attacker.id,
text: 'outer progression',
} as const;
const ordered = orderLegacyActionLoggerFlush([
...resolution.logs,
...resolution.postProgressionLogs,
outerProgressionLog,
]);
expect(ordered.at(-1)?.text).toBe(outerProgressionLog.text);
});
it('orders equal-priority defender inputs by general number before the stable battle sort', () => {
@@ -347,6 +408,7 @@ describe('che_출병', () => {
seedBase: 'route-layer-seed',
warConfig,
aftermathConfig,
messageTime: new Date('2000-01-01T00:00:00.000Z'),
};
const resolution = resolveGeneralAction(
+91 -1
View File
@@ -1,6 +1,96 @@
import { describe, expect, it } from 'vitest';
import { finalizeLogEntry, LogCategory, LogFormat, LogScope } from '../src/index.js';
import {
ActionLogger,
finalizeLogEntry,
LogCategory,
LogFormat,
LogScope,
orderLegacyActionLoggerFlush,
} from '../src/index.js';
describe('ActionLogger', () => {
it('flushes Ref category buffers in physical persistence order', () => {
const logger = new ActionLogger({ generalId: 7, nationId: 3 });
logger.pushGlobalActionLog('global action');
logger.pushGlobalHistoryLog('global history');
logger.pushNationHistoryLog('nation history');
logger.pushGeneralActionLog(['first action', 'second action']);
logger.pushGeneralHistoryLog('general history');
expect(logger.flush().map((entry) => entry.text)).toEqual([
'general history',
'first action',
'second action',
'nation history',
'global history',
'global action',
]);
});
it('keeps a separately flushed logger after every category of the earlier logger', () => {
expect(
orderLegacyActionLoggerFlush([
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: 1,
text: 'actor nation history',
},
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: 2,
text: 'destination nation history',
legacyFlushGroup: 1,
},
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
text: 'global history',
},
]).map((entry) => entry.text)
).toEqual(['actor nation history', 'global history', 'destination nation history']);
});
it('preserves early, actor, and destructor flush stages before applying category buckets', () => {
const ordered = orderLegacyActionLoggerFlush([
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: 1,
text: 'actor nation history',
},
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: 2,
text: 'destructor nation history',
legacyFlushGroup: 1,
},
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: 7,
text: 'early chief action',
legacyFlushGroup: -1,
},
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
text: 'actor global history',
},
]);
expect(ordered.map((entry) => entry.text)).toEqual([
'early chief action',
'actor nation history',
'actor global history',
'destructor nation history',
]);
});
});
describe('finalizeLogEntry', () => {
it('uses an explicit draft year and month for pre-month logs', () => {
+35 -4
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC,
resolveMessageTargetIcon,
sendMessage,
type MessageDraft,
type MessageRecordDraft,
@@ -88,7 +89,7 @@ describe('sendMessage', () => {
expect(store.records[0]!.draft.mailbox).toBe(MESSAGE_MAILBOX_PUBLIC);
});
it('removes diplomacy action from sender copy', async () => {
it('clears the entire actionable diplomacy option from the sender copy', async () => {
const store = new InMemoryMessageStore();
const draft = buildDraft({
msgType: 'diplomacy',
@@ -98,8 +99,38 @@ describe('sendMessage', () => {
await sendMessage(store, draft);
const senderPayload = store.records[1]!.draft.payload.option ?? {};
expect(senderPayload).not.toHaveProperty('action');
expect(senderPayload).toMatchObject({ payload: 1 });
expect(store.records[1]!.draft.payload.option).toBeNull();
});
it('can persist only the receiver copy like Ref Message::send(true)', async () => {
const store = new InMemoryMessageStore();
const draft = buildDraft({ sendDestOnly: true });
const result = await sendMessage(store, draft);
expect(result).toEqual({ receiverId: 1 });
expect(store.records).toHaveLength(1);
expect(store.records[0]!.draft.mailbox).toBe(draft.dest.generalId);
});
});
describe('resolveMessageTargetIcon', () => {
it('uses the product shared origin by default and accepts an explicit differential origin', () => {
expect(resolveMessageTargetIcon()).toBe('https://sam-image.hided.net/icons/default.jpg');
expect(resolveMessageTargetIcon(null, 'https://dev-sam-ref.hided.net/image/icons/')).toBe(
'https://dev-sam-ref.hided.net/image/icons/default.jpg'
);
});
it('keeps a non-default shared picture and legacy user-icon marker visible', () => {
expect(
resolveMessageTargetIcon(
{ picture: '장수/관우.png', imageServer: 0 },
'https://dev-sam-ref.hided.net/image/icons'
)
).toBe('https://dev-sam-ref.hided.net/image/icons/장수/관우.png');
expect(resolveMessageTargetIcon({ picture: 'users/custom.webp', imageServer: 1 })).toBe(
'd_pic/users/custom.webp'
);
});
});
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { ActionDefinition } from '../../src/actions/turn/nation/che_몰수.js';
describe('che_몰수 NPC message icon', () => {
it('uses the target general non-default shared picture for both payload targets', () => {
const actor = {
id: 1,
name: '집행자',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
experience: 0,
dedication: 0,
officerLevel: 12,
role: { personality: null, specialDomestic: null, specialWar: null, items: {} },
injury: 0,
gold: 0,
rice: 0,
crew: 0,
crewTypeId: 1100,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
};
const target = {
...actor,
id: 3,
name: '몰수NPC',
gold: 100,
npcState: 2,
picture: 'npc/custom.png',
imageServer: 0,
};
const definition = new ActionDefinition({
npcSeizureMessageProb: 1,
maxResourceActionAmount: 1_000_000,
} as never);
const result = definition.resolve(
{
general: actor,
nation: { id: 1, name: '아국', color: '#123456', gold: 0, rice: 0 },
destGeneral: target,
messageTime: new Date('0190-01-01T00:00:00.000Z'),
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
rng: {
nextBool: () => true,
nextInt: () => 0,
},
} as never,
{ isGold: true, amount: 100, destGeneralID: target.id }
);
expect(result.effects).toContainEqual(
expect.objectContaining({
type: 'message:add',
draft: expect.objectContaining({
src: expect.objectContaining({ icon: 'https://ref.example/image/icons/npc/custom.png' }),
dest: expect.objectContaining({ icon: 'https://ref.example/image/icons/npc/custom.png' }),
}),
})
);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { ActionResolver } from '../../../src/actions/turn/general/che_등용.js';
import { ActionResolver, actionContextBuilder } from '../../../src/actions/turn/general/che_등용.js';
describe('che_등용 recruitment message', () => {
it('queues the Ref scout prompt with sender and receiver snapshots', () => {
@@ -30,6 +30,23 @@ describe('che_등용 recruitment message', () => {
const sourceNation = { id: 1, name: '위', color: '#ffffff' };
const destinationNation = { id: 2, name: '촉', color: '#000000' };
const messageTime = new Date('0200-01-01T00:10:00.000Z');
const actorTurnTime = new Date('0200-01-01T00:00:00.000Z');
const builtContext = actionContextBuilder(
{
general: { ...general, turnTime: actorTurnTime },
nation: sourceNation,
rng: {},
} as never,
{
gameNow: messageTime,
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
actionArgs: { destGeneralId: destination.id },
worldRef: { getGeneralById: () => destination },
scenarioConfig: { const: {} },
} as never
);
expect(builtContext).toMatchObject({ messageTime });
const result = new ActionResolver().resolve(
{
@@ -54,7 +71,7 @@ describe('che_등용 recruitment message', () => {
nationId: sourceNation.id,
nationName: sourceNation.name,
color: sourceNation.color,
icon: '',
icon: 'https://sam-image.hided.net/icons/default.jpg',
},
dest: {
generalId: destination.id,
@@ -62,13 +79,16 @@ describe('che_등용 recruitment message', () => {
nationId: destinationNation.id,
nationName: destinationNation.name,
color: destinationNation.color,
icon: '',
icon: 'https://sam-image.hided.net/icons/default.jpg',
},
text: '위로 망명 권유 서신',
time: messageTime,
validUntil: new Date('9999-12-31T12:59:59.000Z'),
option: { action: 'scout' },
sendDestOnly: true,
},
});
expect(result.effects.filter((effect) => effect.type === 'log')).toEqual([]);
expect(logs).toHaveLength(1);
});
});
@@ -5,6 +5,9 @@ import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
import { resolveGeneralAction } from '../../../src/actions/engine.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import { LogCategory, LogFormat, LogScope } from '../../../src/logging/types.js';
const MOCK_SCENARIO_BASE = {
title: 'Test',
@@ -224,6 +227,57 @@ describe('che_등용수락', () => {
const { commandSpec } = await import('../../../src/actions/turn/general/che_등용수락.js');
const orderingResolution = resolveGeneralAction(
commandSpec.createDefinition(systemEnv),
{
general: structuredClone(neutralGen),
city: structuredClone(world.getCity(neutralGen.cityId)),
nation: null,
destNation: structuredClone(nation2),
destGeneral: structuredClone(recruiterGen),
rng: {} as any,
addLog: () => {},
} as any,
{
now: new Date('2026-08-23T00:00:00.000Z'),
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
},
{ destNationId: 2, destGeneralId: 2 }
);
const recruiterLogs = orderLegacyActionLoggerFlush(orderingResolution.logs).filter(
(log) => log.generalId === recruiterGen.id
);
expect(recruiterLogs.map((log) => [log.category, log.legacyFlushGroup])).toEqual([
[LogCategory.HISTORY, 1],
[LogCategory.ACTION, 1],
[LogCategory.ACTION, 1],
[LogCategory.ACTION, 1],
]);
expect(recruiterLogs.map((log) => log.text)).toEqual([
expect.stringContaining('등용에 성공'),
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('등용에 성공했습니다.'),
]);
expect(recruiterLogs.map((log) => log.format)).toEqual([
LogFormat.YEAR_MONTH,
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
]);
expect(
orderingResolution.logs.find(
(log) =>
log.scope === LogScope.GENERAL &&
log.category === LogCategory.ACTION &&
log.text.includes('망명하여 수도로')
)?.format
).toBe(LogFormat.MONTH);
expect(
orderingResolution.logs.find((log) => log.scope === LogScope.SYSTEM && log.category === LogCategory.SUMMARY)
?.format
).toBe(LogFormat.MONTH);
await runner.runTurn([
{
generalId: neutralGen.id,
@@ -3,6 +3,8 @@ import type { General, Nation } from '../../../src/domain/entities.js';
import { buildScenarioBootstrap } from '../../../src/world/bootstrap.js';
import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
import type { MapDefinition } from '../../../src/world/types.js';
import { resolveGeneralAction } from '../../../src/actions/engine.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
const MOCK_SCENARIO_BASE = {
title: 'Test',
@@ -56,6 +58,49 @@ const LINEAR_MAP: MapDefinition = {
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
};
const buildGeneral = (id: number, name: string): General => ({
id,
name,
nationId: 1,
cityId: 101,
troopId: 0,
stats: { leadership: 50, strength: 50, intelligence: 50 },
experience: 0,
dedication: 0,
officerLevel: 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 10,
age: 20,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
});
const buildNation = (level = 1): Nation => ({
id: 1,
name: 'MyNation',
color: '#000',
capitalCityId: 101,
chiefGeneralId: 1,
gold: 0,
rice: 0,
power: 0,
level,
typeCode: 'che_def',
meta: {},
});
describe('che_이동', () => {
it('applies movement side effects (gold/atmos/exp/leadership_exp)', async () => {
const bootstrapResult = buildScenarioBootstrap({
@@ -66,47 +111,8 @@ describe('che_이동', () => {
const world = new InMemoryWorld(bootstrapResult.snapshot);
const runner = new TestGameRunner(world, 200, 1);
const general: General = {
id: 1,
name: 'Mover',
nationId: 1,
cityId: 101,
troopId: 0,
stats: { leadership: 50, strength: 50, intelligence: 50 },
experience: 0,
dedication: 0,
officerLevel: 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 10,
age: 20,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
};
const nation: Nation = {
id: 1,
name: 'MyNation',
color: '#000',
capitalCityId: 101,
chiefGeneralId: 1,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
};
const general = buildGeneral(1, 'Mover');
const nation = buildNation();
world.snapshot.generals.push(general);
world.snapshot.nations.push(nation);
@@ -134,4 +140,46 @@ describe('che_이동', () => {
expect(updated?.experience).toBe(50);
expect(updated?.meta.leadership_exp).toBe(1);
});
it('logs roaming followers before the actor logger flush', async () => {
const leader = { ...buildGeneral(1, 'Leader'), officerLevel: 12 };
const follower = buildGeneral(2, 'Follower');
const nation = buildNation(0);
const moveDef = (await import('../../../src/actions/turn/general/che_이동.js')).commandSpec.createDefinition(
{} as any
);
const resolution = resolveGeneralAction(
moveDef,
{
general: leader,
nation,
moveGenerals: [leader, follower],
map: LINEAR_MAP,
develCost: 100,
rng: {} as any,
addLog: () => {},
} as any,
{
now: new Date('2026-08-23T00:00:00.000Z'),
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
},
{ destCityId: 102 }
);
expect(resolution.patches?.generals).toContainEqual({
id: follower.id,
patch: expect.objectContaining({ cityId: 102 }),
});
const orderedLogs = orderLegacyActionLoggerFlush(resolution.logs);
expect(orderedLogs.map((log) => log.generalId)).toEqual([follower.id, leader.id]);
expect(orderedLogs[0]).toEqual(
expect.objectContaining({
text: '방랑군 세력이 <G><b>City2</b></>로 이동했습니다.',
generalId: follower.id,
legacyFlushGroup: -1,
})
);
expect(orderedLogs[1]?.legacyFlushGroup).toBeUndefined();
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { ActionResolver } from '../../../src/actions/turn/general/che_하야.js';
describe('che_하야 Ref ordering', () => {
it('resets belong before refreshing max_belong', () => {
const general = {
id: 1,
name: '하야자',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
experience: 100,
dedication: 100,
officerLevel: 5,
gold: 100,
rice: 100,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
injury: 0,
age: 30,
stats: { leadership: 70, strength: 60, intelligence: 50 },
role: { items: {} },
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { belong: 10 },
};
const nation = {
id: 1,
name: '소속국',
gold: 1_000,
rice: 1_000,
meta: { gennum: 1 },
};
const result = new ActionResolver({ defaultNpcGold: 1_000, defaultNpcRice: 1_000 } as never).resolve(
{
general,
nation,
troopMembers: [],
addLog: () => {},
} as never,
{}
);
const generalPatch = result.effects.find(
(effect) => effect.type === 'general:patch' && effect.targetId === general.id
);
expect(generalPatch).toMatchObject({
type: 'general:patch',
patch: { meta: { belong: 0, max_belong: 0 } },
});
});
});
@@ -6,12 +6,16 @@ import { MINIMAL_MAP } from '../../fixtures/minimalMap.js';
import type { TurnCommandEnv, TurnCommandItemCatalogEntry } from '../../../src/actions/turn/commandEnv.js';
import { commandSpec as rebellionSpec } from '../../../src/actions/turn/general/che_모반시도.js';
import { commandSpec as abdicationSpec } from '../../../src/actions/turn/general/che_선양.js';
import { commandSpec as uprisingSpec } from '../../../src/actions/turn/general/che_거병.js';
import { commandSpec as giftSpec } from '../../../src/actions/turn/general/che_증여.js';
import { commandSpec as disbandSpec } from '../../../src/actions/turn/general/che_해산.js';
import { commandSpec as foundNationSpec } from '../../../src/actions/turn/general/cr_건국.js';
import { commandSpec as tradeItemSpec } from '../../../src/actions/turn/general/che_장비매매.js';
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
import { resolveGeneralAction } from '../../../src/actions/engine.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import { LogCategory, LogFormat, LogScope } from '../../../src/logging/types.js';
const SYSTEM_ENV: TurnCommandEnv = {
develCost: 100,
@@ -168,6 +172,31 @@ const makeSnapshot = (params: {
initialEvents: [],
});
const resolveForLogOrdering = (
resolver: any,
general: General,
city: City,
nation: Nation | null,
args: unknown,
context: Record<string, unknown>
) =>
resolveGeneralAction(
resolver,
{
general,
city,
nation,
rng: {} as any,
addLog: () => {},
...context,
} as any,
{
now: new Date('2026-08-23T00:00:00.000Z'),
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
},
args
);
describe('migrated general commands', () => {
it('che_모반시도: 군주를 찬탈한다', async () => {
const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, experience: 1000 });
@@ -319,6 +348,208 @@ describe('migrated general commands', () => {
expect(world.getNation(1)!.meta.collapsed).toBe(true);
});
it('별도 General logger를 actor applyDB 전후 순서로 flush한다', () => {
const city = makeCity({ id: 1, nationId: 1 });
const nation = makeNation({ id: 1, name: '위', chiefGeneralId: 1, capitalCityId: 1 });
const rebel = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '중신', officerLevel: 2 });
const displacedLord = makeGeneral({
id: 1,
nationId: 1,
cityId: 1,
name: '군주',
officerLevel: 12,
experience: 1000,
});
const rebellionLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
rebellionSpec.createDefinition(SYSTEM_ENV),
rebel,
city,
nation,
{},
{
nationGenerals: [displacedLord, rebel],
}
).logs
);
expect(
rebellionLogs
.filter((log) => log.generalId === displacedLord.id)
.map((log) => [log.category, log.legacyFlushGroup])
).toEqual([
[LogCategory.HISTORY, 1],
[LogCategory.ACTION, 1],
]);
const abdicator = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12 });
const recipient = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '후계자' });
const abdicationLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
abdicationSpec.createDefinition(SYSTEM_ENV),
abdicator,
city,
nation,
{ destGeneralID: recipient.id },
{ destGeneral: recipient }
).logs
);
expect(
abdicationLogs
.filter((log) => log.generalId === recipient.id)
.map((log) => [log.category, log.legacyFlushGroup])
).toEqual([
[LogCategory.HISTORY, 1],
[LogCategory.ACTION, 1],
]);
const giver = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 });
const receiver = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 });
const giftLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
giftSpec.createDefinition(SYSTEM_ENV),
giver,
city,
nation,
{ isGold: true, amount: 500, destGeneralID: receiver.id },
{ destGeneral: receiver }
).logs
);
expect(giftLogs.map((log) => [log.generalId, log.legacyFlushGroup ?? 0])).toEqual([
[giver.id, 0],
[receiver.id, 1],
]);
const disbandActor = makeGeneral({ id: 10, nationId: 1, cityId: 1, name: '방랑군주', officerLevel: 12 });
const member2 = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '부하2' });
const member3 = makeGeneral({ id: 3, nationId: 1, cityId: 1, name: '부하3' });
const wanderingNation = makeNation({
id: 1,
name: '방랑군',
chiefGeneralId: disbandActor.id,
capitalCityId: 1,
level: 0,
typeCode: 'None',
});
const disbandLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
disbandSpec.createDefinition(SYSTEM_ENV),
disbandActor,
city,
wanderingNation,
{},
{
nationGenerals: [disbandActor, member3, member2],
nationCities: [city],
currentYearMonth: 201 * 12 + 2 - 1,
initYearMonth: 201 * 12 + 1 - 1,
}
).logs
);
const nonActorDestructionLogs = disbandLogs.filter(
(log) =>
log.scope === LogScope.GENERAL && log.generalId !== disbandActor.id && log.text.includes('<R>멸망</>')
);
expect(nonActorDestructionLogs.map((log) => [log.generalId, log.category, log.legacyFlushGroup])).toEqual([
[member2.id, LogCategory.HISTORY, -2],
[member2.id, LogCategory.ACTION, -2],
[member3.id, LogCategory.HISTORY, -1],
[member3.id, LogCategory.ACTION, -1],
]);
const actorLogs = disbandLogs.filter(
(log) => log.scope === LogScope.GENERAL && log.generalId === disbandActor.id
);
expect(actorLogs).toHaveLength(4);
expect(actorLogs.every((log) => (log.legacyFlushGroup ?? 0) === 0)).toBe(true);
});
it('선양·모반시도의 Ref logger format과 거병 중립 nation flush 계약을 보존한다', () => {
const city = makeCity({ id: 1, nationId: 1 });
const nation = makeNation({ id: 1, name: '위', chiefGeneralId: 1, capitalCityId: 1 });
const projectRoute = (logs: ReturnType<typeof orderLegacyActionLoggerFlush>) =>
logs.map((log) => [
log.scope,
log.category,
log.generalId ?? null,
log.nationId ?? null,
log.format,
log.legacyFlushGroup ?? 0,
]);
const rebel = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '중신', officerLevel: 2 });
const displacedLord = makeGeneral({
id: 1,
nationId: 1,
cityId: 1,
name: '군주',
officerLevel: 12,
experience: 1_000,
});
const rebellionLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
rebellionSpec.createDefinition(SYSTEM_ENV),
rebel,
city,
nation,
{},
{ nationGenerals: [displacedLord, rebel] }
).logs
);
expect(projectRoute(rebellionLogs)).toEqual([
[LogScope.GENERAL, LogCategory.HISTORY, rebel.id, null, LogFormat.YEAR_MONTH, 0],
[LogScope.GENERAL, LogCategory.ACTION, rebel.id, null, LogFormat.MONTH, 0],
[LogScope.NATION, LogCategory.HISTORY, null, nation.id, LogFormat.YEAR_MONTH, 0],
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0],
[LogScope.GENERAL, LogCategory.HISTORY, displacedLord.id, null, LogFormat.YEAR_MONTH, 1],
[LogScope.GENERAL, LogCategory.ACTION, displacedLord.id, null, LogFormat.MONTH, 1],
]);
const abdicator = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12 });
const recipient = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '후계자' });
const abdicationLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
abdicationSpec.createDefinition(SYSTEM_ENV),
abdicator,
city,
nation,
{ destGeneralID: recipient.id },
{ destGeneral: recipient }
).logs
);
expect(projectRoute(abdicationLogs)).toEqual([
[LogScope.GENERAL, LogCategory.HISTORY, abdicator.id, null, LogFormat.YEAR_MONTH, 0],
[LogScope.GENERAL, LogCategory.ACTION, abdicator.id, null, LogFormat.MONTH, 0],
[LogScope.NATION, LogCategory.HISTORY, null, nation.id, LogFormat.YEAR_MONTH, 0],
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0],
[LogScope.GENERAL, LogCategory.HISTORY, recipient.id, null, LogFormat.YEAR_MONTH, 1],
[LogScope.GENERAL, LogCategory.ACTION, recipient.id, null, LogFormat.MONTH, 1],
]);
const founder = makeGeneral({ id: 7, nationId: 0, cityId: 1, name: '운영자' });
const uprisingLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
uprisingSpec.createDefinition(SYSTEM_ENV),
founder,
city,
null,
{},
{
createNationId: () => 2,
listNations: () => [],
scenarioId: 1,
baseRice: 1_000,
}
).logs
);
expect(projectRoute(uprisingLogs)).toEqual([
[LogScope.GENERAL, LogCategory.HISTORY, founder.id, null, LogFormat.YEAR_MONTH, 0],
[LogScope.GENERAL, LogCategory.ACTION, founder.id, null, LogFormat.MONTH, 0],
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0],
[LogScope.SYSTEM, LogCategory.SUMMARY, null, null, LogFormat.MONTH, 0],
]);
expect(uprisingLogs.some((log) => log.scope === LogScope.NATION)).toBe(false);
});
it('cr_건국: 국가 정보를 건국 상태로 갱신한다', async () => {
const lord = makeGeneral({
id: 1,
@@ -390,7 +390,9 @@ describe('General Commands New Scenario', () => {
const g1_after_resign = world.getGeneral(1)!;
expect(g1_after_resign.nationId).toBe(0);
expect(g1_after_resign.meta.max_belong).toBe(18);
// Ref clears belong before refreshing max_belong, so the existing
// historical maximum is retained instead of the pre-resign belong.
expect(g1_after_resign.meta.max_belong).toBe(12);
// 6. Retire (Needs age >= 60)
// Manually set age
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
parseTurnCommandProfile,
resolveScenarioTurnCommandProfile,
type TurnCommandProfile,
} from '../src/actions/turn/commandProfile.js';
const fallback: TurnCommandProfile = {
general: ['휴식', 'che_훈련'],
nation: ['휴식', 'che_발령'],
};
describe('scenario turn command profile', () => {
it('fails closed for malformed, duplicate, or rest-less base profiles', () => {
expect(() => parseTurnCommandProfile({ general: ['휴식'], nation: '휴식' })).toThrow(
'Invalid turn command profile'
);
expect(() => parseTurnCommandProfile({ general: ['휴식', '휴식'], nation: ['휴식'] })).toThrow(
'Duplicate general command key'
);
expect(() => parseTurnCommandProfile({ general: ['che_훈련'], nation: ['휴식'] })).toThrow('must include 휴식');
});
it('keeps the base profile when the scenario does not override command groups', () => {
expect(resolveScenarioTurnCommandProfile({}, fallback)).toEqual({
profile: fallback,
generalGroups: null,
nationGroups: null,
});
});
it('flattens Ref command groups while preserving category and command order', () => {
const result = resolveScenarioTurnCommandProfile(
{
availableGeneralCommand: {
: ['휴식'],
: ['cr_맹훈련', 'che_훈련'],
},
availableChiefCommand: {
: ['휴식'],
: ['event_대검병연구'],
},
},
fallback
);
expect(result.profile).toEqual({
general: ['휴식', 'cr_맹훈련', 'che_훈련'],
nation: ['휴식', 'event_대검병연구'],
});
expect(result.generalGroups).toEqual([
{ category: '개인', commands: ['휴식'] },
{ category: '군사', commands: ['cr_맹훈련', 'che_훈련'] },
]);
expect(result.nationGroups).toEqual([
{ category: '휴식', commands: ['휴식'] },
{ category: '연구', commands: ['event_대검병연구'] },
]);
});
it('fails closed for unknown, duplicate, or fallback-less scenario commands', () => {
expect(() => resolveScenarioTurnCommandProfile('invalid', fallback)).toThrow(
'Scenario const must be an object'
);
expect(() =>
resolveScenarioTurnCommandProfile(
{ availableGeneralCommand: { : ['휴식', 'unknown-command'] } },
fallback
)
).toThrow('Unknown scenario general command key');
expect(() =>
resolveScenarioTurnCommandProfile({ availableChiefCommand: { : ['휴식'], : ['휴식'] } }, fallback)
).toThrow('Duplicate scenario nation command key');
expect(() =>
resolveScenarioTurnCommandProfile({ availableGeneralCommand: { : ['che_훈련'] } }, fallback)
).toThrow('must include 휴식');
});
});
+87 -3
View File
@@ -8,9 +8,12 @@ import type { UnitSetDefinition } from '../src/world/types.js';
import { resolveWarAftermath } from '../src/war/aftermath.js';
import type { WarAftermathConfig } from '../src/war/types.js';
import { LogFormat } from '../src/logging/types.js';
import { LegacyWarLogFlushSequence } from '../src/war/legacyFlushSequence.js';
import { buildWarAftermathConfig, buildWarConfig } from '../src/actions/turn/actionContextHelpers.js';
import type { ScenarioConfig } from '../src/scenario/types.js';
const MESSAGE_TIME = new Date('0185-01-01T00:00:00.000Z');
const buildUnitSet = (): UnitSetDefinition => ({
id: 'test',
name: 'test',
@@ -188,6 +191,7 @@ describe('war aftermath', () => {
unitSet: buildUnitSet(),
config: { ...buildConfig(), maxTechLevel: 15 },
time: { year: 200, month: 1, startYear: 180 },
messageTime: MESSAGE_TIME,
});
expect(defenderNation.rice).toBe(985);
@@ -232,6 +236,7 @@ describe('war aftermath', () => {
month: 1,
startYear: 180,
},
messageTime: MESSAGE_TIME,
});
expect(attackerNation.meta.tech).toBe(Math.fround(1000.6));
@@ -271,6 +276,7 @@ describe('war aftermath', () => {
unitSet: buildUnitSet(),
config: buildConfig(),
time: { year: 200, month: 1, startYear: 180 },
messageTime: MESSAGE_TIME,
});
expect(attackerCity.meta.dead).toBe(71);
@@ -320,6 +326,7 @@ describe('war aftermath', () => {
month: 1,
startYear: 180,
},
messageTime: MESSAGE_TIME,
});
expect(defenderNation.capitalCityId).toBe(nextCapital.id);
@@ -330,6 +337,7 @@ describe('war aftermath', () => {
'수뇌는 <G><b>City3</b></>으로 집합되었습니다.',
])
);
expect(outcome.logs.find((log) => log.text.startsWith('수뇌는'))?.format).toBe(LogFormat.MONTH);
});
it('uses the city battle phase, not retained casualties, for conquered supply-city rice', () => {
@@ -372,6 +380,7 @@ describe('war aftermath', () => {
unitSet: buildUnitSet(),
config: buildConfig(),
time: { year: 200, month: 1, startYear: 180 },
messageTime: MESSAGE_TIME,
});
expect(defenderNation.rice).toBe(6500);
@@ -389,6 +398,11 @@ describe('war aftermath', () => {
const attacker = buildGeneral(1, 1, 1);
attacker.officerLevel = 12;
const defender = buildGeneral(2, 2, 2);
defender.officerLevel = 12;
defender.experience = 2_000;
defender.dedication = 2_000;
defender.meta.explevel = 14;
defender.meta.dedlevel = 3;
const outcome = resolveWarAftermath({
battle: {
@@ -430,14 +444,16 @@ describe('war aftermath', () => {
month: 1,
startYear: 180,
},
messageTime: MESSAGE_TIME,
rng,
legacyFlushSequence: new LegacyWarLogFlushSequence(100),
});
expect(outcome.conquest?.nationCollapsed).toBe(true);
expect(attackerNation.gold).toBe(3600);
expect(attackerNation.rice).toBe(4600);
expect(defender.experience).toBe(90);
expect(defender.dedication).toBe(50);
expect(defender.experience).toBe(1_800);
expect(defender.dedication).toBe(1_000);
// Removed nations can remain in old persisted conflict data. They
// must never receive ownership during a later conquest.
expect(defenderCity.nationId).toBe(attackerNation.id);
@@ -447,9 +463,14 @@ describe('war aftermath', () => {
'<D><b>Nation2</b></>를 정복',
'<R><b>【멸망】</b></><D><b>Nation2</b></>는 <R>멸망</>했습니다.',
'<D><b>Nation2</b></>가 <R>멸망</>했습니다.',
'<C>Lv 13</>으로 <R>레벨다운</>!',
'<Y>27품관</>으로 <C>승급</>하여 봉록이 <C>1,200</>으로 <C>상승</>했습니다!',
'<D><b>Nation2</b></> 정복으로 금<C>2,600</> 쌀<C>3,600</>을 획득했습니다.',
])
);
expect(outcome.logs.filter((log) => log.generalId === defender.id).map((log) => log.legacyFlushGroup)).toEqual([
103, 103, 103, 103, 103,
]);
});
it('matches ruined-nation lord ordering and NPC appointment draws', () => {
@@ -464,7 +485,7 @@ describe('war aftermath', () => {
npc.npcState = 2;
const rangeDraws = [0.2, 0.21, 0.4, 0.41];
const nextBool = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true).mockReturnValueOnce(false);
const nextBool = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(true).mockReturnValueOnce(false);
const rng = {
nextRange: vi.fn(() => rangeDraws.shift()!),
nextBool,
@@ -495,6 +516,8 @@ describe('war aftermath', () => {
joinRuinedNpcProbability: 0.1,
},
time: { year: 186, month: 1, startYear: 179 },
messageTime: MESSAGE_TIME,
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
rng,
});
@@ -503,6 +526,32 @@ describe('war aftermath', () => {
expect(lord.gold).toBe(600);
expect(lord.rice).toBe(590);
expect(nextBool.mock.calls.map(([probability]) => probability)).toEqual([0.5, 0.1, 0.5]);
expect(outcome.conquest?.messages).toEqual([
{
msgType: 'private',
src: {
generalId: attacker.id,
generalName: attacker.name,
nationId: attackerNation.id,
nationName: attackerNation.name,
color: attackerNation.color,
icon: 'https://ref.example/image/icons/default.jpg',
},
dest: {
generalId: npc.id,
generalName: npc.name,
nationId: 0,
nationName: '재야',
color: '#000000',
icon: 'https://ref.example/image/icons/default.jpg',
},
text: 'Nation1로 망명 권유 서신',
time: MESSAGE_TIME,
validUntil: new Date('9999-12-31T12:59:59.000Z'),
option: { action: 'scout' },
sendDestOnly: true,
},
]);
expect(outcome.conquest?.ruinedNpcJoinPlans).toEqual([
{ generalId: npc.id, destNationId: attackerNation.id, joinTurn: 6 },
]);
@@ -523,10 +572,12 @@ describe('war aftermath', () => {
const attackerCity = buildCity(1, 1);
const defenderCity = buildCity(2, 2);
const attacker = buildGeneral(1, 1, 1);
attacker.officerLevel = 12;
const firstDefender = buildGeneral(2, 2, 2);
const secondDefender = buildGeneral(3, 2, 2);
secondDefender.crew = 0;
const elsewhere = buildGeneral(4, 2, 3);
elsewhere.officerLevel = 12;
const dispatchOrder: number[] = [];
const module: GeneralActionModule = {
eventHandlers: {
@@ -562,8 +613,10 @@ describe('war aftermath', () => {
month: 1,
startYear: 180,
},
messageTime: MESSAGE_TIME,
rng,
generalActionModules: [module],
legacyFlushSequence: new LegacyWarLogFlushSequence(200),
});
expect(dispatchOrder).toEqual([firstDefender.id, secondDefender.id]);
@@ -585,6 +638,27 @@ describe('war aftermath', () => {
LogFormat.MONTH,
LogFormat.MONTH,
]);
expect(
outcome.logs.filter((log) => log.text.startsWith('점령 이벤트')).map((log) => log.legacyFlushGroup)
).toEqual([200, 201]);
expect(
outcome.logs.find((log) => log.nationId === defenderNation.id && log.text.includes('<O>함락</>'))
?.legacyFlushGroup
).toBe(202);
expect(
outcome.logs.find((log) => log.text.includes(`Nation${defenderNation.id}</b></>를 정복`))?.legacyFlushGroup
).toBe(203);
expect(
outcome.logs
.filter((log) => log.text.includes('도주하며'))
.map((log) => [log.generalId, log.legacyFlushGroup])
).toEqual([
[firstDefender.id, 204],
[secondDefender.id, 205],
[elsewhere.id, 206],
]);
expect(outcome.logs.find((log) => log.text.includes('【멸망】'))?.legacyFlushGroup).toBe(206);
expect(outcome.logs.find((log) => log.text.includes('정복으로 금'))?.legacyFlushGroup).toBeUndefined();
expect(outcome.generals.map((general) => general.id)).toEqual(
expect.arrayContaining([firstDefender.id, secondDefender.id])
);
@@ -626,11 +700,21 @@ describe('war aftermath', () => {
month: 1,
startYear: 180,
},
messageTime: MESSAGE_TIME,
legacyFlushSequence: new LegacyWarLogFlushSequence(300),
});
expect(outcome.conquest?.conquerNationId).toBe(4);
expect(defenderCity.nationId).toBe(4);
expect(defenderCity.meta.conflict_order).toEqual([]);
expect(attacker.cityId).toBe(1);
expect(outcome.logs.find((log) => log.nationId === defenderNation.id)?.legacyFlushGroup).toBe(300);
expect(outcome.logs.find((log) => log.nationId === firstNation.id)?.legacyFlushGroup).toBe(301);
expect(
outcome.logs
.filter((log) => log.generalId === attacker.id || log.scope === 'SYSTEM')
.filter((log) => log.text.includes('영토분쟁') || log.text.includes('점령'))
.every((log) => log.legacyFlushGroup === undefined)
).toBe(true);
});
});
+59
View File
@@ -7,6 +7,7 @@ import type { UnitSetDefinition } from '../src/world/types.js';
import { ActionLogger } from '../src/logging/actionLogger.js';
import { WarActionPipeline } from '../src/war/actions.js';
import { resolveWarBattle } from '../src/war/engine.js';
import { LegacyWarLogFlushSequence } from '../src/war/legacyFlushSequence.js';
import type { WarEngineConfig } from '../src/war/types.js';
import { WarCrewType } from '../src/war/crewType.js';
import { loadWarTriggerModules } from '../src/war/triggers/index.js';
@@ -535,6 +536,64 @@ describe('war triggers', () => {
});
describe('resolveWarBattle', () => {
it('flushes each fought defender before the attacker with a unique legacy epoch', () => {
const attackerNation = buildNation();
const defenderNation = { ...buildNation(), id: 2, name: 'DefenderNation', capitalCityId: 2 };
const attackerCity = buildCity();
const defenderCity = {
...buildCity(),
id: 2,
name: 'DefenderCity',
nationId: defenderNation.id,
defence: 0,
wall: 0,
};
const attacker = { ...buildGeneral(100), crew: 10_000, rice: 100_000 };
const firstDefender = {
...buildGeneral(10),
id: 2,
name: 'FirstDefender',
nationId: defenderNation.id,
cityId: defenderCity.id,
crew: 1,
};
const secondDefender = {
...buildGeneral(10),
id: 3,
name: 'SecondDefender',
nationId: defenderNation.id,
cityId: defenderCity.id,
crew: 1,
};
const outcome = resolveWarBattle({
rng: new RandUtil(new ConstantRNG(0)),
unitSet: buildUnitSet(),
config: buildConfig(),
time: { year: 200, month: 1, startYear: 180 },
attacker: { general: attacker, city: attackerCity, nation: attackerNation },
defenders: [
{ general: firstDefender, city: defenderCity, nation: defenderNation },
{ general: secondDefender, city: defenderCity, nation: defenderNation },
],
defenderCity,
defenderNation,
legacyFlushSequence: new LegacyWarLogFlushSequence(100),
});
const groupsFor = (generalId: number): number[] => [
...new Set(
outcome.logs
.filter((log) => log.generalId === generalId)
.map((log) => log.legacyFlushGroup)
.filter((group): group is number => group !== undefined)
),
];
expect(groupsFor(firstDefender.id)).toEqual([100]);
expect(groupsFor(secondDefender.id)).toEqual([101]);
// group 102 is the city applyDB/rollback epoch. Ref then flushes the attacker.
expect(groupsFor(attacker.id)).toEqual([103]);
});
it('persists multi-use battle item charges and removes the last charge', async () => {
const general = buildGeneral(100);
equipNewItem(general, 'item', 'event_충차', { charges: 2 });

Some files were not shown because too many files have changed in this diff Show More