fix: 커맨드 차등 생명주기와 로그 그래프를 보강
장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다. 실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
finalizeLogEntry,
|
finalizeLogEntry,
|
||||||
LogFormat,
|
LogFormat,
|
||||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||||
|
orderLegacyActionLoggerFlush,
|
||||||
resolveInstantDiplomacyResponse,
|
resolveInstantDiplomacyResponse,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
type GeneralActionEffect,
|
type GeneralActionEffect,
|
||||||
@@ -121,7 +122,7 @@ const persistEffects = async (
|
|||||||
logs.push(effect.entry);
|
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[]> => {
|
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import {
|
|||||||
} from '../../turns/commandTable.js';
|
} from '../../turns/commandTable.js';
|
||||||
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
||||||
import {
|
import {
|
||||||
|
assertReservedTurnActionAvailable,
|
||||||
buildEquipmentTradeItemOptions,
|
buildEquipmentTradeItemOptions,
|
||||||
parseReservedTurnArgs,
|
parseRegisteredTurnArgs,
|
||||||
TURN_COMMAND_NATION_COLORS,
|
TURN_COMMAND_NATION_COLORS,
|
||||||
type TurnCommandInputOptions,
|
type TurnCommandInputOptions,
|
||||||
} from '../../turns/commandInput.js';
|
} from '../../turns/commandInput.js';
|
||||||
@@ -65,7 +66,7 @@ const buildBulkEntrySchema = (turnList: z.ZodType<number[]>) =>
|
|||||||
|
|
||||||
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
|
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
|
||||||
try {
|
try {
|
||||||
return await parseReservedTurnArgs(scope, action, args);
|
return await parseRegisteredTurnArgs(scope, action, args);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
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> => {
|
const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> => {
|
||||||
try {
|
try {
|
||||||
return await mutation();
|
return await mutation();
|
||||||
@@ -413,6 +430,7 @@ export const turnsRouter = router({
|
|||||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||||
const args = await parseCommandArgs('general', input.action, input.args);
|
const args = await parseCommandArgs('general', input.action, input.args);
|
||||||
const worldState = await getReservationWorldState(ctx);
|
const worldState = await getReservationWorldState(ctx);
|
||||||
|
await assertScenarioCommandAvailable('general', input.action, worldState);
|
||||||
await assertReservedTurnPermission(worldState, general, 'general', input.action, args);
|
await assertReservedTurnPermission(worldState, general, 'general', input.action, args);
|
||||||
|
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
@@ -476,6 +494,7 @@ export const turnsRouter = router({
|
|||||||
);
|
);
|
||||||
const worldState = await getReservationWorldState(ctx);
|
const worldState = await getReservationWorldState(ctx);
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
|
await assertScenarioCommandAvailable('general', update.action, worldState);
|
||||||
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
|
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
|
||||||
}
|
}
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
@@ -515,6 +534,7 @@ export const turnsRouter = router({
|
|||||||
}
|
}
|
||||||
const args = await parseCommandArgs('nation', input.action, input.args);
|
const args = await parseCommandArgs('nation', input.action, input.args);
|
||||||
const worldState = await getReservationWorldState(ctx);
|
const worldState = await getReservationWorldState(ctx);
|
||||||
|
await assertScenarioCommandAvailable('nation', input.action, worldState);
|
||||||
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
|
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
|
||||||
|
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
@@ -628,6 +648,7 @@ export const turnsRouter = router({
|
|||||||
);
|
);
|
||||||
const worldState = await getReservationWorldState(ctx);
|
const worldState = await getReservationWorldState(ctx);
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
|
await assertScenarioCommandAvailable('nation', update.action, worldState);
|
||||||
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
||||||
}
|
}
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
isGeneralTurnCommandKey,
|
||||||
|
isNationTurnCommandKey,
|
||||||
loadGeneralTurnCommandSpecs,
|
loadGeneralTurnCommandSpecs,
|
||||||
loadNationTurnCommandSpecs,
|
loadNationTurnCommandSpecs,
|
||||||
type GeneralTurnCommandSpec,
|
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 { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import { z } from 'zod';
|
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;
|
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)));
|
return Object.entries(properties).map(([key, schema]) => buildField(key, schema, required.has(key)));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadTurnCommandSpecs = async () => {
|
export const loadTurnCommandSpecs = async (scenarioConst?: unknown) => {
|
||||||
const profile = await loadTurnCommandProfile();
|
const resolution = await loadScenarioTurnCommandProfile({ scenarioConst });
|
||||||
|
const profile = resolution.profile;
|
||||||
const [general, nation] = await Promise.all([
|
const [general, nation] = await Promise.all([
|
||||||
loadGeneralTurnCommandSpecs(profile.general),
|
loadGeneralTurnCommandSpecs(profile.general),
|
||||||
loadNationTurnCommandSpecs(profile.nation),
|
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',
|
scope: 'general' | 'nation',
|
||||||
action: string,
|
action: string,
|
||||||
rawArgs: unknown
|
rawArgs: unknown
|
||||||
): Promise<Record<string, unknown>> => {
|
): Promise<Record<string, unknown>> => {
|
||||||
const specs = await loadTurnCommandSpecs();
|
const specs =
|
||||||
const spec = specs[scope].find((entry) => entry.key === action);
|
scope === 'general'
|
||||||
|
? isGeneralTurnCommandKey(action)
|
||||||
|
? await loadGeneralTurnCommandSpecs([action])
|
||||||
|
: []
|
||||||
|
: isNationTurnCommandKey(action)
|
||||||
|
? await loadNationTurnCommandSpecs([action])
|
||||||
|
: [];
|
||||||
|
const spec = specs[0];
|
||||||
if (!spec) {
|
if (!spec) {
|
||||||
throw new Error(`Unknown ${scope} turn command: ${action}`);
|
throw new Error(`Unknown ${scope} turn command: ${action}`);
|
||||||
}
|
}
|
||||||
@@ -369,3 +384,24 @@ export const parseReservedTurnArgs = async (
|
|||||||
}
|
}
|
||||||
return spec.argsSchema.parse(rawArgs);
|
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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -142,15 +142,6 @@ const REF_GENERAL_COMMAND_GROUPS = [
|
|||||||
commands: ReadonlyArray<GeneralTurnCommandKey>;
|
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 = [
|
const REF_NATION_COMMAND_GROUPS = [
|
||||||
{
|
{
|
||||||
category: '휴식',
|
category: '휴식',
|
||||||
@@ -190,15 +181,6 @@ const REF_NATION_COMMAND_GROUPS = [
|
|||||||
commands: ReadonlyArray<NationTurnCommandKey>;
|
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']>([
|
const INPUT_REQUIREMENT_KINDS = new Set<RequirementKey['kind']>([
|
||||||
'destGeneral',
|
'destGeneral',
|
||||||
'destCity',
|
'destCity',
|
||||||
@@ -746,34 +728,22 @@ const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: Stat
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectRefGeneralCommandGroups = (entries: CommandEntry[]): CommandEntry[] =>
|
type CommandGroupLayout = ReadonlyArray<{ category: string; commands: ReadonlyArray<string> }>;
|
||||||
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);
|
|
||||||
|
|
||||||
const projectRefNationCommandGroups = (entries: CommandEntry[]): CommandEntry[] =>
|
const projectCommandGroups = (entries: CommandEntry[], layout: CommandGroupLayout): CommandEntry[] => {
|
||||||
entries
|
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) => {
|
.map((entry, profileIndex) => {
|
||||||
const refPosition = REF_NATION_COMMAND_POSITION.get(entry.definition.key as NationTurnCommandKey);
|
const refPosition = commandPosition.get(entry.definition.key);
|
||||||
return {
|
return {
|
||||||
entry: refPosition ? { ...entry, category: refPosition.category } : entry,
|
entry: refPosition ? { ...entry, category: refPosition.category } : entry,
|
||||||
categoryIndex:
|
categoryIndex: categoryOrder.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER,
|
||||||
REF_NATION_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER,
|
|
||||||
commandIndex: refPosition?.index ?? profileIndex,
|
commandIndex: refPosition?.index ?? profileIndex,
|
||||||
profileIndex,
|
profileIndex,
|
||||||
};
|
};
|
||||||
@@ -785,6 +755,7 @@ const projectRefNationCommandGroups = (entries: CommandEntry[]): CommandEntry[]
|
|||||||
left.profileIndex - right.profileIndex
|
left.profileIndex - right.profileIndex
|
||||||
)
|
)
|
||||||
.map(({ entry }) => entry);
|
.map(({ entry }) => entry);
|
||||||
|
};
|
||||||
|
|
||||||
export const buildTurnCommandTable = async (options: {
|
export const buildTurnCommandTable = async (options: {
|
||||||
worldState: WorldStateRow;
|
worldState: WorldStateRow;
|
||||||
@@ -814,7 +785,13 @@ export const buildTurnCommandTable = async (options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const env = buildCommandEnv(options.worldState);
|
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, {
|
const generalEntries = buildEntries(env, generalSpecs, {
|
||||||
foundingAvailable:
|
foundingAvailable:
|
||||||
options.realNationCount === undefined
|
options.realNationCount === undefined
|
||||||
@@ -824,8 +801,12 @@ export const buildTurnCommandTable = async (options: {
|
|||||||
const nationEntries = buildEntries(env, nationSpecs);
|
const nationEntries = buildEntries(env, nationSpecs);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
general: buildGroups(projectRefGeneralCommandGroups(generalEntries), ctx, view),
|
general: buildGroups(
|
||||||
nation: buildGroups(projectRefNationCommandGroups(nationEntries), ctx, view),
|
projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS),
|
||||||
|
ctx,
|
||||||
|
view
|
||||||
|
),
|
||||||
|
nation: buildGroups(projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), ctx, view),
|
||||||
inputOptions: options.inputOptions ?? {
|
inputOptions: options.inputOptions ?? {
|
||||||
cities: [],
|
cities: [],
|
||||||
nations: [],
|
nations: [],
|
||||||
@@ -850,7 +831,8 @@ export const evaluateReservedTurnPermission = async (options: {
|
|||||||
action: string;
|
action: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
}): Promise<ConstraintResult> => {
|
}): 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);
|
const spec = specs[options.scope].find((entry) => entry.key === options.action);
|
||||||
if (!spec) {
|
if (!spec) {
|
||||||
throw new Error(`Unknown ${options.scope} turn command: ${options.action}`);
|
throw new Error(`Unknown ${options.scope} turn command: ${options.action}`);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
loadGeneralTurnCommandSpecs,
|
loadGeneralTurnCommandSpecs,
|
||||||
loadNationTurnCommandSpecs,
|
loadNationTurnCommandSpecs,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
|
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -98,6 +99,52 @@ describe('turn command argument input', () => {
|
|||||||
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
|
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', () => {
|
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
|
||||||
const items = buildEquipmentTradeItemOptions({
|
const items = buildEquipmentTradeItemOptions({
|
||||||
configConst: {},
|
configConst: {},
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../src/context.js';
|
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 type { GeneralActionModule, MapDefinition, UnitSetDefinition } from '@sammo-ts/logic';
|
||||||
import { buildRecruitmentCommandInfo, buildTurnCommandTable } from '../src/turns/commandTable.js';
|
import { buildRecruitmentCommandInfo, buildTurnCommandTable } from '../src/turns/commandTable.js';
|
||||||
|
|
||||||
const buildWorldState = (joinMode = 'full'): WorldStateRow =>
|
const buildWorldState = (joinMode = 'full', constOverrides: Record<string, unknown> = {}): WorldStateRow =>
|
||||||
({
|
({
|
||||||
id: 1,
|
id: 1,
|
||||||
scenarioCode: 'default',
|
scenarioCode: 'default',
|
||||||
@@ -17,6 +18,7 @@ const buildWorldState = (joinMode = 'full'): WorldStateRow =>
|
|||||||
baseGold: 1000,
|
baseGold: 1000,
|
||||||
baseRice: 1000,
|
baseRice: 1000,
|
||||||
develCost: 100,
|
develCost: 100,
|
||||||
|
...constOverrides,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meta: {
|
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 () => {
|
it('keeps every default general and chief argument command inside the shared frontend field contract', async () => {
|
||||||
const table = await buildTurnCommandTable({
|
const table = await buildTurnCommandTable({
|
||||||
worldState: buildWorldState(),
|
worldState: buildWorldState(),
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLea
|
|||||||
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
|
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
|
||||||
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
||||||
import type { TurnGeneral } 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 { persistUnificationFinalization } from './unificationPersistence.js';
|
||||||
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
||||||
import { persistYearbookSnapshot } from './yearbookPersistence.js';
|
import { persistYearbookSnapshot } from './yearbookPersistence.js';
|
||||||
@@ -769,17 +769,6 @@ const buildPersistedGeneralMeta = (
|
|||||||
return asJson(meta);
|
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 RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
|
||||||
|
|
||||||
const upsertRankRows = async (
|
const upsertRankRows = async (
|
||||||
|
|||||||
@@ -1933,9 +1933,17 @@ export class InMemoryTurnWorld {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
delete conflict[key];
|
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, {
|
this.cities.set(city.id, {
|
||||||
...city,
|
...city,
|
||||||
conflict: conflict as City['conflict'],
|
conflict: persistedConflict,
|
||||||
});
|
});
|
||||||
this.dirtyCityIds.add(city.id);
|
this.dirtyCityIds.add(city.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = (
|
export const buildLegacyComparableRankRows = (
|
||||||
general: RankedGeneralState
|
general: RankedGeneralState
|
||||||
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
|
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
|
||||||
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
|
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
|
||||||
return buildPersistedRankRows(general).filter(
|
return buildPersistedRankRows(general).filter((row): row is PersistedRankRow & { type: LegacyRankDataType } =>
|
||||||
(row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type)
|
legacyTypes.has(row.type)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
getNextTurnAt,
|
getNextTurnAt,
|
||||||
getBillByLevel,
|
getBillByLevel,
|
||||||
LEGACY_DEFAULT_MAX_LEVEL,
|
LEGACY_DEFAULT_MAX_LEVEL,
|
||||||
|
orderLegacyActionLoggerFlush,
|
||||||
type ItemModule,
|
type ItemModule,
|
||||||
type UniqueLotteryRunner,
|
type UniqueLotteryRunner,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
@@ -121,6 +122,42 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
|
|||||||
'che_전투태세',
|
'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 = (
|
export const applyLegacyGeneralProgression = (
|
||||||
general: TurnGeneral,
|
general: TurnGeneral,
|
||||||
previousGeneral: TurnGeneral,
|
previousGeneral: TurnGeneral,
|
||||||
@@ -152,36 +189,49 @@ export const applyLegacyGeneralProgression = (
|
|||||||
actionKey === 'che_선양' ||
|
actionKey === 'che_선양' ||
|
||||||
actionKey === 'che_출병' ||
|
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 previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0);
|
||||||
const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel);
|
const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel);
|
||||||
meta.explevel = expLevel;
|
const nextExpLevel = preservesResolvedProcurementLevel ? actionResolvedExpLevel : expLevel;
|
||||||
if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) {
|
meta.explevel = nextExpLevel;
|
||||||
const josaRo = JosaUtil.pick(String(expLevel), '로');
|
if (
|
||||||
|
nextExpLevel !== previousExpLevel &&
|
||||||
|
(preservesResolvedProcurementLevel || actionResolvedExpLevel !== nextExpLevel)
|
||||||
|
) {
|
||||||
|
const josaRo = JosaUtil.pick(String(nextExpLevel), '로');
|
||||||
logs.push(
|
logs.push(
|
||||||
createGeneralActionLog(
|
createGeneralActionLog(
|
||||||
general.id,
|
general.id,
|
||||||
expLevel > previousExpLevel
|
nextExpLevel > previousExpLevel
|
||||||
? `<C>Lv ${expLevel}</>${josaRo} <C>레벨업</>!`
|
? `<C>Lv ${nextExpLevel}</>${josaRo} <C>레벨업</>!`
|
||||||
: `<C>Lv ${expLevel}</>${josaRo} <R>레벨다운</>!`,
|
: `<C>Lv ${nextExpLevel}</>${josaRo} <R>레벨다운</>!`,
|
||||||
{ format: LogFormat.PLAIN }
|
{ 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);
|
const previousDedicationLevel = readMetaNumber(previousGeneral.meta, 'dedlevel', 0);
|
||||||
meta.dedlevel = dedicationLevel;
|
const actionResolvedDedicationLevel = readMetaNumber(general.meta, 'dedlevel', previousDedicationLevel);
|
||||||
if (dedicationLevel !== previousDedicationLevel) {
|
const nextDedicationLevel = preservesResolvedProcurementLevel ? actionResolvedDedicationLevel : dedicationLevel;
|
||||||
|
meta.dedlevel = nextDedicationLevel;
|
||||||
|
if (nextDedicationLevel !== previousDedicationLevel) {
|
||||||
const dedicationLevelText =
|
const dedicationLevelText =
|
||||||
dedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - dedicationLevel + 1}품관`;
|
nextDedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - nextDedicationLevel + 1}품관`;
|
||||||
const billText = getBillByLevel(dedicationLevel).toLocaleString('en-US');
|
const billText = getBillByLevel(nextDedicationLevel).toLocaleString('en-US');
|
||||||
const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로');
|
const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로');
|
||||||
const josaRoBill = JosaUtil.pick(billText, '로');
|
const josaRoBill = JosaUtil.pick(billText, '로');
|
||||||
logs.push(
|
logs.push(
|
||||||
createGeneralActionLog(
|
createGeneralActionLog(
|
||||||
general.id,
|
general.id,
|
||||||
dedicationLevel > previousDedicationLevel
|
nextDedicationLevel > previousDedicationLevel
|
||||||
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
|
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
|
||||||
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
|
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
|
||||||
{ format: LogFormat.PLAIN }
|
{ format: LogFormat.PLAIN }
|
||||||
@@ -778,8 +828,14 @@ const createGeneralActionLog = (
|
|||||||
const resolveDefinition = (
|
const resolveDefinition = (
|
||||||
actionKey: string,
|
actionKey: string,
|
||||||
definitions: Map<string, GeneralActionDefinition>,
|
definitions: Map<string, GeneralActionDefinition>,
|
||||||
fallback: GeneralActionDefinition
|
kind: 'general' | 'nation'
|
||||||
): GeneralActionDefinition => definitions.get(actionKey) ?? fallback;
|
): GeneralActionDefinition => {
|
||||||
|
const definition = definitions.get(actionKey);
|
||||||
|
if (!definition) {
|
||||||
|
throw new Error(`Unknown reserved ${kind} turn command: ${actionKey}`);
|
||||||
|
}
|
||||||
|
return definition;
|
||||||
|
};
|
||||||
|
|
||||||
export const createReservedTurnHandler = async (options: {
|
export const createReservedTurnHandler = async (options: {
|
||||||
reservedTurns: InMemoryReservedTurnStore;
|
reservedTurns: InMemoryReservedTurnStore;
|
||||||
@@ -790,6 +846,8 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
getWorld: () => InMemoryTurnWorld | null;
|
getWorld: () => InMemoryTurnWorld | null;
|
||||||
commandProfile?: TurnCommandProfile;
|
commandProfile?: TurnCommandProfile;
|
||||||
commandEnv?: TurnCommandEnv;
|
commandEnv?: TurnCommandEnv;
|
||||||
|
now?: () => Date;
|
||||||
|
messageSharedIconBaseUrl?: string;
|
||||||
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
|
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
|
||||||
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
|
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
|
||||||
calculateNpcNationFinance?: (
|
calculateNpcNationFinance?: (
|
||||||
@@ -957,6 +1015,9 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
let currentGeneral = context.general;
|
let currentGeneral = context.general;
|
||||||
let currentCity = context.city;
|
let currentCity = context.city;
|
||||||
let currentNation = context.nation ?? null;
|
let currentNation = context.nation ?? null;
|
||||||
|
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
|
||||||
|
// 같은 장수 lifecycle의 국호변경은 뒤이은 유니크 획득 로그의 국호를 바꾸지 않는다.
|
||||||
|
const legacyStaticNationName = currentNation?.name ?? '재야';
|
||||||
|
|
||||||
const runAction = (
|
const runAction = (
|
||||||
kind: 'nation' | 'general',
|
kind: 'nation' | 'general',
|
||||||
@@ -973,7 +1034,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
blockedReason?: string;
|
blockedReason?: string;
|
||||||
} => {
|
} => {
|
||||||
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
|
const resolvedDefinition = resolveDefinition(command.action, definitionMap, kind);
|
||||||
const rawArgs = extractArgsRecord(command.args);
|
const rawArgs = extractArgsRecord(command.args);
|
||||||
const parsedArgs = resolvedDefinition.parseArgs(rawArgs);
|
const parsedArgs = resolvedDefinition.parseArgs(rawArgs);
|
||||||
let definition = resolvedDefinition;
|
let definition = resolvedDefinition;
|
||||||
@@ -1098,12 +1159,15 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
time: actionTime,
|
time: actionTime,
|
||||||
maxTechLevel: env.maxTechLevel,
|
maxTechLevel: env.maxTechLevel,
|
||||||
uniqueLottery,
|
uniqueLottery,
|
||||||
|
legacyStaticNationName,
|
||||||
};
|
};
|
||||||
let specificContext = buildActionContext(
|
let specificContext = buildActionContext(
|
||||||
actionKey,
|
actionKey,
|
||||||
baseContext,
|
baseContext,
|
||||||
{
|
{
|
||||||
world: context.world,
|
world: context.world,
|
||||||
|
gameNow: options.now?.() ?? currentGeneral.turnTime,
|
||||||
|
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
|
||||||
scenarioConfig: options.scenarioConfig,
|
scenarioConfig: options.scenarioConfig,
|
||||||
scenarioMeta: options.scenarioMeta,
|
scenarioMeta: options.scenarioMeta,
|
||||||
map: options.map,
|
map: options.map,
|
||||||
@@ -1280,15 +1344,24 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const progressionLogs: LogEntryDraft[] = [];
|
||||||
if (!resolution.alternative && !usedFallback && resolution.completed) {
|
if (!resolution.alternative && !usedFallback && resolution.completed) {
|
||||||
currentGeneral = applyLegacyGeneralProgression(
|
currentGeneral = applyLegacyGeneralProgression(
|
||||||
currentGeneral,
|
currentGeneral,
|
||||||
generalBeforeExecution,
|
generalBeforeExecution,
|
||||||
actionKey,
|
actionKey,
|
||||||
env,
|
env,
|
||||||
logs
|
progressionLogs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
logs.push(
|
||||||
|
...orderLegacyCommandLogs(
|
||||||
|
actionKey,
|
||||||
|
resolution.logs,
|
||||||
|
progressionLogs,
|
||||||
|
resolution.postProgressionLogs
|
||||||
|
)
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
!resolution.alternative &&
|
!resolution.alternative &&
|
||||||
kind === 'nation' &&
|
kind === 'nation' &&
|
||||||
@@ -1418,7 +1491,6 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
logs.push(...resolution.logs);
|
|
||||||
for (const nationId of resolution.destroyedNationIds ?? []) {
|
for (const nationId of resolution.destroyedNationIds ?? []) {
|
||||||
destroyedNationIds.add(nationId);
|
destroyedNationIds.add(nationId);
|
||||||
}
|
}
|
||||||
@@ -1535,10 +1607,9 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
if (resolution.created?.generals) {
|
if (resolution.created?.generals) {
|
||||||
const newGenerals = resolution.created.generals as TurnGeneral[];
|
const newGenerals = resolution.created.generals as TurnGeneral[];
|
||||||
createdGenerals.push(...newGenerals);
|
createdGenerals.push(...newGenerals);
|
||||||
if (worldOverlay) {
|
|
||||||
for (const general of newGenerals) {
|
for (const general of newGenerals) {
|
||||||
worldOverlay.syncGeneral(general);
|
worldOverlay?.syncGeneral(general);
|
||||||
}
|
options.reservedTurns.ensureGeneralTurns(general.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (resolution.created?.nations) {
|
if (resolution.created?.nations) {
|
||||||
@@ -1990,7 +2061,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
src: messageTarget,
|
src: messageTarget,
|
||||||
dest: messageTarget,
|
dest: messageTarget,
|
||||||
text: npcMessage,
|
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'),
|
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||||
option: {},
|
option: {},
|
||||||
});
|
});
|
||||||
@@ -2438,6 +2509,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
|||||||
},
|
},
|
||||||
maxTechLevel: env.maxTechLevel,
|
maxTechLevel: env.maxTechLevel,
|
||||||
uniqueLottery,
|
uniqueLottery,
|
||||||
|
legacyStaticNationName: nation?.name ?? '재야',
|
||||||
};
|
};
|
||||||
const actionContext =
|
const actionContext =
|
||||||
buildActionContext(
|
buildActionContext(
|
||||||
@@ -2476,7 +2548,10 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (input.actionKey === 'che_접경귀환' && (resolution.general as TurnGeneral).cityId === general.cityId) {
|
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);
|
options.world.pushLog(log, general.turnTime);
|
||||||
}
|
}
|
||||||
return { ok: false, reason: '가까운 아국 도시가 없습니다.' };
|
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 = applyLegacyGeneralProgression(
|
||||||
{
|
{
|
||||||
...nextGeneral,
|
...nextGeneral,
|
||||||
@@ -2566,7 +2645,12 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
|||||||
for (const troopId of resolution.deletedTroopIds ?? []) {
|
for (const troopId of resolution.deletedTroopIds ?? []) {
|
||||||
options.world.removeTroop(troopId);
|
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.pushLog(log, general.turnTime);
|
||||||
}
|
}
|
||||||
options.world.updateGeneral(input.generalId, nextGeneral);
|
options.world.updateGeneral(input.generalId, nextGeneral);
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
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';
|
import { resolveWorkspaceRoot } from '../paths.js';
|
||||||
|
|
||||||
@@ -10,6 +15,7 @@ const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands
|
|||||||
|
|
||||||
export interface TurnCommandProfileOptions {
|
export interface TurnCommandProfileOptions {
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
|
scenarioConst?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const readCommandProfile = async (filePath: string): Promise<TurnCommandProfile> => {
|
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);
|
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;
|
const filePath = options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH;
|
||||||
try {
|
const fallback = await readCommandProfile(filePath);
|
||||||
return await readCommandProfile(filePath);
|
return resolveScenarioTurnCommandProfile(options?.scenarioConst, fallback);
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
return DEFAULT_TURN_COMMAND_PROFILE;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise<TurnCommandProfile> =>
|
||||||
|
(await loadScenarioTurnCommandProfile(options)).profile;
|
||||||
|
|||||||
@@ -663,11 +663,10 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
});
|
});
|
||||||
const commandProfile =
|
const commandProfile =
|
||||||
options.commandProfile ??
|
options.commandProfile ??
|
||||||
(options.commandProfilePath
|
(await loadTurnCommandProfile({
|
||||||
? await loadTurnCommandProfile({
|
...(options.commandProfilePath ? { filePath: options.commandProfilePath } : {}),
|
||||||
filePath: options.commandProfilePath,
|
scenarioConst: snapshot.scenarioConfig.const,
|
||||||
})
|
}));
|
||||||
: await loadTurnCommandProfile());
|
|
||||||
let worldRef: InMemoryTurnWorld | null = null;
|
let worldRef: InMemoryTurnWorld | null = null;
|
||||||
let redisConnector: RedisConnector | null = null;
|
let redisConnector: RedisConnector | null = null;
|
||||||
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
||||||
@@ -730,6 +729,10 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
map: snapshot.map,
|
map: snapshot.map,
|
||||||
unitSet: snapshot.unitSet,
|
unitSet: snapshot.unitSet,
|
||||||
getWorld: () => worldRef,
|
getWorld: () => worldRef,
|
||||||
|
now: () => {
|
||||||
|
const wallNow = new Date(clock.nowMs());
|
||||||
|
return worldRef?.getGameNow(wallNow) ?? wallNow;
|
||||||
|
},
|
||||||
commandProfile,
|
commandProfile,
|
||||||
commandEnv: monthlyCommandEnv,
|
commandEnv: monthlyCommandEnv,
|
||||||
calculateNpcNationFinance: (financeWorld, nation, currentMonth) =>
|
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 = {
|
const bidder = {
|
||||||
id: 7,
|
id: 7,
|
||||||
name: '관우',
|
name: '관우',
|
||||||
@@ -390,13 +390,6 @@ describe('unique auction inheritance log compatibility', () => {
|
|||||||
itemRawName: '칠성검',
|
itemRawName: '칠성검',
|
||||||
})
|
})
|
||||||
).toEqual([
|
).toEqual([
|
||||||
expect.objectContaining({
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.MONTH,
|
|
||||||
generalId: 7,
|
|
||||||
text: '<C>칠성검(+12)</>을 습득했습니다!',
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
@@ -405,10 +398,11 @@ describe('unique auction inheritance log compatibility', () => {
|
|||||||
text: '<C>칠성검(+12)</>을 습득',
|
text: '<C>칠성검(+12)</>을 습득',
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.SUMMARY,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
text: '<Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
|
generalId: 7,
|
||||||
|
text: '<C>칠성검(+12)</>을 습득했습니다!',
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
@@ -416,6 +410,12 @@ describe('unique auction inheritance log compatibility', () => {
|
|||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
text: '<C><b>【보물수배】</b></><D><b>촉</b></>의 <Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
|
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(),
|
state: buildState(),
|
||||||
schedule,
|
schedule,
|
||||||
map,
|
map,
|
||||||
reservedTurnStoreOptions: { maxGeneralTurns: 10, maxNationTurns: 12 },
|
reservedTurnStoreOptions: { maxGeneralTurns: 30, maxNationTurns: 12 },
|
||||||
commandRngFactory: ({ actionKey }) =>
|
commandRngFactory: ({ actionKey }) =>
|
||||||
actionKey === 'che_의병모집'
|
actionKey === 'che_의병모집'
|
||||||
? new RandUtil(new SequenceRNG([0, 0.26, 0.51, 0.76]))
|
? 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(created.map((general) => general.npcState).sort()).toEqual([3, 4, 4, 4]);
|
||||||
expect(claims.every(Boolean)).toBe(true);
|
expect(claims.every(Boolean)).toBe(true);
|
||||||
expect(new Set(claims.map((claim) => claim?.poolEntryId))).toEqual(new Set([1, 2, 3, 4]));
|
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 });
|
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 () => {
|
it('quantizes integer general columns at each in-memory DB mutation boundary', async () => {
|
||||||
const harness = await createTurnTestHarness({
|
const harness = await createTurnTestHarness({
|
||||||
snapshot: makeSnapshot(makeGeneral()),
|
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 () => {
|
it('keeps fractional nation rewards in the same general object until the following command is persisted', async () => {
|
||||||
const twoCityMap = {
|
const twoCityMap = {
|
||||||
...map,
|
...map,
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
|
|||||||
return {
|
return {
|
||||||
world,
|
world,
|
||||||
worldRef,
|
worldRef,
|
||||||
|
mockPrisma,
|
||||||
reservedTurnStore,
|
reservedTurnStore,
|
||||||
handler,
|
handler,
|
||||||
processor,
|
processor,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
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 { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
@@ -562,6 +569,25 @@ describe('my information world commands', () => {
|
|||||||
experience: recruiter.experience + 100,
|
experience: recruiter.experience + 100,
|
||||||
dedication: recruiter.dedication + 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 () => {
|
it('preserves the Ref uprising precheck order and messages after the game starts', async () => {
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ describe('도시 점령 시 국가 멸망 처리', () => {
|
|||||||
strongFrontCity.supplyState = 1;
|
strongFrontCity.supplyState = 1;
|
||||||
const conflictCity = cities.find((city) => city.id === 3)!;
|
const conflictCity = cities.find((city) => city.id === 3)!;
|
||||||
conflictCity.conflict = { 2: 100, 1: 50 };
|
conflictCity.conflict = { 2: 100, 1: 50 };
|
||||||
|
const emptiedConflictCity = cities.find((city) => city.id === 4)!;
|
||||||
|
emptiedConflictCity.conflict = { 2: 25 };
|
||||||
|
|
||||||
const unitSet: UnitSetDefinition = {
|
const unitSet: UnitSetDefinition = {
|
||||||
id: 'test_unit_set',
|
id: 'test_unit_set',
|
||||||
@@ -291,6 +293,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
|
|||||||
expect(world.listEvents('destroy_nation')).toEqual([]);
|
expect(world.listEvents('destroy_nation')).toEqual([]);
|
||||||
expect(world.getState().meta.block_change_scout).toBeUndefined();
|
expect(world.getState().meta.block_change_scout).toBeUndefined();
|
||||||
expect(world.getCityById(conflictCity.id)?.conflict).toEqual({ 1: 50 });
|
expect(world.getCityById(conflictCity.id)?.conflict).toEqual({ 1: 50 });
|
||||||
|
expect(world.getCityById(emptiedConflictCity.id)?.conflict).toEqual([]);
|
||||||
|
|
||||||
const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
|
const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
|
||||||
expect(updatedWeakGeneral?.nationId).toBe(0);
|
expect(updatedWeakGeneral?.nationId).toBe(0);
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ describe('NPC 일반 내정 턴', () => {
|
|||||||
});
|
});
|
||||||
await reservedTurnStore.loadAll();
|
await reservedTurnStore.loadAll();
|
||||||
|
|
||||||
|
const logicalGameNow = addMinutes(mockDate, 3);
|
||||||
const wrapper = { world: null as InMemoryTurnWorld | null };
|
const wrapper = { world: null as InMemoryTurnWorld | null };
|
||||||
const handler = await createReservedTurnHandler({
|
const handler = await createReservedTurnHandler({
|
||||||
reservedTurns: reservedTurnStore,
|
reservedTurns: reservedTurnStore,
|
||||||
@@ -259,6 +260,7 @@ describe('NPC 일반 내정 턴', () => {
|
|||||||
map: MINIMAL_MAP as any,
|
map: MINIMAL_MAP as any,
|
||||||
unitSet: snapshot.unitSet,
|
unitSet: snapshot.unitSet,
|
||||||
getWorld: () => wrapper.world,
|
getWorld: () => wrapper.world,
|
||||||
|
now: () => logicalGameNow,
|
||||||
});
|
});
|
||||||
|
|
||||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
@@ -296,6 +298,7 @@ describe('NPC 일반 내정 턴', () => {
|
|||||||
msgType: 'public',
|
msgType: 'public',
|
||||||
text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다',
|
text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다',
|
||||||
src: expect.objectContaining({ generalId: 1, generalName: 'NPC_무장', nationId: 1 }),
|
src: expect.objectContaining({ generalId: 1, generalName: 'NPC_무장', nationId: 1 }),
|
||||||
|
time: logicalGameNow,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
applyPersistedRankRowsToMeta,
|
applyPersistedRankRowsToMeta,
|
||||||
|
buildInitialRankRows,
|
||||||
|
buildLegacyComparableInitialRankRows,
|
||||||
buildLegacyComparableRankRows,
|
buildLegacyComparableRankRows,
|
||||||
buildPersistedRankRows,
|
buildPersistedRankRows,
|
||||||
rankMetaKey,
|
rankMetaKey,
|
||||||
@@ -34,13 +36,39 @@ describe('rank data projection', () => {
|
|||||||
{ generalId: 7, nationId: 2, type: 'dex1', value: 12 },
|
{ generalId: 7, nationId: 2, type: 'dex1', value: 12 },
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
expect(buildLegacyComparableRankRows({
|
expect(
|
||||||
|
buildLegacyComparableRankRows({
|
||||||
id: 7,
|
id: 7,
|
||||||
nationId: 2,
|
nationId: 2,
|
||||||
experience: 10.5,
|
experience: 10.5,
|
||||||
dedication: 20.49,
|
dedication: 20.49,
|
||||||
meta: {},
|
meta: {},
|
||||||
})).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
|
})
|
||||||
|
).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
|
||||||
|
|
||||||
|
const initialRows = buildInitialRankRows({
|
||||||
|
id: 8,
|
||||||
|
nationId: 2,
|
||||||
|
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', () => {
|
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', () => {
|
describe('unique lottery on general commands', () => {
|
||||||
it('awards a unique item for eligible commands', async () => {
|
it('awards a unique item for eligible commands', async () => {
|
||||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
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 = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
generals: generals as any,
|
generals: generals as any,
|
||||||
cities: [
|
cities: [
|
||||||
@@ -172,6 +176,14 @@ describe('unique lottery on general commands', () => {
|
|||||||
expect(result.general?.role.items.weapon).toBe('che_무기_12_칠성검');
|
expect(result.general?.role.items.weapon).toBe('che_무기_12_칠성검');
|
||||||
const logTexts = (result.logs ?? []).map((entry) => entry.text);
|
const logTexts = (result.logs ?? []).map((entry) => entry.text);
|
||||||
expect(logTexts.some((text) => text.includes('【아이템】'))).toBe(true);
|
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 () => {
|
it('does not award a unique item reserved by an active auction', async () => {
|
||||||
|
|||||||
@@ -54,8 +54,16 @@ pnpm check:legacy:nation
|
|||||||
즉시 외교 3종의 core log는 명령 파일이 아니라 공통
|
즉시 외교 3종의 core log는 명령 파일이 아니라 공통
|
||||||
`packages/logic/src/diplomacy/instantResponse.ts`에서 생성됩니다.
|
`packages/logic/src/diplomacy/instantResponse.ts`에서 생성됩니다.
|
||||||
정적 log 비교의 이 예외는 명령별 allowlist로 제한하며,
|
정적 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:
|
기본 integration:
|
||||||
|
|
||||||
@@ -82,17 +90,25 @@ pnpm --filter @sammo-ts/integration-tests test:integration
|
|||||||
|
|
||||||
Canonical 비교값은 의미 단위로 정규화합니다.
|
Canonical 비교값은 의미 단위로 정규화합니다.
|
||||||
|
|
||||||
- general: 위치, 소속, 자원, 병력, 능력치, 경험·공헌, penalty, aux, turn time
|
- general: 위치, 소속, owner identity, 자원, 병력, 능력치, 경험·공헌·rank mirror,
|
||||||
- nation: 자원, level, 수도, 외교, 정책, aux
|
affinity·NPC origin/state, penalty, aux, turn time
|
||||||
|
- nation: 자원, level, 수도, 외교, 정책, flag·spy·rate·bill·secret limit, 저장 `gennum`, aux
|
||||||
- city: 소유권, 인구·내정·방어, conflict, supply와 인접 상태
|
- city: 소유권, 인구·내정·방어, conflict, supply와 인접 상태
|
||||||
|
- troop과 실행 중 새로 생긴 general/city/nation/troop의 created-entity closure
|
||||||
- queue: command, args, term, next available, revision
|
- 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
|
- execution: RNG trace, handler 순서, input event와 checkpoint
|
||||||
|
|
||||||
DB auto ID, 생성 시각처럼 의미 없는 차이는 comparator에서 이름과 이유를
|
DB auto ID, 생성 시각처럼 의미 없는 차이는 comparator에서 이름과 이유를
|
||||||
명시합니다. 의미 field를 ignore하거나 숫자 허용 범위를 넓혀 mismatch를
|
명시합니다. 의미 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
|
## RNG
|
||||||
|
|
||||||
Gameplay RNG는 `LiteHashDRBG`, `RNG`, `RandUtil` 경로를 추적합니다.
|
Gameplay RNG는 `LiteHashDRBG`, `RNG`, `RandUtil` 경로를 추적합니다.
|
||||||
|
|||||||
@@ -28,12 +28,15 @@ export interface GeneralActionResolveContext<
|
|||||||
rng: RandomGenerator;
|
rng: RandomGenerator;
|
||||||
city?: City;
|
city?: City;
|
||||||
nation?: Nation | null;
|
nation?: Nation | null;
|
||||||
|
/** Ref General이 한 장수 lifecycle 동안 유지하는 정적 국가 조회값. */
|
||||||
|
legacyStaticNationName?: string;
|
||||||
addLog(message: string, options?: Partial<Omit<LogEntryDraft, 'text'>>): void;
|
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<
|
export type GeneralActionResolveInputContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> = Omit<
|
||||||
GeneralActionResolveContext<TriggerState>,
|
GeneralActionResolveContext<TriggerState>,
|
||||||
'addLog'
|
'addLog' | 'addPostProgressionLog'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface TurnScheduleContext {
|
export interface TurnScheduleContext {
|
||||||
@@ -125,6 +128,7 @@ export interface GeneralActionResolution {
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
nextTurnAt: Date;
|
nextTurnAt: Date;
|
||||||
logs: LogEntryDraft[];
|
logs: LogEntryDraft[];
|
||||||
|
postProgressionLogs: LogEntryDraft[];
|
||||||
effects: GeneralActionEffect[];
|
effects: GeneralActionEffect[];
|
||||||
destroyedNationIds?: NationId[];
|
destroyedNationIds?: NationId[];
|
||||||
created?: {
|
created?: {
|
||||||
@@ -216,6 +220,7 @@ export const createLogEffect = (message: string, options: Partial<Omit<LogEntryD
|
|||||||
...(options.userId !== undefined ? { userId: options.userId } : {}),
|
...(options.userId !== undefined ? { userId: options.userId } : {}),
|
||||||
...(options.subType !== undefined ? { subType: options.subType } : {}),
|
...(options.subType !== undefined ? { subType: options.subType } : {}),
|
||||||
...(options.meta !== undefined ? { meta: options.meta } : {}),
|
...(options.meta !== undefined ? { meta: options.meta } : {}),
|
||||||
|
...(options.legacyFlushGroup !== undefined ? { legacyFlushGroup: options.legacyFlushGroup } : {}),
|
||||||
format: options.format ?? LogFormat.MONTH,
|
format: options.format ?? LogFormat.MONTH,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -357,6 +362,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
args: Args
|
args: Args
|
||||||
): GeneralActionResolution => {
|
): GeneralActionResolution => {
|
||||||
const logs: LogEntryDraft[] = [];
|
const logs: LogEntryDraft[] = [];
|
||||||
|
const postProgressionLogs: LogEntryDraft[] = [];
|
||||||
const accumulator: ActionResolutionAccumulator = {
|
const accumulator: ActionResolutionAccumulator = {
|
||||||
createdGenerals: [],
|
createdGenerals: [],
|
||||||
createdNations: [],
|
createdNations: [],
|
||||||
@@ -372,6 +378,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
} as WorldState<TriggerState>,
|
} as WorldState<TriggerState>,
|
||||||
(draft) => {
|
(draft) => {
|
||||||
const addLog = createActionLogSink(context, logs);
|
const addLog = createActionLogSink(context, logs);
|
||||||
|
const addPostProgressionLog = createActionLogSink(context, postProgressionLogs);
|
||||||
|
|
||||||
outcome = resolver.resolve(
|
outcome = resolver.resolve(
|
||||||
{
|
{
|
||||||
@@ -381,6 +388,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
city: castDraft(draft.city),
|
city: castDraft(draft.city),
|
||||||
nation: castDraft(draft.nation),
|
nation: castDraft(draft.nation),
|
||||||
addLog,
|
addLog,
|
||||||
|
addPostProgressionLog,
|
||||||
} as GeneralActionResolveContext<TriggerState>,
|
} as GeneralActionResolveContext<TriggerState>,
|
||||||
args
|
args
|
||||||
);
|
);
|
||||||
@@ -405,6 +413,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
completed: outcome?.completed !== false,
|
completed: outcome?.completed !== false,
|
||||||
nextTurnAt,
|
nextTurnAt,
|
||||||
logs,
|
logs,
|
||||||
|
postProgressionLogs,
|
||||||
effects: accumulator.pendingEffects,
|
effects: accumulator.pendingEffects,
|
||||||
...(outcome?.alternative ? { alternative: outcome.alternative } : {}),
|
...(outcome?.alternative ? { alternative: outcome.alternative } : {}),
|
||||||
...(outcome?.deletedTroopIds?.length ? { deletedTroopIds: outcome.deletedTroopIds } : {}),
|
...(outcome?.deletedTroopIds?.length ? { deletedTroopIds: outcome.deletedTroopIds } : {}),
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface ActionRandomSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionContextGeneral extends General {
|
export interface ActionContextGeneral extends General {
|
||||||
|
picture?: string | null;
|
||||||
|
imageServer?: number;
|
||||||
turnTime: Date;
|
turnTime: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +25,8 @@ export type ActionContextBase = {
|
|||||||
worldView?: GeneralWorldView;
|
worldView?: GeneralWorldView;
|
||||||
rng: ActionRandomSource;
|
rng: ActionRandomSource;
|
||||||
uniqueLottery?: UniqueLotteryRunner;
|
uniqueLottery?: UniqueLotteryRunner;
|
||||||
|
/** Ref General#getStaticNation()이 수뇌턴과 장수턴 사이에 유지하는 캐시값. */
|
||||||
|
legacyStaticNationName?: string;
|
||||||
time?: {
|
time?: {
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
@@ -72,6 +76,10 @@ export interface ActionContextWorldRef {
|
|||||||
|
|
||||||
export interface ActionContextOptions<TArgs extends Record<string, unknown> = Record<string, unknown>> {
|
export interface ActionContextOptions<TArgs extends Record<string, unknown> = Record<string, unknown>> {
|
||||||
world: ActionContextWorldState;
|
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;
|
scenarioConfig: ScenarioConfig;
|
||||||
scenarioMeta?: ScenarioMeta;
|
scenarioMeta?: ScenarioMeta;
|
||||||
map?: MapDefinition;
|
map?: MapDefinition;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GENERAL_TURN_COMMAND_KEYS, isGeneralTurnCommandKey, type GeneralTurnCommandKey } from './general/index.js';
|
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 { 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';
|
import { TurnCommandProfileInputSchema } from '../../resources/turnCommandSchema.js';
|
||||||
|
|
||||||
export interface TurnCommandProfile {
|
export interface TurnCommandProfile {
|
||||||
@@ -8,31 +8,43 @@ export interface TurnCommandProfile {
|
|||||||
nation: NationTurnCommandKey[];
|
nation: NationTurnCommandKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const asStringArrayOrNull = (value: unknown): string[] | null => {
|
export interface TurnCommandGroup<Key extends string> {
|
||||||
if (!Array.isArray(value)) {
|
category: string;
|
||||||
return null;
|
commands: Key[];
|
||||||
}
|
}
|
||||||
const list = asStringArray(value);
|
|
||||||
return list.length > 0 ? list : null;
|
export interface ScenarioTurnCommandProfileResolution {
|
||||||
};
|
profile: TurnCommandProfile;
|
||||||
|
generalGroups: Array<TurnCommandGroup<GeneralTurnCommandKey>> | null;
|
||||||
|
nationGroups: Array<TurnCommandGroup<NationTurnCommandKey>> | null;
|
||||||
|
}
|
||||||
|
|
||||||
const parseKeyList = <T extends string>(options: {
|
const parseKeyList = <T extends string>(options: {
|
||||||
raw: unknown;
|
raw: unknown;
|
||||||
defaults: T[];
|
|
||||||
isKey: (value: string) => value is T;
|
isKey: (value: string) => value is T;
|
||||||
label: string;
|
label: string;
|
||||||
}): T[] => {
|
}): T[] => {
|
||||||
const rawList = asStringArrayOrNull(options.raw);
|
if (!Array.isArray(options.raw) || options.raw.length === 0) {
|
||||||
if (!rawList) {
|
throw new Error(`${options.label} command profile must be a non-empty array.`);
|
||||||
return options.defaults;
|
|
||||||
}
|
}
|
||||||
const parsed: T[] = [];
|
const parsed: T[] = [];
|
||||||
for (const value of rawList) {
|
const seen = new Set<T>();
|
||||||
|
for (const value of asStringArray(options.raw)) {
|
||||||
if (!options.isKey(value)) {
|
if (!options.isKey(value)) {
|
||||||
throw new Error(`Unknown ${options.label} command key: ${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);
|
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;
|
return parsed;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,27 +53,101 @@ export const DEFAULT_TURN_COMMAND_PROFILE: TurnCommandProfile = {
|
|||||||
nation: [...NATION_TURN_COMMAND_KEYS],
|
nation: [...NATION_TURN_COMMAND_KEYS],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseTurnCommandProfile = (
|
export const parseTurnCommandProfile = (raw: unknown): TurnCommandProfile => {
|
||||||
raw: unknown,
|
|
||||||
fallback: TurnCommandProfile = DEFAULT_TURN_COMMAND_PROFILE
|
|
||||||
): TurnCommandProfile => {
|
|
||||||
const parsed = TurnCommandProfileInputSchema.safeParse(raw);
|
const parsed = TurnCommandProfileInputSchema.safeParse(raw);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return fallback;
|
throw new Error(`Invalid turn command profile: ${parsed.error.message}`);
|
||||||
}
|
}
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
return {
|
return {
|
||||||
general: parseKeyList({
|
general: parseKeyList({
|
||||||
raw: data.general,
|
raw: data.general,
|
||||||
defaults: fallback.general,
|
|
||||||
isKey: isGeneralTurnCommandKey,
|
isKey: isGeneralTurnCommandKey,
|
||||||
label: 'general',
|
label: 'general',
|
||||||
}),
|
}),
|
||||||
nation: parseKeyList({
|
nation: parseKeyList({
|
||||||
raw: data.nation,
|
raw: data.nation,
|
||||||
defaults: fallback.nation,
|
|
||||||
isKey: isNationTurnCommandKey,
|
isKey: isNationTurnCommandKey,
|
||||||
label: 'nation',
|
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 currentArgs = initialArgs;
|
||||||
let loopLimit = 5; // Prevent infinite loops
|
let loopLimit = 5; // Prevent infinite loops
|
||||||
const accumulatedLogs: LogEntryDraft[] = [];
|
const accumulatedLogs: LogEntryDraft[] = [];
|
||||||
|
const accumulatedPostProgressionLogs: LogEntryDraft[] = [];
|
||||||
|
|
||||||
while (loopLimit > 0) {
|
while (loopLimit > 0) {
|
||||||
loopLimit--;
|
loopLimit--;
|
||||||
@@ -32,6 +33,7 @@ export const processGeneralActionWithFallback = async <TriggerState extends Gene
|
|||||||
|
|
||||||
if (resolution.alternative) {
|
if (resolution.alternative) {
|
||||||
accumulatedLogs.push(...resolution.logs);
|
accumulatedLogs.push(...resolution.logs);
|
||||||
|
accumulatedPostProgressionLogs.push(...resolution.postProgressionLogs);
|
||||||
const { commandKey, args } = resolution.alternative;
|
const { commandKey, args } = resolution.alternative;
|
||||||
const nextResolver = await commandLoader.load(commandKey);
|
const nextResolver = await commandLoader.load(commandKey);
|
||||||
|
|
||||||
@@ -46,6 +48,9 @@ export const processGeneralActionWithFallback = async <TriggerState extends Gene
|
|||||||
if (accumulatedLogs.length > 0) {
|
if (accumulatedLogs.length > 0) {
|
||||||
resolution.logs.unshift(...accumulatedLogs);
|
resolution.logs.unshift(...accumulatedLogs);
|
||||||
}
|
}
|
||||||
|
if (accumulatedPostProgressionLogs.length > 0) {
|
||||||
|
resolution.postProgressionLogs.unshift(...accumulatedPostProgressionLogs);
|
||||||
|
}
|
||||||
return resolution;
|
return resolution;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export class ActionResolver<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,13 +140,8 @@ export class ActionDefinition<
|
|||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
});
|
});
|
||||||
context.addLog(`<Y>${general.name}</>${josaYi} <G><b>${cityName}</b></>에서 거병`, {
|
// Ref queues the national history entry on the actor logger created
|
||||||
scope: LogScope.NATION,
|
// while nationID is still 0, so ActionLogger::flush discards it.
|
||||||
nationId: newNationId,
|
|
||||||
category: LogCategory.HISTORY,
|
|
||||||
format: LogFormat.YEAR_MONTH,
|
|
||||||
});
|
|
||||||
|
|
||||||
tryApplyUniqueLottery(context, {
|
tryApplyUniqueLottery(context, {
|
||||||
acquireType: '아이템',
|
acquireType: '아이템',
|
||||||
reason: ACTION_NAME,
|
reason: ACTION_NAME,
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export class ActionDefinition<
|
|||||||
`군량 <C>${Math.round(buyAmount).toLocaleString()}</>을 사서 자금 <C>${Math.round(
|
`군량 <C>${Math.round(buyAmount).toLocaleString()}</>을 사서 자금 <C>${Math.round(
|
||||||
sellAmount
|
sellAmount
|
||||||
).toLocaleString()}</>을 썼습니다.`,
|
).toLocaleString()}</>을 썼습니다.`,
|
||||||
{ format: LogFormat.PLAIN }
|
{ format: LogFormat.MONTH }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const sellAmount = Math.min(args.amount, general.rice);
|
const sellAmount = Math.min(args.amount, general.rice);
|
||||||
@@ -118,7 +118,7 @@ export class ActionDefinition<
|
|||||||
`군량 <C>${Math.round(sellAmount).toLocaleString()}</>을 팔아 자금 <C>${Math.round(
|
`군량 <C>${Math.round(sellAmount).toLocaleString()}</>을 팔아 자금 <C>${Math.round(
|
||||||
buyAmount
|
buyAmount
|
||||||
).toLocaleString()}</>을 얻었습니다.`,
|
).toLocaleString()}</>을 얻었습니다.`,
|
||||||
{ format: LogFormat.PLAIN }
|
{ format: LogFormat.MONTH }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ import type {
|
|||||||
GeneralActionResolver,
|
GeneralActionResolver,
|
||||||
GeneralActionEffect,
|
GeneralActionEffect,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createGeneralPatchEffect, createLogEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js';
|
import { createGeneralPatchEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogScope } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
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 type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.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 type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ export interface EmployResolveContext<
|
|||||||
destGeneral?: General;
|
destGeneral?: General;
|
||||||
env?: TurnCommandEnv;
|
env?: TurnCommandEnv;
|
||||||
messageTime: Date;
|
messageTime: Date;
|
||||||
|
messageSharedIconBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '등용';
|
const ACTION_NAME = '등용';
|
||||||
@@ -88,10 +89,9 @@ export class ActionResolver<
|
|||||||
> implements GeneralActionResolver<TriggerState, EmployArgs> {
|
> implements GeneralActionResolver<TriggerState, EmployArgs> {
|
||||||
readonly key = ACTION_KEY;
|
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 ctx = context as EmployResolveContext<TriggerState>;
|
||||||
const general = ctx.general;
|
const general = ctx.general;
|
||||||
const { destGeneralId } = args;
|
|
||||||
|
|
||||||
const destGeneral = ctx.destGeneral;
|
const destGeneral = ctx.destGeneral;
|
||||||
if (!destGeneral) {
|
if (!destGeneral) {
|
||||||
@@ -137,44 +137,18 @@ export class ActionResolver<
|
|||||||
const destNation = ctx.worldView
|
const destNation = ctx.worldView
|
||||||
?.listNations?.()
|
?.listNations?.()
|
||||||
.find((candidate) => candidate.id === destGeneral.nationId);
|
.find((candidate) => candidate.id === destGeneral.nationId);
|
||||||
const josaRo = JosaUtil.pick(ctx.nation.name, '로');
|
const message = buildScoutMessageDraft({
|
||||||
effects.push(
|
srcGeneral: general,
|
||||||
createMessageEffect({
|
destGeneral,
|
||||||
msgType: 'private',
|
srcNation: ctx.nation,
|
||||||
src: {
|
destNation: destNation ?? null,
|
||||||
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,
|
time: ctx.messageTime,
|
||||||
validUntil: new Date('9999-12-31T12:59:59.000Z'),
|
...(ctx.messageSharedIconBaseUrl ? { sharedIconBaseUrl: ctx.messageSharedIconBaseUrl } : {}),
|
||||||
option: { action: 'scout' },
|
});
|
||||||
})
|
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 };
|
return { effects };
|
||||||
}
|
}
|
||||||
@@ -249,10 +223,12 @@ export const actionContextBuilder = (base: ActionContextBase, options: ActionCon
|
|||||||
...base,
|
...base,
|
||||||
destGeneral,
|
destGeneral,
|
||||||
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
||||||
|
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
|
||||||
messageTime:
|
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
|
? (base.general as General & { turnTime: Date }).turnTime
|
||||||
: options.world.lastTurnTime,
|
: options.world.lastTurnTime),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -86,14 +86,14 @@ export class ActionResolver<
|
|||||||
// Self Log
|
// Self Log
|
||||||
context.addLog(`<D>${destNationName}</>${josaRo} 망명하여 수도로 이동합니다.`, {
|
context.addLog(`<D>${destNationName}</>${josaRo} 망명하여 수도로 이동합니다.`, {
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global Log
|
// Global Log
|
||||||
context.addLog(`<Y>${generalName}</>${josaYi} <D><b>${destNationName}</b></>${josaRo} <S>망명</>하였습니다.`, {
|
context.addLog(`<Y>${generalName}</>${josaYi} <D><b>${destNationName}</b></>${josaRo} <S>망명</>하였습니다.`, {
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.SUMMARY,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Recruiter Rewards
|
// 2. Recruiter Rewards
|
||||||
@@ -143,6 +143,7 @@ export class ActionResolver<
|
|||||||
generalId: destGeneral.id,
|
generalId: destGeneral.id,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -164,6 +165,7 @@ export class ActionResolver<
|
|||||||
generalId: destGeneral.id,
|
generalId: destGeneral.id,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -283,18 +285,22 @@ export class ActionResolver<
|
|||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
});
|
});
|
||||||
context.addLog(`<Y>${generalName}</> 등용에 성공했습니다.`, {
|
effects.push(
|
||||||
|
createLogEffect(`<Y>${generalName}</> 등용에 성공했습니다.`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
generalId: destGeneral.id,
|
generalId: destGeneral.id,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
});
|
legacyFlushGroup: 1,
|
||||||
context.addLog(`<Y>${generalName}</> 등용에 성공`, {
|
}),
|
||||||
|
createLogEffect(`<Y>${generalName}</> 등용에 성공`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
generalId: destGeneral.id,
|
generalId: destGeneral.id,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
});
|
legacyFlushGroup: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const deletedTroopIds: number[] = [];
|
const deletedTroopIds: number[] = [];
|
||||||
if (general.troopId === general.id) {
|
if (general.troopId === general.id) {
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ import type {
|
|||||||
GeneralActionOutcome,
|
GeneralActionOutcome,
|
||||||
GeneralActionResolveContext,
|
GeneralActionResolveContext,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import {
|
import { createGeneralPatchEffect, createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
createGeneralPatchEffect,
|
|
||||||
createLogEffect,
|
|
||||||
createNationPatchEffect,
|
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
@@ -76,11 +72,13 @@ export class ActionDefinition<
|
|||||||
{
|
{
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${lord.name}</>에게서 군주자리를 찬탈`, {
|
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${lord.name}</>에게서 군주자리를 찬탈`, {
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
});
|
});
|
||||||
context.addLog('모반에 성공했습니다.', {
|
context.addLog('모반에 성공했습니다.', {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
@@ -90,6 +88,7 @@ export class ActionDefinition<
|
|||||||
context.addLog(`모반으로 <D><b>${nation.name}</b></>의 군주자리를 찬탈`, {
|
context.addLog(`모반으로 <D><b>${nation.name}</b></>의 군주자리를 찬탈`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
effects.push(
|
effects.push(
|
||||||
@@ -97,7 +96,8 @@ export class ActionDefinition<
|
|||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
generalId: lord.id,
|
generalId: lord.id,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}),
|
}),
|
||||||
createLogEffect(
|
createLogEffect(
|
||||||
`<D><b>${general.name}</b></>의 모반으로 인해 <D><b>${nation.name}</b></>의 군주자리를 박탈당함`,
|
`<D><b>${general.name}</b></>의 모반으로 인해 <D><b>${nation.name}</b></>의 군주자리를 박탈당함`,
|
||||||
@@ -105,7 +105,8 @@ export class ActionDefinition<
|
|||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
generalId: lord.id,
|
generalId: lord.id,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import type {
|
|||||||
GeneralActionOutcome,
|
GeneralActionOutcome,
|
||||||
GeneralActionResolveContext,
|
GeneralActionResolveContext,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import {
|
import { createGeneralPatchEffect, createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
createGeneralPatchEffect,
|
|
||||||
createLogEffect,
|
|
||||||
createNationPatchEffect,
|
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -107,11 +103,13 @@ export class ActionDefinition<
|
|||||||
{
|
{
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${destGeneral.name}</>에게 선양`, {
|
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${destGeneral.name}</>에게 선양`, {
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
});
|
});
|
||||||
context.addLog(`<Y>${destGeneral.name}</>에게 군주의 자리를 물려줍니다.`, {
|
context.addLog(`<Y>${destGeneral.name}</>에게 군주의 자리를 물려줍니다.`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
@@ -121,6 +119,7 @@ export class ActionDefinition<
|
|||||||
context.addLog(`<D><b>${nation.name}</b></>의 군주자리를 <Y>${destGeneral.name}</>에게 선양`, {
|
context.addLog(`<D><b>${nation.name}</b></>의 군주자리를 <Y>${destGeneral.name}</>에게 선양`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
effects.push(
|
effects.push(
|
||||||
@@ -128,13 +127,15 @@ export class ActionDefinition<
|
|||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
generalId: destGeneral.id,
|
generalId: destGeneral.id,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}),
|
}),
|
||||||
createLogEffect(`<D><b>${nation.name}</b></>의 군주자리를 물려 받음`, {
|
createLogEffect(`<D><b>${nation.name}</b></>의 군주자리를 물려 받음`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
generalId: destGeneral.id,
|
generalId: destGeneral.id,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}),
|
}),
|
||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export class ActionResolver<
|
|||||||
context.addLog(`<Y>${general.name}</>${josaYi} <R>은퇴</>하고 그 자손이 유지를 이어받았습니다.`, {
|
context.addLog(`<Y>${general.name}</>${josaYi} <R>은퇴</>하고 그 자손이 유지를 이어받았습니다.`, {
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.SUMMARY,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.RAWTEXT,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
context.addLog('나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.', {
|
context.addLog('나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.', {
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import type {
|
|||||||
GeneralActionResolver,
|
GeneralActionResolver,
|
||||||
GeneralActionEffect,
|
GeneralActionEffect,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
@@ -113,6 +113,17 @@ export class ActionResolver<
|
|||||||
target.id
|
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 };
|
return { effects };
|
||||||
|
|||||||
@@ -493,6 +493,7 @@ export class ActionResolver<
|
|||||||
const meta: GeneralMeta = {
|
const meta: GeneralMeta = {
|
||||||
killturn,
|
killturn,
|
||||||
npcType: NPC_TYPE,
|
npcType: NPC_TYPE,
|
||||||
|
npc_org: NPC_TYPE,
|
||||||
explevel: 0,
|
explevel: 0,
|
||||||
dedlevel: 1,
|
dedlevel: 1,
|
||||||
crewTypeId: this.env.defaultCrewTypeId,
|
crewTypeId: this.env.defaultCrewTypeId,
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export class ActionDefinition<
|
|||||||
context.addLog(`<D><b>${destNation.name}</b></>에 임관`, {
|
context.addLog(`<D><b>${destNation.name}</b></>에 임관`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
});
|
});
|
||||||
context.addLog(`<Y>${general.name}</>${josaYi} <D><b>${destNation.name}</b></>에 <S>임관</>했습니다.`, {
|
context.addLog(`<Y>${general.name}</>${josaYi} <D><b>${destNation.name}</b></>에 <S>임관</>했습니다.`, {
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export class ActionDefinition<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: destGeneral.id,
|
generalId: destGeneral.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}),
|
}),
|
||||||
createLogEffect(`<Y>${destGeneral.name}</>에게 ${resName} <C>${amountText}</>을 증여했습니다.`, {
|
createLogEffect(`<Y>${destGeneral.name}</>에게 ${resName} <C>${amountText}</>을 증여했습니다.`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export class ActionDefinition<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
generalId: member.id,
|
generalId: member.id,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export class ActionResolver<
|
|||||||
: '<C>↓</>미미';
|
: '<C>↓</>미미';
|
||||||
ctx.addLog(`【<span class='ev_notice'>${destNation.name}</span>】아국대비기술:${techText}`, {
|
ctx.addLog(`【<span class='ev_notice'>${destNation.name}</span>】아국대비기술:${techText}`, {
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.RAWTEXT,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (distance === 2) {
|
} else if (distance === 2) {
|
||||||
@@ -317,12 +317,7 @@ export class ActionDefinition<
|
|||||||
return [notOccupiedDestCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
return [notOccupiedDestCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
||||||
}
|
}
|
||||||
|
|
||||||
formatConstraintFailure(
|
formatConstraintFailure(reason: string, _ctx: ConstraintContext, args: SpyArgs, view: StateView): string | null {
|
||||||
reason: string,
|
|
||||||
_ctx: ConstraintContext,
|
|
||||||
args: SpyArgs,
|
|
||||||
view: StateView
|
|
||||||
): string | null {
|
|
||||||
return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'location');
|
return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'location');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
createCityPatchEffect,
|
createCityPatchEffect,
|
||||||
createDiplomacyPatchEffect,
|
createDiplomacyPatchEffect,
|
||||||
createGeneralPatchEffect,
|
createGeneralPatchEffect,
|
||||||
|
createMessageEffect,
|
||||||
createNationPatchEffect,
|
createNationPatchEffect,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { JosaUtil, LiteHashDRBG } from '@sammo-ts/common';
|
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 type { WarAftermathConfig, WarEngineConfig, WarTimeContext } from '@sammo-ts/logic/war/types.js';
|
||||||
import { resolveWarAftermath } from '@sammo-ts/logic/war/aftermath.js';
|
import { resolveWarAftermath } from '@sammo-ts/logic/war/aftermath.js';
|
||||||
import { resolveWarBattle } from '@sammo-ts/logic/war/engine.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 { WarActionModule } from '@sammo-ts/logic/war/actions.js';
|
||||||
import type { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
|
import type { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
|
||||||
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
||||||
@@ -63,6 +65,8 @@ export interface DispatchResolveContext<
|
|||||||
seedBase: string;
|
seedBase: string;
|
||||||
warConfig: WarEngineConfig;
|
warConfig: WarEngineConfig;
|
||||||
aftermathConfig: WarAftermathConfig;
|
aftermathConfig: WarAftermathConfig;
|
||||||
|
messageTime: Date;
|
||||||
|
messageSharedIconBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const orderDefenderGenerals = <TriggerState extends GeneralTriggerState>(
|
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);
|
): General<TriggerState>[] => [...generals].sort((left, right) => left.id - right.id);
|
||||||
|
|
||||||
const ACTION_NAME = '출병';
|
const ACTION_NAME = '출병';
|
||||||
|
const LEGACY_SORTIE_FLUSH_GROUP_START = Number.MIN_SAFE_INTEGER;
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destCityId: z.number(),
|
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) {
|
if (finalTargetCity.id !== destCity.id) {
|
||||||
const josaRo = JosaUtil.pick(finalTargetCity.name, '로');
|
const josaRo = JosaUtil.pick(finalTargetCity.name, '로');
|
||||||
const josaUl = JosaUtil.pick(destCity.name, '을');
|
const josaUl = JosaUtil.pick(destCity.name, '을');
|
||||||
if (minDist === 0) {
|
if (minDist === 0) {
|
||||||
context.addLog(
|
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 {
|
} else {
|
||||||
context.addLog(
|
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,
|
defenderCity,
|
||||||
defenderNation,
|
defenderNation,
|
||||||
|
legacyFlushSequence,
|
||||||
...(shouldTraceWar
|
...(shouldTraceWar
|
||||||
? {
|
? {
|
||||||
trace: (event) => {
|
trace: (event) => {
|
||||||
@@ -635,7 +649,9 @@ export class ActionDefinition<
|
|||||||
unitSet,
|
unitSet,
|
||||||
config: context.aftermathConfig,
|
config: context.aftermathConfig,
|
||||||
time,
|
time,
|
||||||
|
messageTime: context.messageTime,
|
||||||
hiddenSeed: context.seedBase,
|
hiddenSeed: context.seedBase,
|
||||||
|
legacyFlushSequence,
|
||||||
generalActionModules: this.generalModules,
|
generalActionModules: this.generalModules,
|
||||||
calcNationTechGain: ({ nation, baseGain }) => {
|
calcNationTechGain: ({ nation, baseGain }) => {
|
||||||
const module = this.nationTraitModules.get(nation.typeCode);
|
const module = this.nationTraitModules.get(nation.typeCode);
|
||||||
@@ -644,6 +660,7 @@ export class ActionDefinition<
|
|||||||
baseGain
|
baseGain
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
...(context.messageSharedIconBaseUrl ? { messageSharedIconBaseUrl: context.messageSharedIconBaseUrl } : {}),
|
||||||
...(this.trace ? { trace: this.trace } : {}),
|
...(this.trace ? { trace: this.trace } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -676,12 +693,22 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
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) {
|
for (const entry of battle.logs) {
|
||||||
effects.push({ type: 'log', entry });
|
effects.push({ type: 'log', entry });
|
||||||
}
|
}
|
||||||
for (const entry of aftermath.logs) {
|
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>>();
|
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 {
|
return {
|
||||||
effects,
|
effects,
|
||||||
@@ -792,6 +832,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
|||||||
seedBase: options.seedBase,
|
seedBase: options.seedBase,
|
||||||
warConfig,
|
warConfig,
|
||||||
aftermathConfig,
|
aftermathConfig,
|
||||||
|
messageTime: options.gameNow ?? base.general.turnTime,
|
||||||
|
...(options.messageSharedIconBaseUrl ? { messageSharedIconBaseUrl: options.messageSharedIconBaseUrl } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export class ActionResolver<
|
|||||||
|
|
||||||
// Penalty
|
// Penalty
|
||||||
const betrayal = typeof general.meta.betray === 'number' ? general.meta.betray : 0;
|
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 maxBelong = typeof general.meta.max_belong === 'number' ? general.meta.max_belong : 0;
|
||||||
const penaltyRatio = betrayal * 0.1;
|
const penaltyRatio = betrayal * 0.1;
|
||||||
const nextExp = Math.round(general.experience * (1 - penaltyRatio));
|
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>하야</>했습니다.`, {
|
context.addLog(`<Y>${general.name}</>${josaYi} <D><b>${nation.name}</b></>에서 <R>하야</>했습니다.`, {
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.SUMMARY,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.RAWTEXT,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
effects.push(
|
effects.push(
|
||||||
@@ -101,7 +100,9 @@ export class ActionResolver<
|
|||||||
...general.meta,
|
...general.meta,
|
||||||
betray: Math.min(9, betrayal + 1),
|
betray: Math.min(9, betrayal + 1),
|
||||||
belong: 0,
|
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,
|
makelimit: 12,
|
||||||
officer_city: 0,
|
officer_city: 0,
|
||||||
permission: 'normal',
|
permission: 'normal',
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ import type { GeneralTurnCommandSpec } from './index.js';
|
|||||||
const ACTION_NAME = '해산';
|
const ACTION_NAME = '해산';
|
||||||
const ACTION_KEY = 'che_해산';
|
const ACTION_KEY = 'che_해산';
|
||||||
|
|
||||||
const readMetaNumber = (value: unknown): number =>
|
const readMetaNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
||||||
typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
||||||
|
|
||||||
export interface DisbandFactionArgs {}
|
export interface DisbandFactionArgs {}
|
||||||
|
|
||||||
@@ -77,7 +76,13 @@ export class ActionDefinition<
|
|||||||
const defaultGold = this.env.defaultNpcGold > 0 ? this.env.defaultNpcGold : 1000;
|
const defaultGold = this.env.defaultNpcGold > 0 ? this.env.defaultNpcGold : 1000;
|
||||||
const defaultRice = this.env.defaultNpcRice > 0 ? this.env.defaultNpcRice : 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) {
|
for (const targetGeneral of nationGenerals) {
|
||||||
const isActor = targetGeneral.id === general.id;
|
const isActor = targetGeneral.id === general.id;
|
||||||
const belong = readMetaNumber(targetGeneral.meta.belong);
|
const belong = readMetaNumber(targetGeneral.meta.belong);
|
||||||
@@ -148,6 +153,7 @@ export class ActionDefinition<
|
|||||||
context.addLog(`<D><b>${nation.name}</b></>${josaUl} 해산`, {
|
context.addLog(`<D><b>${nation.name}</b></>${josaUl} 해산`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
const josaUn = JosaUtil.pick(nation.name, '은');
|
const josaUn = JosaUtil.pick(nation.name, '은');
|
||||||
@@ -159,19 +165,22 @@ export class ActionDefinition<
|
|||||||
format: LogFormat.YEAR_MONTH,
|
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(
|
effects.push(
|
||||||
createLogEffect(`<D><b>${nation.name}</b></>${josaNationYi} <R>멸망</>했습니다.`, {
|
createLogEffect(`<D><b>${nation.name}</b></>${josaNationYi} <R>멸망</>했습니다.`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
generalId: targetGeneral.id,
|
generalId: targetGeneral.id,
|
||||||
|
...(legacyFlushGroup === undefined ? {} : { legacyFlushGroup }),
|
||||||
}),
|
}),
|
||||||
createLogEffect(`<D><b>${nation.name}</b></>${josaNationYi} <R>멸망</>`, {
|
createLogEffect(`<D><b>${nation.name}</b></>${josaNationYi} <R>멸망</>`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
generalId: targetGeneral.id,
|
generalId: targetGeneral.id,
|
||||||
|
...(legacyFlushGroup === undefined ? {} : { legacyFlushGroup }),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,8 +185,8 @@ export class ActionDefinition<
|
|||||||
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>하였습니다.`,
|
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>하였습니다.`,
|
||||||
{
|
{
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
// Global History Log
|
// Global History Log
|
||||||
|
|||||||
@@ -115,28 +115,29 @@ export class ActionResolver<
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const target of context.friendlyGenerals) {
|
const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id);
|
||||||
if (target.id === general.id) {
|
const firstLegacyFlushGroup = -(friendlyTargets.length + context.destNationGenerals.length + 1);
|
||||||
continue;
|
for (const [index, target] of friendlyTargets.entries()) {
|
||||||
}
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(broadcastMessage, {
|
createLogEffect(broadcastMessage, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: firstLegacyFlushGroup + index,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const destBroadcast = `아국에 <M>${ACTION_NAME}</>${JosaUtil.pick(ACTION_NAME, '이')} 발동되었습니다.`;
|
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(
|
effects.push(
|
||||||
createLogEffect(destBroadcast, {
|
createLogEffect(destBroadcast, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -148,12 +149,15 @@ export class ActionResolver<
|
|||||||
strategic_cmd_limit: globalDelay,
|
strategic_cmd_limit: globalDelay,
|
||||||
};
|
};
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(broadcastMessage, {
|
createLogEffect(
|
||||||
|
`<Y>${generalName}</>${generalJosa} <D><b>${destNationName}</b></>에 <M>${ACTION_NAME}</>${actionJosa} 발동`,
|
||||||
|
{
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
effects.push(
|
effects.push(
|
||||||
@@ -163,7 +167,8 @@ export class ActionResolver<
|
|||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
nationId: context.destNation.id,
|
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 { z } from 'zod';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||||
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
isGold: z.boolean(),
|
isGold: z.boolean(),
|
||||||
@@ -43,6 +44,7 @@ export interface SeizureResolveContext<
|
|||||||
> extends GeneralActionResolveContext<TriggerState> {
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
destGeneral: General<TriggerState>;
|
destGeneral: General<TriggerState>;
|
||||||
messageTime: Date;
|
messageTime: Date;
|
||||||
|
messageSharedIconBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '몰수';
|
const ACTION_NAME = '몰수';
|
||||||
@@ -67,16 +69,6 @@ const pickLegacyNpcMessage = (rng: GeneralActionResolveContext['rng']): string =
|
|||||||
return NPC_SEIZURE_MESSAGES[index]!;
|
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<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, SeizureArgs, SeizureResolveContext<TriggerState>> {
|
> implements GeneralActionDefinition<TriggerState, SeizureArgs, SeizureResolveContext<TriggerState>> {
|
||||||
@@ -170,6 +162,7 @@ export class ActionDefinition<
|
|||||||
generalId: destGeneral.id,
|
generalId: destGeneral.id,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -183,7 +176,7 @@ export class ActionDefinition<
|
|||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
nationName: nation.name,
|
nationName: nation.name,
|
||||||
color: nation.color,
|
color: nation.color,
|
||||||
icon: resolveGeneralIcon(destGeneral),
|
icon: resolveMessageTargetIcon(destGeneral, context.messageSharedIconBaseUrl),
|
||||||
};
|
};
|
||||||
effects.push(
|
effects.push(
|
||||||
createMessageEffect({
|
createMessageEffect({
|
||||||
@@ -214,7 +207,8 @@ export const actionContextBuilder: ActionContextBuilder<SeizureArgs> = (base, op
|
|||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
destGeneral,
|
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>수도 이전</>하였습니다.`,
|
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaRo} <M>수도 이전</>하였습니다.`,
|
||||||
{
|
{
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
// Global History Log
|
// Global History Log
|
||||||
@@ -202,6 +202,7 @@ export class ActionDefinition<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: targetGeneral.id,
|
generalId: targetGeneral.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export class ActionDefinition<
|
|||||||
nationId: destNation.id,
|
nationId: destNation.id,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
// General Action Log
|
// General Action Log
|
||||||
@@ -222,6 +223,7 @@ export class ActionDefinition<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: chief.id,
|
generalId: chief.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -234,6 +236,7 @@ export class ActionDefinition<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: chief.id,
|
generalId: chief.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export class ActionResolver<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: destGeneral.id,
|
generalId: destGeneral.id,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
effects.push(
|
effects.push(
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export class ActionResolver<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,12 +137,15 @@ export class ActionResolver<
|
|||||||
strategic_cmd_limit: globalDelay,
|
strategic_cmd_limit: globalDelay,
|
||||||
};
|
};
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(broadcastMessage, {
|
createLogEffect(
|
||||||
|
`<Y>${generalName}</>${generalJosa} <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>을 발동`,
|
||||||
|
{
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ export class ActionDefinition<
|
|||||||
createLogEffect(`<Y>${general.name}</>에게 부대 탈퇴를 지시 받았습니다.`, {
|
createLogEffect(`<Y>${general.name}</>에게 부대 탈퇴를 지시 받았습니다.`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
generalId: destGeneral.id,
|
generalId: destGeneral.id,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { NationTurnCommandSpec } from './index.js';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { resolveDiplomacyMessageValidMinutes } from '../../../diplomacy/messageValidity.js';
|
import { resolveDiplomacyMessageValidMinutes } from '../../../diplomacy/messageValidity.js';
|
||||||
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destNationId: z.number().int().positive(),
|
destNationId: z.number().int().positive(),
|
||||||
@@ -33,6 +34,7 @@ interface NonAggressionProposalContext<
|
|||||||
destNation: Nation;
|
destNation: Nation;
|
||||||
messageValidMinutes: number;
|
messageValidMinutes: number;
|
||||||
messageTime: Date;
|
messageTime: Date;
|
||||||
|
messageSharedIconBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '불가침 제의';
|
const ACTION_NAME = '불가침 제의';
|
||||||
@@ -161,7 +163,7 @@ export class ActionDefinition<
|
|||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
nationName: nation.name,
|
nationName: nation.name,
|
||||||
color: nation.color,
|
color: nation.color,
|
||||||
icon: '',
|
icon: resolveMessageTargetIcon(general, context.messageSharedIconBaseUrl),
|
||||||
},
|
},
|
||||||
dest: {
|
dest: {
|
||||||
generalId: 0,
|
generalId: 0,
|
||||||
@@ -169,7 +171,7 @@ export class ActionDefinition<
|
|||||||
nationId: destNation.id,
|
nationId: destNation.id,
|
||||||
nationName: destNation.name,
|
nationName: destNation.name,
|
||||||
color: destNation.color,
|
color: destNation.color,
|
||||||
icon: '',
|
icon: resolveMessageTargetIcon(null, context.messageSharedIconBaseUrl),
|
||||||
},
|
},
|
||||||
text: `${nation.name}${josaWa} ${args.year}년 ${args.month}월까지 불가침 제의 서신`,
|
text: `${nation.name}${josaWa} ${args.year}년 ${args.month}월까지 불가침 제의 서신`,
|
||||||
time: context.messageTime,
|
time: context.messageTime,
|
||||||
@@ -195,7 +197,8 @@ export const actionContextBuilder: ActionContextBuilder<NonAggressionProposalArg
|
|||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
destNation,
|
destNation,
|
||||||
messageTime: base.general.turnTime,
|
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
|
||||||
|
messageTime: options.gameNow ?? base.general.turnTime,
|
||||||
messageValidMinutes: resolveDiplomacyMessageValidMinutes(options.world.tickSeconds),
|
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 type { NationTurnCommandSpec } from './index.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destNationId: z.number().int().positive(),
|
destNationId: z.number().int().positive(),
|
||||||
@@ -30,6 +31,7 @@ interface NonAggressionCancelProposalContext<
|
|||||||
destNation: Nation;
|
destNation: Nation;
|
||||||
messageValidMinutes: number;
|
messageValidMinutes: number;
|
||||||
messageTime: Date;
|
messageTime: Date;
|
||||||
|
messageSharedIconBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '불가침 파기 제의';
|
const ACTION_NAME = '불가침 파기 제의';
|
||||||
@@ -86,7 +88,7 @@ export class ActionDefinition<
|
|||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
nationName: nation.name,
|
nationName: nation.name,
|
||||||
color: nation.color,
|
color: nation.color,
|
||||||
icon: '',
|
icon: resolveMessageTargetIcon(general, context.messageSharedIconBaseUrl),
|
||||||
},
|
},
|
||||||
dest: {
|
dest: {
|
||||||
generalId: 0,
|
generalId: 0,
|
||||||
@@ -94,7 +96,7 @@ export class ActionDefinition<
|
|||||||
nationId: destNation.id,
|
nationId: destNation.id,
|
||||||
nationName: destNation.name,
|
nationName: destNation.name,
|
||||||
color: destNation.color,
|
color: destNation.color,
|
||||||
icon: '',
|
icon: resolveMessageTargetIcon(null, context.messageSharedIconBaseUrl),
|
||||||
},
|
},
|
||||||
text: `${nation.name}의 불가침 파기 제의 서신`,
|
text: `${nation.name}의 불가침 파기 제의 서신`,
|
||||||
time: context.messageTime,
|
time: context.messageTime,
|
||||||
@@ -120,7 +122,8 @@ export const actionContextBuilder: ActionContextBuilder<NonAggressionCancelPropo
|
|||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
destNation,
|
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)),
|
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 type { NationTurnCommandSpec } from './index.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destNationId: z.number().int().positive(),
|
destNationId: z.number().int().positive(),
|
||||||
@@ -36,6 +37,7 @@ interface DeclareWarResolveContext<
|
|||||||
currentYear: number;
|
currentYear: number;
|
||||||
currentMonth: number;
|
currentMonth: number;
|
||||||
messageTime: Date;
|
messageTime: Date;
|
||||||
|
messageSharedIconBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '선전포고';
|
const ACTION_NAME = '선전포고';
|
||||||
@@ -152,6 +154,8 @@ export class ActionDefinition<
|
|||||||
nationId: args.destNationId,
|
nationId: args.destNationId,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
// Ref는 actor logger를 applyDB로 flush한 뒤 상대국 logger를 따로 flush한다.
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}),
|
}),
|
||||||
// Global Action Log
|
// Global Action Log
|
||||||
createLogEffect(
|
createLogEffect(
|
||||||
@@ -179,7 +183,7 @@ export class ActionDefinition<
|
|||||||
nationId,
|
nationId,
|
||||||
nationName,
|
nationName,
|
||||||
color: context.nation?.color ?? '',
|
color: context.nation?.color ?? '',
|
||||||
icon: '',
|
icon: resolveMessageTargetIcon(context.general, context.messageSharedIconBaseUrl),
|
||||||
},
|
},
|
||||||
dest: {
|
dest: {
|
||||||
generalId: 0,
|
generalId: 0,
|
||||||
@@ -187,7 +191,7 @@ export class ActionDefinition<
|
|||||||
nationId: args.destNationId,
|
nationId: args.destNationId,
|
||||||
nationName: destNationName,
|
nationName: destNationName,
|
||||||
color: context.destNation.color,
|
color: context.destNation.color,
|
||||||
icon: '',
|
icon: resolveMessageTargetIcon(null, context.messageSharedIconBaseUrl),
|
||||||
},
|
},
|
||||||
text: `【외교】${context.currentYear}년 ${context.currentMonth}월:${nationName}에서 ${destNationName}에 선전포고`,
|
text: `【외교】${context.currentYear}년 ${context.currentMonth}월:${nationName}에서 ${destNationName}에 선전포고`,
|
||||||
time: context.messageTime,
|
time: context.messageTime,
|
||||||
@@ -219,7 +223,8 @@ export const actionContextBuilder: ActionContextBuilder<DeclareWarArgs> = (base,
|
|||||||
destNation,
|
destNation,
|
||||||
currentYear: options.world.currentYear,
|
currentYear: options.world.currentYear,
|
||||||
currentMonth: options.world.currentMonth,
|
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,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -138,6 +139,7 @@ export class ActionResolver<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -180,6 +182,7 @@ export class ActionResolver<
|
|||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
nationId: context.destNation.id,
|
nationId: context.destNation.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ export class ActionResolver<
|
|||||||
|
|
||||||
const actionName = ACTION_NAME;
|
const actionName = ACTION_NAME;
|
||||||
context.addLog(`${actionName} 발동!`);
|
context.addLog(`${actionName} 발동!`);
|
||||||
context.addLog(`${actionName} 발동`, {
|
context.addLog(`<M>${actionName}</>${actionJosa} 발동`, {
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
});
|
});
|
||||||
@@ -416,6 +416,7 @@ export class ActionResolver<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -518,6 +519,9 @@ export class ActionResolver<
|
|||||||
const meta: GeneralMeta = {
|
const meta: GeneralMeta = {
|
||||||
killturn,
|
killturn,
|
||||||
npcType: NPC_TYPE,
|
npcType: NPC_TYPE,
|
||||||
|
npc_org: NPC_TYPE,
|
||||||
|
explevel: 0,
|
||||||
|
dedlevel: 1,
|
||||||
crewTypeId: this.env.defaultCrewTypeId,
|
crewTypeId: this.env.defaultCrewTypeId,
|
||||||
dex1: dex[0],
|
dex1: dex[0],
|
||||||
dex2: dex[1],
|
dex2: dex[1],
|
||||||
@@ -567,6 +571,7 @@ export class ActionResolver<
|
|||||||
}),
|
}),
|
||||||
turnTime,
|
turnTime,
|
||||||
...(turnTick === undefined ? {} : { turnTick }),
|
...(turnTick === undefined ? {} : { turnTick }),
|
||||||
|
affinity,
|
||||||
bornYear: birthYear,
|
bornYear: birthYear,
|
||||||
deadYear: deathYear,
|
deadYear: deathYear,
|
||||||
imageServer: candidate.imageServer ?? 0,
|
imageServer: candidate.imageServer ?? 0,
|
||||||
|
|||||||
@@ -122,28 +122,29 @@ export class ActionResolver<
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const target of context.friendlyGenerals) {
|
const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id);
|
||||||
if (target.id === general.id) {
|
const firstLegacyFlushGroup = -(friendlyTargets.length + context.destNationGenerals.length + 1);
|
||||||
continue;
|
for (const [index, target] of friendlyTargets.entries()) {
|
||||||
}
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(broadcastMessage, {
|
createLogEffect(broadcastMessage, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: firstLegacyFlushGroup + index,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const destBroadcast = `<D><b>${nationName}</b></>${nationJosa} 아국에 <M>${ACTION_NAME}</>${actionJosa} 발동하였습니다.`;
|
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(
|
effects.push(
|
||||||
createLogEffect(destBroadcast, {
|
createLogEffect(destBroadcast, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,12 +156,15 @@ export class ActionResolver<
|
|||||||
strategic_cmd_limit: globalDelay,
|
strategic_cmd_limit: globalDelay,
|
||||||
};
|
};
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(broadcastMessage, {
|
createLogEffect(
|
||||||
|
`<Y>${generalName}</>${generalJosa} <D><b>${destNationName}</b></>에 <M>${ACTION_NAME}</>${actionJosa} 발동`,
|
||||||
|
{
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
effects.push(
|
effects.push(
|
||||||
@@ -170,7 +174,8 @@ export class ActionResolver<
|
|||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
nationId: context.destNation.id,
|
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 { JosaUtil } from '@sammo-ts/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destNationId: z.number().int().positive(),
|
destNationId: z.number().int().positive(),
|
||||||
@@ -30,6 +31,7 @@ interface StopWarProposalContext<
|
|||||||
destNation: Nation;
|
destNation: Nation;
|
||||||
messageValidMinutes: number;
|
messageValidMinutes: number;
|
||||||
messageTime: Date;
|
messageTime: Date;
|
||||||
|
messageSharedIconBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '종전 제의';
|
const ACTION_NAME = '종전 제의';
|
||||||
@@ -81,7 +83,7 @@ export class ActionDefinition<
|
|||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
nationName: nation.name,
|
nationName: nation.name,
|
||||||
color: nation.color,
|
color: nation.color,
|
||||||
icon: '',
|
icon: resolveMessageTargetIcon(general, context.messageSharedIconBaseUrl),
|
||||||
},
|
},
|
||||||
dest: {
|
dest: {
|
||||||
generalId: 0,
|
generalId: 0,
|
||||||
@@ -89,7 +91,7 @@ export class ActionDefinition<
|
|||||||
nationId: destNation.id,
|
nationId: destNation.id,
|
||||||
nationName: destNation.name,
|
nationName: destNation.name,
|
||||||
color: destNation.color,
|
color: destNation.color,
|
||||||
icon: '',
|
icon: resolveMessageTargetIcon(null, context.messageSharedIconBaseUrl),
|
||||||
},
|
},
|
||||||
text: `${nation.name}의 종전 제의 서신`,
|
text: `${nation.name}의 종전 제의 서신`,
|
||||||
time: context.messageTime,
|
time: context.messageTime,
|
||||||
@@ -115,7 +117,8 @@ export const actionContextBuilder: ActionContextBuilder<StopWarProposalArgs> = (
|
|||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
destNation,
|
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)),
|
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}</>하였습니다.`,
|
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>하였습니다.`,
|
||||||
{
|
{
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
// Global History Log
|
// Global History Log
|
||||||
|
|||||||
@@ -224,8 +224,8 @@ export class ActionDefinition<
|
|||||||
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaRo} <M>${ACTION_NAME}</>를 명령하였습니다.`,
|
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaRo} <M>${ACTION_NAME}</>를 명령하였습니다.`,
|
||||||
{
|
{
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
// Global History Log
|
// Global History Log
|
||||||
|
|||||||
@@ -204,8 +204,8 @@ export class ActionDefinition<
|
|||||||
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>하였습니다.`,
|
`<Y>${generalName}</>${josaYi} <G><b>${destCityName}</b></>${josaUl} <M>${ACTION_NAME}</>하였습니다.`,
|
||||||
{
|
{
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
createLogEffect(
|
createLogEffect(
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export class ActionResolver<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: context.destGeneral.id,
|
generalId: context.destGeneral.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
effects.push(
|
effects.push(
|
||||||
|
|||||||
@@ -177,27 +177,28 @@ export class ActionResolver<
|
|||||||
'이'
|
'이'
|
||||||
)} 발동되었습니다.`;
|
)} 발동되었습니다.`;
|
||||||
|
|
||||||
for (const target of context.friendlyGenerals) {
|
const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id);
|
||||||
if (target.id === general.id) {
|
const firstLegacyFlushGroup = -(friendlyTargets.length + context.destNationGenerals.length + 1);
|
||||||
continue;
|
for (const [index, target] of friendlyTargets.entries()) {
|
||||||
}
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(broadcastMessage, {
|
createLogEffect(broadcastMessage, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: firstLegacyFlushGroup + index,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const target of context.destNationGenerals) {
|
for (const [index, target] of context.destNationGenerals.entries()) {
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(destBroadcastMessage, {
|
createLogEffect(destBroadcastMessage, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,7 +233,8 @@ export class ActionResolver<
|
|||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
nationId: destNation.id,
|
nationId: destNation.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -112,10 +112,9 @@ export class ActionResolver<
|
|||||||
general.atmos = selfPatch.atmos;
|
general.atmos = selfPatch.atmos;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const target of context.nationGenerals) {
|
const nationTargets = context.nationGenerals.filter((target) => target.id !== general.id);
|
||||||
if (target.id === general.id) {
|
const firstLegacyFlushGroup = -nationTargets.length;
|
||||||
continue;
|
for (const [index, target] of nationTargets.entries()) {
|
||||||
}
|
|
||||||
const patch = updateTrainAtmos(target);
|
const patch = updateTrainAtmos(target);
|
||||||
if (patch) {
|
if (patch) {
|
||||||
effects.push(createGeneralPatchEffect(patch, target.id));
|
effects.push(createGeneralPatchEffect(patch, target.id));
|
||||||
@@ -126,6 +125,7 @@ export class ActionResolver<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: firstLegacyFlushGroup + index,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ export class ActionResolver<
|
|||||||
strategic_cmd_limit: globalDelay,
|
strategic_cmd_limit: globalDelay,
|
||||||
};
|
};
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(broadcastMessage, {
|
createLogEffect(`<Y>${generalName}</>${generalJosa} <M>${ACTION_NAME}</>을 발동`, {
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
|
|||||||
@@ -127,21 +127,25 @@ export class ActionResolver<
|
|||||||
|
|
||||||
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
||||||
|
|
||||||
for (const target of context.friendlyGenerals) {
|
const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id);
|
||||||
if (target.id === general.id) {
|
const firstLegacyFlushGroup = -(
|
||||||
continue;
|
friendlyTargets.length +
|
||||||
}
|
context.destCityGenerals.length +
|
||||||
|
(context.destNation ? 1 : 0)
|
||||||
|
);
|
||||||
|
for (const [index, target] of friendlyTargets.entries()) {
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(broadcastMessage, {
|
createLogEffect(broadcastMessage, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
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);
|
const moveCityId = pickMoveCityId(context.rng, context.destCity.id, context.destNationSupplyCities);
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(destBroadcastMessage, {
|
createLogEffect(destBroadcastMessage, {
|
||||||
@@ -149,6 +153,7 @@ export class ActionResolver<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (moveCityId !== target.cityId) {
|
if (moveCityId !== target.cityId) {
|
||||||
@@ -163,12 +168,15 @@ export class ActionResolver<
|
|||||||
strategic_cmd_limit: globalDelay,
|
strategic_cmd_limit: globalDelay,
|
||||||
};
|
};
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect(broadcastMessage, {
|
createLogEffect(
|
||||||
|
`<Y>${generalName}</>${generalJosa} <G><b>${cityName}</b></>에 <M>${ACTION_NAME}</>를 발동`,
|
||||||
|
{
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +189,7 @@ export class ActionResolver<
|
|||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
nationId: context.destNation.id,
|
nationId: context.destNation.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: -1,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export class ActionDefinition<
|
|||||||
const amount = clamp(args.amount, 0, available);
|
const amount = clamp(args.amount, 0, available);
|
||||||
|
|
||||||
const cost = calcCost(this.env.develCost, args.amount);
|
const cost = calcCost(this.env.develCost, args.amount);
|
||||||
const amountText = amount.toLocaleString();
|
const amountText = String(amount);
|
||||||
const destCityName = destCity.name;
|
const destCityName = destCity.name;
|
||||||
const josaRo = JosaUtil.pick(destCityName, '로');
|
const josaRo = JosaUtil.pick(destCityName, '로');
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ const buildNonAggressionEffects = <TriggerState extends GeneralTriggerState>(
|
|||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
generalId: context.proposer.id,
|
generalId: context.proposer.id,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
createLogEffect(
|
createLogEffect(
|
||||||
@@ -166,6 +167,7 @@ const buildNonAggressionEffects = <TriggerState extends GeneralTriggerState>(
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: context.proposer.id,
|
generalId: context.proposer.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -226,12 +228,14 @@ const buildCancelNonAggressionEffects = <TriggerState extends GeneralTriggerStat
|
|||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
generalId: context.proposer.id,
|
generalId: context.proposer.id,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}),
|
}),
|
||||||
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa}의 불가침 파기에 성공했습니다.`, {
|
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa}의 불가침 파기에 성공했습니다.`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
generalId: context.proposer.id,
|
generalId: context.proposer.id,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -297,18 +301,21 @@ const buildStopWarEffects = <TriggerState extends GeneralTriggerState>(
|
|||||||
generalId: context.proposer.id,
|
generalId: context.proposer.id,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}),
|
}),
|
||||||
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전에 성공했습니다.`, {
|
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전에 성공했습니다.`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
generalId: context.proposer.id,
|
generalId: context.proposer.id,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}),
|
}),
|
||||||
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전`, {
|
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전`, {
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
nationId: proposerNation.id,
|
nationId: proposerNation.id,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
legacyFlushGroup: 1,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,55 @@
|
|||||||
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from './types.js';
|
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 {
|
export class ActionLogger {
|
||||||
private readonly generalId: number | undefined;
|
private readonly generalId: number | undefined;
|
||||||
private readonly nationId: number | undefined;
|
private readonly nationId: number | undefined;
|
||||||
@@ -13,7 +63,7 @@ export class ActionLogger {
|
|||||||
// 장수/국가/전역 로그를 한번에 모아두고 외부에서 저장한다.
|
// 장수/국가/전역 로그를 한번에 모아두고 외부에서 저장한다.
|
||||||
public flush(): LogEntryDraft[] {
|
public flush(): LogEntryDraft[] {
|
||||||
const items = this.logs.splice(0, this.logs.length);
|
const items = this.logs.splice(0, this.logs.length);
|
||||||
return items;
|
return orderLegacyActionLoggerFlush(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
public rollback(): LogEntryDraft[] {
|
public rollback(): LogEntryDraft[] {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface LogEntryDraft {
|
|||||||
month?: number;
|
month?: number;
|
||||||
/** 로그를 만든 논리 게임 시각. 생략하면 flush context의 시각을 사용한다. */
|
/** 로그를 만든 논리 게임 시각. 생략하면 flush context의 시각을 사용한다. */
|
||||||
occurredAt?: Date;
|
occurredAt?: Date;
|
||||||
|
/** 하나의 명령에서 별도 Ref ActionLogger로 저장한 순서. 작은 그룹을 먼저 flush한다. */
|
||||||
|
legacyFlushGroup?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogEntryRecord {
|
export interface LogEntryRecord {
|
||||||
|
|||||||
@@ -2,6 +2,33 @@ export type MessageType = 'public' | 'private' | 'national' | 'diplomacy';
|
|||||||
|
|
||||||
export const MESSAGE_MAILBOX_PUBLIC = 9999;
|
export const MESSAGE_MAILBOX_PUBLIC = 9999;
|
||||||
export const MESSAGE_MAILBOX_NATIONAL_BASE = 9000;
|
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 {
|
export interface MessageTarget {
|
||||||
generalId: number;
|
generalId: number;
|
||||||
@@ -80,7 +107,7 @@ const buildPayload = (draft: MessageDraft, optionOverride?: MessageOption | null
|
|||||||
src: draft.src,
|
src: draft.src,
|
||||||
dest: draft.dest,
|
dest: draft.dest,
|
||||||
text: draft.text,
|
text: draft.text,
|
||||||
option: optionOverride ?? draft.option ?? {},
|
option: optionOverride !== undefined ? optionOverride : (draft.option ?? {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildRecord = (
|
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 = {
|
const option = {
|
||||||
...(draft.option ?? {}),
|
...(draft.option ?? {}),
|
||||||
receiverMessageID: receiverId,
|
receiverMessageID: receiverId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (draft.msgType === 'diplomacy' && 'action' in option) {
|
if (draft.msgType === 'diplomacy' && 'action' in option) {
|
||||||
const { action: _action, ...rest } = option;
|
// Ref Message::sendToSender temporarily replaces the entire actionable
|
||||||
return rest;
|
// 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;
|
return option;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
import type { General, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import { resolveMessageTargetIcon, type MessageDraft } from './message.js';
|
||||||
|
|
||||||
|
type ScoutGeneral = Pick<General, 'id' | 'name' | 'nationId' | 'officerLevel'>;
|
||||||
|
type ScoutNation = Pick<Nation, 'id' | 'name' | 'color'>;
|
||||||
|
|
||||||
|
export interface ScoutMessageDraftInput {
|
||||||
|
srcGeneral: ScoutGeneral;
|
||||||
|
destGeneral: ScoutGeneral;
|
||||||
|
srcNation: ScoutNation | null;
|
||||||
|
destNation: ScoutNation | null;
|
||||||
|
time: Date;
|
||||||
|
sharedIconBaseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure equivalent of Ref ScoutMessage::buildScoutMessage(). Ref constructs
|
||||||
|
* both targets without a general picture, so MessageTarget supplies the shared
|
||||||
|
* default icon, and every caller persists the receiver copy only via send(true).
|
||||||
|
*/
|
||||||
|
export const buildScoutMessageDraft = (input: ScoutMessageDraftInput): MessageDraft | null => {
|
||||||
|
const { srcGeneral, destGeneral, srcNation, destNation } = input;
|
||||||
|
if (
|
||||||
|
srcGeneral.id === destGeneral.id ||
|
||||||
|
destGeneral.officerLevel === 12 ||
|
||||||
|
srcGeneral.nationId === 0 ||
|
||||||
|
srcGeneral.nationId === destGeneral.nationId ||
|
||||||
|
!srcNation ||
|
||||||
|
srcNation.id !== srcGeneral.nationId
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedDestNation =
|
||||||
|
destGeneral.nationId === 0
|
||||||
|
? { id: 0, name: '재야', color: '#000000' }
|
||||||
|
: destNation?.id === destGeneral.nationId
|
||||||
|
? destNation
|
||||||
|
: null;
|
||||||
|
if (!resolvedDestNation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const josaRo = JosaUtil.pick(srcNation.name, '로');
|
||||||
|
const icon = resolveMessageTargetIcon(null, input.sharedIconBaseUrl);
|
||||||
|
return {
|
||||||
|
msgType: 'private',
|
||||||
|
src: {
|
||||||
|
generalId: srcGeneral.id,
|
||||||
|
generalName: srcGeneral.name,
|
||||||
|
nationId: srcGeneral.nationId,
|
||||||
|
nationName: srcNation.name,
|
||||||
|
color: srcNation.color,
|
||||||
|
icon,
|
||||||
|
},
|
||||||
|
dest: {
|
||||||
|
generalId: destGeneral.id,
|
||||||
|
generalName: destGeneral.name,
|
||||||
|
nationId: destGeneral.nationId,
|
||||||
|
nationName: resolvedDestNation.name,
|
||||||
|
color: resolvedDestNation.color,
|
||||||
|
icon,
|
||||||
|
},
|
||||||
|
text: `${srcNation.name}${josaRo} 망명 권유 서신`,
|
||||||
|
time: input.time,
|
||||||
|
validUntil: new Date('9999-12-31T12:59:59.000Z'),
|
||||||
|
option: { action: 'scout' },
|
||||||
|
sendDestOnly: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -334,27 +334,28 @@ const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
const itemRawName = itemModule.rawName;
|
const itemRawName = itemModule.rawName;
|
||||||
const josaYi = JosaUtil.pick(generalName, '이');
|
const josaYi = JosaUtil.pick(generalName, '이');
|
||||||
const josaUl = JosaUtil.pick(itemRawName, '을');
|
const josaUl = JosaUtil.pick(itemRawName, '을');
|
||||||
|
const addLog = context.addPostProgressionLog ?? context.addLog;
|
||||||
|
|
||||||
equipNewItem(general, itemModule.slot, itemModule.key, {
|
equipNewItem(general, itemModule.slot, itemModule.key, {
|
||||||
...(itemModule.initialCharges === undefined ? {} : { charges: itemModule.initialCharges }),
|
...(itemModule.initialCharges === undefined ? {} : { charges: itemModule.initialCharges }),
|
||||||
});
|
});
|
||||||
|
|
||||||
context.addLog(`<C>${itemName}</>${josaUl} 습득했습니다!`, {
|
addLog(`<C>${itemName}</>${josaUl} 습득했습니다!`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
context.addLog(`<C>${itemName}</>${josaUl} 습득`, {
|
addLog(`<C>${itemName}</>${josaUl} 습득`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
format: LogFormat.YEAR_MONTH,
|
format: LogFormat.YEAR_MONTH,
|
||||||
});
|
});
|
||||||
context.addLog(`<Y>${generalName}</>${josaYi} <C>${itemName}</>${josaUl} 습득했습니다!`, {
|
addLog(`<Y>${generalName}</>${josaYi} <C>${itemName}</>${josaUl} 습득했습니다!`, {
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.SUMMARY,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
context.addLog(
|
addLog(
|
||||||
`<C><b>【${acquireType}】</b></><D><b>${nationName}</b></>의 <Y>${generalName}</>${josaYi} <C>${itemName}</>${josaUl} 습득했습니다!`,
|
`<C><b>【${acquireType}】</b></><D><b>${nationName}</b></>의 <Y>${generalName}</>${josaYi} <C>${itemName}</>${josaUl} 습득했습니다!`,
|
||||||
{
|
{
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
@@ -376,6 +377,6 @@ export const tryApplyUniqueLottery = <TriggerState extends GeneralTriggerState =
|
|||||||
if (!itemModule) {
|
if (!itemModule) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
applyUniqueItemGain(context, itemModule, request.acquireType, request.nationName);
|
applyUniqueItemGain(context, itemModule, request.acquireType, request.nationName ?? context.legacyStaticNationName);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic
|
|||||||
import { createGeneralActionEvent } from '@sammo-ts/logic/actionModules/events.js';
|
import { createGeneralActionEvent } from '@sammo-ts/logic/actionModules/events.js';
|
||||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
||||||
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.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 { buildCrewTypeIndex, getTechCost, getTechLevel } from '@sammo-ts/logic/world/unitSet.js';
|
||||||
import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js';
|
import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js';
|
||||||
import type { WarUnitReport } from './types.js';
|
import type { WarUnitReport } from './types.js';
|
||||||
@@ -26,17 +27,45 @@ import {
|
|||||||
simpleSerialize,
|
simpleSerialize,
|
||||||
sortConflictEntries,
|
sortConflictEntries,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
import { LegacyWarLogFlushSequence } from './legacyFlushSequence.js';
|
||||||
|
|
||||||
const META_DEAD = 'dead';
|
const META_DEAD = 'dead';
|
||||||
const MAX_DEDICATION_LEVEL = 30;
|
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 =
|
const expLevel =
|
||||||
general.experience < 1_000
|
general.experience < 1_000
|
||||||
? Math.trunc(general.experience / 100)
|
? Math.trunc(general.experience / 100)
|
||||||
: Math.trunc(Math.sqrt(general.experience / 10));
|
: Math.trunc(Math.sqrt(general.experience / 10));
|
||||||
general.meta.explevel = clamp(expLevel, 0, LEGACY_DEFAULT_MAX_LEVEL);
|
const nextExpLevel = clamp(expLevel, 0, LEGACY_DEFAULT_MAX_LEVEL);
|
||||||
general.meta.dedlevel = clamp(Math.ceil(Math.sqrt(general.dedication) / 10), 0, MAX_DEDICATION_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 => {
|
const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport) => boolean): WarUnitReport | null => {
|
||||||
@@ -207,16 +236,19 @@ const findNextCapital = (
|
|||||||
})[0]!.city;
|
})[0]!.city;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pushLoggers = (loggers: ActionLogger[], logs: LogEntryDraft[]): void => {
|
const pushLogger = (
|
||||||
for (const logger of loggers) {
|
logger: ActionLogger,
|
||||||
logs.push(...logger.flush());
|
logs: LogEntryDraft[],
|
||||||
}
|
legacyFlushSequence: LegacyWarLogFlushSequence
|
||||||
|
): void => {
|
||||||
|
logs.push(...legacyFlushSequence.flush(logger));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 도시 점령 이후의 국가 붕괴/수도 이동/도시 리셋 처리.
|
// 도시 점령 이후의 국가 붕괴/수도 이동/도시 리셋 처리.
|
||||||
const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||||
input: WarAftermathInput<TriggerState>,
|
input: WarAftermathInput<TriggerState>,
|
||||||
rng: RandUtil
|
rng: RandUtil,
|
||||||
|
legacyFlushSequence: LegacyWarLogFlushSequence
|
||||||
): ConquerCityOutcome<TriggerState> => {
|
): ConquerCityOutcome<TriggerState> => {
|
||||||
const { attackerNation, defenderNation, defenderCity, cities, generals, config } = input;
|
const { attackerNation, defenderNation, defenderCity, cities, generals, config } = input;
|
||||||
const attacker = input.battle.attacker;
|
const attacker = input.battle.attacker;
|
||||||
@@ -257,12 +289,11 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
|||||||
`<Y>${attackerGeneralName}</>${josaYiGen} ${defenderNationDecoration} <G><b>${cityName}</b></> ${josaUl} <S>점령</>`
|
`<Y>${attackerGeneralName}</>${josaYiGen} ${defenderNationDecoration} <G><b>${cityName}</b></> ${josaUl} <S>점령</>`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (defenderNationId) {
|
const defenderNationLogger = defenderNationId ? new ActionLogger({ nationId: defenderNationId }) : null;
|
||||||
const defenderNationLogger = new ActionLogger({ nationId: defenderNationId });
|
if (defenderNationLogger) {
|
||||||
defenderNationLogger.pushNationHistoryLog(
|
defenderNationLogger.pushNationHistoryLog(
|
||||||
`<D><b>${attackerNationName}</b></>의 <Y>${attackerGeneralName}</>에 의해 <G><b>${cityName}</b></>${josaYiCity} <O>함락</>`
|
`<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;
|
const defenderCityCount = defenderNationId ? cities.filter((city) => city.nationId === defenderNationId).length : 0;
|
||||||
@@ -270,17 +301,26 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
|||||||
|
|
||||||
let collapseRewardGold = 0;
|
let collapseRewardGold = 0;
|
||||||
let collapseRewardRice = 0;
|
let collapseRewardRice = 0;
|
||||||
|
const messages: ConquerCityOutcome<TriggerState>['messages'] = [];
|
||||||
const ruinedNpcJoinPlans: ConquerCityOutcome<TriggerState>['ruinedNpcJoinPlans'] = [];
|
const ruinedNpcJoinPlans: ConquerCityOutcome<TriggerState>['ruinedNpcJoinPlans'] = [];
|
||||||
|
|
||||||
// 국가 붕괴 시 자원 손실과 포상 정산.
|
// 국가 붕괴 시 자원 손실과 포상 정산.
|
||||||
if (nationCollapsed && defenderNation) {
|
if (nationCollapsed && defenderNation) {
|
||||||
const defenderGenerals = generals
|
const defenderNationGenerals = generals.filter((general) => general.nationId === defenderNationId);
|
||||||
.filter((general) => general.nationId === defenderNationId)
|
const collapseLord =
|
||||||
.sort((lhs, rhs) => {
|
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,
|
// deleteNation() reads the non-lord rows in primary-key order,
|
||||||
// then appends the lord object to the returned PHP array.
|
// then appends the lord object to the returned PHP array.
|
||||||
const lhsIsLord = lhs.id === defenderNation.chiefGeneralId;
|
const lhsIsLord = lhs.id === collapseLordId;
|
||||||
const rhsIsLord = rhs.id === defenderNation.chiefGeneralId;
|
const rhsIsLord = rhs.id === collapseLordId;
|
||||||
if (lhsIsLord !== rhsIsLord) {
|
if (lhsIsLord !== rhsIsLord) {
|
||||||
return lhsIsLord ? 1 : -1;
|
return lhsIsLord ? 1 : -1;
|
||||||
}
|
}
|
||||||
@@ -292,10 +332,13 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
|||||||
const defenderNationJosaUl = JosaUtil.pick(defenderNationName, '을');
|
const defenderNationJosaUl = JosaUtil.pick(defenderNationName, '을');
|
||||||
const defenderNationJosaUn = JosaUtil.pick(defenderNationName, '은');
|
const defenderNationJosaUn = JosaUtil.pick(defenderNationName, '은');
|
||||||
const defenderNationJosaYi = 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.pushNationHistoryLog(`<D><b>${defenderNationName}</b></>${defenderNationJosaUl} 정복`);
|
||||||
attackerLogger.pushGlobalHistoryLog(
|
pushLogger(attackerLogger, logs, legacyFlushSequence);
|
||||||
`<R><b>【멸망】</b></><D><b>${defenderNationName}</b></>${defenderNationJosaUn} <R>멸망</>했습니다.`
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const general of defenderGenerals) {
|
for (const general of defenderGenerals) {
|
||||||
// Legacy Util::toInt truncates these losses rather than rounding.
|
// 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.rice = clampMin(general.rice - loseRice, 0);
|
||||||
general.experience = round(general.experience * 0.9);
|
general.experience = round(general.experience * 0.9);
|
||||||
general.dedication = round(general.dedication * 0.5);
|
general.dedication = round(general.dedication * 0.5);
|
||||||
updateLegacyProgressionLevels(general);
|
|
||||||
|
|
||||||
totalGoldLoss += loseGold;
|
totalGoldLoss += loseGold;
|
||||||
totalRiceLoss += loseRice;
|
totalRiceLoss += loseRice;
|
||||||
@@ -323,13 +365,46 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
|||||||
`도주하며 금<C>${loseGold}</> 쌀<C>${loseRice}</>을 분실했습니다.`,
|
`도주하며 금<C>${loseGold}</> 쌀<C>${loseRice}</>을 분실했습니다.`,
|
||||||
LogFormat.PLAIN
|
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);
|
affectedGenerals.add(general);
|
||||||
|
|
||||||
if (config.joinMode !== 'onlyRandom') {
|
if (config.joinMode !== 'onlyRandom') {
|
||||||
// Ref attempts to build/send a scout message after every loss.
|
// deleteNation() has already persisted every former member as
|
||||||
// Message availability does not affect this draw.
|
// an unaffiliated officer before this draw and message build.
|
||||||
rng.nextBool(0.5);
|
// 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;
|
const eligibleNpc = general.npcState >= 2 && general.npcState <= 8 && general.npcState !== 5;
|
||||||
if (eligibleNpc && rng.nextBool(config.joinRuinedNpcProbability ?? 0.1)) {
|
if (eligibleNpc && rng.nextBool(config.joinRuinedNpcProbability ?? 0.1)) {
|
||||||
@@ -365,7 +440,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
|||||||
nationId: attackerNation.id,
|
nationId: attackerNation.id,
|
||||||
});
|
});
|
||||||
chiefLogger.pushGeneralActionLog(resourceLog, LogFormat.PLAIN);
|
chiefLogger.pushGeneralActionLog(resourceLog, LogFormat.PLAIN);
|
||||||
pushLoggers([chiefLogger], logs);
|
pushLogger(chiefLogger, logs, legacyFlushSequence);
|
||||||
}
|
}
|
||||||
|
|
||||||
defenderNation.meta.collapsed = true;
|
defenderNation.meta.collapsed = true;
|
||||||
@@ -402,9 +477,11 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
|||||||
});
|
});
|
||||||
defenderLogger.pushGeneralActionLog(moveLog, LogFormat.PLAIN);
|
defenderLogger.pushGeneralActionLog(moveLog, LogFormat.PLAIN);
|
||||||
if (general.officerLevel >= 5) {
|
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);
|
general.atmos = round(general.atmos * 0.8);
|
||||||
if (general.officerLevel >= 5) {
|
if (general.officerLevel >= 5) {
|
||||||
@@ -422,12 +499,13 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
|||||||
? attackerNation
|
? attackerNation
|
||||||
: (input.nations.find((nation) => nation.id === conquerNationId) ?? attackerNation);
|
: (input.nations.find((nation) => nation.id === conquerNationId) ?? attackerNation);
|
||||||
|
|
||||||
|
let conquerNationLogger: ActionLogger | null = null;
|
||||||
if (conquerNationId === attackerNation.id) {
|
if (conquerNationId === attackerNation.id) {
|
||||||
attacker.cityId = defenderCity.id;
|
attacker.cityId = defenderCity.id;
|
||||||
affectedGenerals.add(attacker);
|
affectedGenerals.add(attacker);
|
||||||
} else {
|
} else {
|
||||||
const conquerNationName = conquerNation.name;
|
const conquerNationName = conquerNation.name;
|
||||||
const conquerNationLogger = new ActionLogger({ nationId: conquerNationId });
|
conquerNationLogger = new ActionLogger({ nationId: conquerNationId });
|
||||||
const josaUl = JosaUtil.pick(cityName, '을');
|
const josaUl = JosaUtil.pick(cityName, '을');
|
||||||
const josaYi = JosaUtil.pick(conquerNationName, '이');
|
const josaYi = JosaUtil.pick(conquerNationName, '이');
|
||||||
|
|
||||||
@@ -440,7 +518,6 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
|||||||
attackerLogger.pushNationHistoryLog(
|
attackerLogger.pushNationHistoryLog(
|
||||||
`<G><b>${cityName}</b></>${josaUl} <D><b>${conquerNationName}</b></>에 <Y>양도</>`
|
`<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);
|
affectedCities.add(defenderCity);
|
||||||
affectedNations.add(conquerNation);
|
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 {
|
return {
|
||||||
conquerNationId,
|
conquerNationId,
|
||||||
@@ -476,6 +562,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
|||||||
nations: Array.from(affectedNations),
|
nations: Array.from(affectedNations),
|
||||||
cities: Array.from(affectedCities),
|
cities: Array.from(affectedCities),
|
||||||
generals: Array.from(affectedGenerals),
|
generals: Array.from(affectedGenerals),
|
||||||
|
messages,
|
||||||
ruinedNpcJoinPlans,
|
ruinedNpcJoinPlans,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -484,6 +571,7 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
|
|||||||
input: WarAftermathInput<TriggerState>
|
input: WarAftermathInput<TriggerState>
|
||||||
): WarAftermathOutcome<TriggerState> => {
|
): WarAftermathOutcome<TriggerState> => {
|
||||||
const logs: LogEntryDraft[] = [];
|
const logs: LogEntryDraft[] = [];
|
||||||
|
const legacyFlushSequence = input.legacyFlushSequence ?? new LegacyWarLogFlushSequence();
|
||||||
const diplomacyDeltas: WarDiplomacyDelta[] = [];
|
const diplomacyDeltas: WarDiplomacyDelta[] = [];
|
||||||
const affectedNations = new Set<Nation>();
|
const affectedNations = new Set<Nation>();
|
||||||
const affectedCities = new Set<City>();
|
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
|
// nation-collapse RNG is consumed. Keep that order and the same RNG
|
||||||
// object instead of opening a second random stream.
|
// object instead of opening a second random stream.
|
||||||
const pipeline = new GeneralActionPipeline(input.generalActionModules ?? []);
|
const pipeline = new GeneralActionPipeline(input.generalActionModules ?? []);
|
||||||
const cityDefenders = input.generals.filter(
|
const cityDefenders = input.generals
|
||||||
|
.filter(
|
||||||
(general) =>
|
(general) =>
|
||||||
general.nationId !== 0 &&
|
general.nationId !== 0 &&
|
||||||
general.nationId === input.defenderCity.nationId &&
|
general.nationId === input.defenderCity.nationId &&
|
||||||
general.cityId === input.defenderCity.id
|
general.cityId === input.defenderCity.id
|
||||||
);
|
)
|
||||||
|
.sort((left, right) => left.id - right.id);
|
||||||
for (const general of cityDefenders) {
|
for (const general of cityDefenders) {
|
||||||
|
const generalLogger = new ActionLogger({
|
||||||
|
generalId: general.id,
|
||||||
|
nationId: general.nationId,
|
||||||
|
});
|
||||||
pipeline.dispatch(
|
pipeline.dispatch(
|
||||||
{
|
{
|
||||||
general,
|
general,
|
||||||
@@ -610,25 +704,20 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
|
|||||||
listNations: () => input.nations,
|
listNations: () => input.nations,
|
||||||
},
|
},
|
||||||
log: {
|
log: {
|
||||||
push: (text) =>
|
push: (text) => generalLogger.pushGeneralActionLog(text, LogFormat.MONTH),
|
||||||
logs.push({
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.MONTH,
|
|
||||||
generalId: general.id,
|
|
||||||
nationId: general.nationId,
|
|
||||||
text,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createGeneralActionEvent('city.conquered', {
|
createGeneralActionEvent('city.conquered', {
|
||||||
attacker: input.battle.attacker,
|
attacker: input.battle.attacker,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
// Ref는 ID 오름차순 city defender마다 onArbitraryAction 직후
|
||||||
|
// General::applyDB()를 호출한다. 로그가 없어도 epoch은 하나 소비한다.
|
||||||
|
pushLogger(generalLogger, logs, legacyFlushSequence);
|
||||||
affectedGenerals.add(general);
|
affectedGenerals.add(general);
|
||||||
}
|
}
|
||||||
|
|
||||||
conquest = resolveConquerCity(input, rng);
|
conquest = resolveConquerCity(input, rng, legacyFlushSequence);
|
||||||
logs.push(...conquest.logs);
|
logs.push(...conquest.logs);
|
||||||
|
|
||||||
conquest.nations.forEach((nation) => affectedNations.add(nation));
|
conquest.nations.forEach((nation) => affectedNations.add(nation));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
|||||||
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
|
import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
|
||||||
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.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 { buildCrewTypeIndex as buildCrewTypeDefinitionIndex } from '@sammo-ts/logic/world/unitSet.js';
|
||||||
import { WarActionPipeline, type WarActionModule } from './actions.js';
|
import { WarActionPipeline, type WarActionModule } from './actions.js';
|
||||||
import { createCrewTypeWarTriggerRegistry } from './crewTypeTriggers.js';
|
import { createCrewTypeWarTriggerRegistry } from './crewTypeTriggers.js';
|
||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
WarGeneralInput,
|
WarGeneralInput,
|
||||||
WarUnitReport,
|
WarUnitReport,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { LegacyWarLogFlushSequence } from './legacyFlushSequence.js';
|
||||||
import { getMetaNumber } from './utils.js';
|
import { getMetaNumber } from './utils.js';
|
||||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.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>(
|
export const resolveWarBattle = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||||
input: WarBattleInput<TriggerState>
|
input: WarBattleInput<TriggerState>
|
||||||
): WarBattleOutcome<TriggerState> => {
|
): WarBattleOutcome<TriggerState> => {
|
||||||
// process_war.php 전투 루프를 순수 로직으로 이식한다.
|
// process_war.php 전투 루프를 순수 로직으로 이식한다.
|
||||||
const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? ''));
|
const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? ''));
|
||||||
const loggerFactory = input.loggerFactory ?? defaultLoggerFactory;
|
const loggerFactory = input.loggerFactory ?? defaultLoggerFactory;
|
||||||
|
const legacyFlushSequence = input.legacyFlushSequence ?? new LegacyWarLogFlushSequence();
|
||||||
const triggerRegistry: WarTriggerRegistry = {
|
const triggerRegistry: WarTriggerRegistry = {
|
||||||
...createCrewTypeWarTriggerRegistry(),
|
...createCrewTypeWarTriggerRegistry(),
|
||||||
...(input.triggerRegistry ?? {}),
|
...(input.triggerRegistry ?? {}),
|
||||||
@@ -340,11 +334,21 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
|||||||
);
|
);
|
||||||
|
|
||||||
const iter = defenderUnits.values();
|
const iter = defenderUnits.values();
|
||||||
|
const logs: LogEntryDraft[] = [];
|
||||||
|
|
||||||
const getNextDefender = (
|
const getNextDefender = (
|
||||||
_prevDefender: WarUnit<TriggerState> | null,
|
prevDefender: WarUnit<TriggerState> | null,
|
||||||
reqNext: boolean
|
reqNext: boolean
|
||||||
): WarUnit<TriggerState> | null => {
|
): 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) {
|
if (!reqNext) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -698,9 +702,11 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
getNextDefender(defender, false);
|
||||||
emitTrace('battle_end', defender, { conquered: conquerCity });
|
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[] = [
|
const reports: WarUnitReport[] = [
|
||||||
resolveUnitReport(attackerUnit),
|
resolveUnitReport(attackerUnit),
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { orderLegacyActionLoggerFlush } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||||
|
import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||||
|
import type { LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ref의 한 `ActionLogger::flush()`/`General::applyDB()` 경계를 로그 정렬 그룹 하나로 보존한다.
|
||||||
|
* 시작값을 생략하면 그룹 표식 없이 logger별 bucket 순서만 유지한다.
|
||||||
|
*/
|
||||||
|
export class LegacyWarLogFlushSequence {
|
||||||
|
public constructor(private nextLegacyFlushGroup?: number) {}
|
||||||
|
|
||||||
|
public claimGroup(): number | undefined {
|
||||||
|
if (this.nextLegacyFlushGroup === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const group = this.nextLegacyFlushGroup;
|
||||||
|
this.nextLegacyFlushGroup += 1;
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
public flush(logger: ActionLogger): LogEntryDraft[] {
|
||||||
|
return this.flushEntries(logger.flush());
|
||||||
|
}
|
||||||
|
|
||||||
|
public flushEntries(entries: readonly LogEntryDraft[]): LogEntryDraft[] {
|
||||||
|
const ordered = orderLegacyActionLoggerFlush(entries);
|
||||||
|
const group = this.claimGroup();
|
||||||
|
if (group === undefined) {
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
return ordered.map((entry) => ({ ...entry, legacyFlushGroup: group }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WarUnitCity::applyDB()처럼 logger 내용을 버리지만 flush epoch은 소비하는 경계. */
|
||||||
|
public discard(logger: ActionLogger): void {
|
||||||
|
logger.rollback();
|
||||||
|
this.claimGroup();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic
|
|||||||
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
||||||
import type { ActionLogger } 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';
|
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 { TracePort } from '@sammo-ts/logic/ports/trace.js';
|
||||||
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
||||||
import type { WarActionModule } from './actions.js';
|
import type { WarActionModule } from './actions.js';
|
||||||
import type { WarTriggerRegistry } from './triggers.js';
|
import type { WarTriggerRegistry } from './triggers.js';
|
||||||
|
import type { LegacyWarLogFlushSequence } from './legacyFlushSequence.js';
|
||||||
|
|
||||||
export interface WarArmTypes {
|
export interface WarArmTypes {
|
||||||
footman?: number;
|
footman?: number;
|
||||||
@@ -58,6 +60,8 @@ export interface WarBattleInput<TriggerState extends GeneralTriggerState = Gener
|
|||||||
defenderNation: Nation | null;
|
defenderNation: Nation | null;
|
||||||
triggerRegistry?: WarTriggerRegistry;
|
triggerRegistry?: WarTriggerRegistry;
|
||||||
loggerFactory?: (options: { generalId?: number; nationId?: number }) => ActionLogger;
|
loggerFactory?: (options: { generalId?: number; nationId?: number }) => ActionLogger;
|
||||||
|
/** Ref processWar의 logger/applyDB epoch을 명령 전체에서 이어 주는 순서 cursor. */
|
||||||
|
legacyFlushSequence?: LegacyWarLogFlushSequence;
|
||||||
trace?: (event: WarBattleTraceEvent) => void;
|
trace?: (event: WarBattleTraceEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +181,7 @@ export interface ConquerCityOutcome<TriggerState extends GeneralTriggerState = G
|
|||||||
nations: Nation[];
|
nations: Nation[];
|
||||||
cities: City[];
|
cities: City[];
|
||||||
generals: General<TriggerState>[];
|
generals: General<TriggerState>[];
|
||||||
|
messages: MessageDraft[];
|
||||||
ruinedNpcJoinPlans: RuinedNpcJoinPlan[];
|
ruinedNpcJoinPlans: RuinedNpcJoinPlan[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,9 +197,14 @@ export interface WarAftermathInput<TriggerState extends GeneralTriggerState = Ge
|
|||||||
unitSet: UnitSetDefinition;
|
unitSet: UnitSetDefinition;
|
||||||
config: WarAftermathConfig;
|
config: WarAftermathConfig;
|
||||||
time: WarTimeContext;
|
time: WarTimeContext;
|
||||||
|
/** Ref Message::gameNow() at the command transaction's logical tick. */
|
||||||
|
messageTime: Date;
|
||||||
|
messageSharedIconBaseUrl?: string;
|
||||||
hiddenSeed?: string;
|
hiddenSeed?: string;
|
||||||
rng?: RandUtil;
|
rng?: RandUtil;
|
||||||
generalActionModules?: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>;
|
generalActionModules?: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>;
|
||||||
|
/** 전투에서 시작한 Ref logger/applyDB epoch 순서를 점령 후처리까지 이어 간다. */
|
||||||
|
legacyFlushSequence?: LegacyWarLogFlushSequence;
|
||||||
calcNationTechGain?: (context: WarAftermathTechContext) => number;
|
calcNationTechGain?: (context: WarAftermathTechContext) => number;
|
||||||
trace?: TracePort;
|
trace?: TracePort;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,5 +116,8 @@ describe('appointment global summary log format', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expectMonthlySummary(logs);
|
expectMonthlySummary(logs);
|
||||||
|
expect(
|
||||||
|
logs.find((entry) => entry.scope === LogScope.GENERAL && entry.category === LogCategory.HISTORY)?.format
|
||||||
|
).toBe(LogFormat.YEAR_MONTH);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ describe('processGeneralActionWithFallback', () => {
|
|||||||
|
|
||||||
const fallbackResolver: GeneralActionResolver = {
|
const fallbackResolver: GeneralActionResolver = {
|
||||||
key: 'fallbackCmd',
|
key: 'fallbackCmd',
|
||||||
resolve: () => ({
|
resolve: (context) => {
|
||||||
|
context.addPostProgressionLog?.('Primary post-progression');
|
||||||
|
return {
|
||||||
effects: [
|
effects: [
|
||||||
{
|
{
|
||||||
type: 'log',
|
type: 'log',
|
||||||
@@ -38,7 +40,8 @@ describe('processGeneralActionWithFallback', () => {
|
|||||||
commandKey: 'alternativeCmd',
|
commandKey: 'alternativeCmd',
|
||||||
args: { foo: 'bar' },
|
args: { foo: 'bar' },
|
||||||
},
|
},
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const alternativeResolver: GeneralActionResolver = {
|
const alternativeResolver: GeneralActionResolver = {
|
||||||
@@ -100,18 +103,8 @@ describe('processGeneralActionWithFallback', () => {
|
|||||||
mockLoader
|
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.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(resolution.alternative).toBeUndefined(); // The final one succeeded
|
||||||
expect(mockLoader.load).toHaveBeenCalledWith('alternativeCmd');
|
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 type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||||
import { evaluateConstraints } from '../../../src/constraints/evaluate.js';
|
import { evaluateConstraints } from '../../../src/constraints/evaluate.js';
|
||||||
import { resolveGeneralAction } from '../../../src/actions/engine.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 { MapDefinition } from '../../../src/world/types.js';
|
||||||
import type { TurnSchedule } from '../../../src/turn/calendar.js';
|
import type { TurnSchedule } from '../../../src/turn/calendar.js';
|
||||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.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();
|
expect(definition.parseArgs({ destNationId: 2, amountList: [-1, 10] })).toBeNull();
|
||||||
|
|
||||||
const general = buildGeneral(1, 1, 1);
|
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 nation = { ...buildNation(1), gold: 1000, rice: 1000 };
|
||||||
const destNation = { ...buildNation(2), gold: 100, rice: 100 };
|
const destNation = { ...buildNation(2), gold: 100, rice: 100 };
|
||||||
const resolution = resolveGeneralAction(
|
const resolution = resolveGeneralAction(
|
||||||
@@ -364,8 +367,8 @@ describe('Nation Missing Actions', () => {
|
|||||||
city: buildCity(1, 1),
|
city: buildCity(1, 1),
|
||||||
nation,
|
nation,
|
||||||
destNation,
|
destNation,
|
||||||
friendlyChiefs: [general],
|
friendlyChiefs: [general, sourceChief],
|
||||||
destNationChiefs: [],
|
destNationChiefs: [destChief],
|
||||||
rng: {} as any,
|
rng: {} as any,
|
||||||
addLog: () => {},
|
addLog: () => {},
|
||||||
} as any,
|
} 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', () => {
|
it('che_초토화: blocks when diplomacy limit exists', () => {
|
||||||
|
|||||||
@@ -109,16 +109,21 @@ describe('nation volunteer recruitment lifespan', () => {
|
|||||||
return;
|
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({
|
expect(created).toMatchObject({
|
||||||
name: 'ⓖ장수',
|
name: 'ⓖ장수',
|
||||||
|
affinity: 1,
|
||||||
bornYear: 170,
|
bornYear: 170,
|
||||||
deadYear: 200,
|
deadYear: 200,
|
||||||
experience: 2_000,
|
experience: 2_000,
|
||||||
dedication: 2_000,
|
dedication: 2_000,
|
||||||
meta: {
|
meta: {
|
||||||
|
affinity: 1,
|
||||||
birthYear: 170,
|
birthYear: 170,
|
||||||
deathYear: 200,
|
deathYear: 200,
|
||||||
|
npc_org: 4,
|
||||||
|
explevel: 0,
|
||||||
|
dedlevel: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ describe('talent scout scenario general pool', () => {
|
|||||||
imageServer: 1,
|
imageServer: 1,
|
||||||
role: { specialDomestic: null, specialWar: null },
|
role: { specialDomestic: null, specialWar: null },
|
||||||
meta: {
|
meta: {
|
||||||
|
npc_org: 3,
|
||||||
dex1: 12,
|
dex1: 12,
|
||||||
dex2: 24,
|
dex2: 24,
|
||||||
dex3: 36,
|
dex3: 36,
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ describe('instant diplomatic response parity', () => {
|
|||||||
const logs = result.effects.filter((effect) => effect.type === 'log');
|
const logs = result.effects.filter((effect) => effect.type === 'log');
|
||||||
expect(logs).toHaveLength(4);
|
expect(logs).toHaveLength(4);
|
||||||
expect(logs.map((effect) => effect.entry.generalId)).toEqual([11, 11, 22, 22]);
|
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', () => {
|
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.actionKey).toBe('che_불가침파기수락');
|
||||||
expect(result.refreshFront).toBe(false);
|
expect(result.refreshFront).toBe(false);
|
||||||
expect(logs).toHaveLength(6);
|
expect(logs).toHaveLength(6);
|
||||||
|
expect(logs.map((effect) => effect.entry.legacyFlushGroup ?? 0)).toEqual([0, 0, 0, 0, 1, 1]);
|
||||||
expect(logs).toContainEqual(
|
expect(logs).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
entry: 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', () => {
|
it('creates both nation histories and requests front refresh for stop-war', () => {
|
||||||
const result = resolveInstantDiplomacyResponse(context, { action: 'stopWar' });
|
const result = resolveInstantDiplomacyResponse(context, { action: 'stopWar' });
|
||||||
|
const logs = result.effects.filter((effect) => effect.type === 'log');
|
||||||
const nationLogIds = result.effects.flatMap((effect) =>
|
const nationLogIds = result.effects.flatMap((effect) =>
|
||||||
effect.type === 'log' && effect.entry.scope === LogScope.NATION ? [effect.entry.nationId] : []
|
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.actionKey).toBe('che_종전수락');
|
||||||
expect(result.refreshFront).toBe(true);
|
expect(result.refreshFront).toBe(true);
|
||||||
expect(nationLogIds).toEqual([1, 2]);
|
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', () => {
|
it('recomputes only requested nation fronts with legacy priority', () => {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import type { DispatchResolveContext } from '../src/actions/turn/general/che_출
|
|||||||
import type { TurnSchedule } from '../src/turn/calendar.js';
|
import type { TurnSchedule } from '../src/turn/calendar.js';
|
||||||
import type { WarAftermathConfig, WarEngineConfig } from '../src/war/types.js';
|
import type { WarAftermathConfig, WarEngineConfig } from '../src/war/types.js';
|
||||||
import type { UnitSetDefinition } from '../src/world/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 => ({
|
const buildGeneral = (id: number, nationId: number, cityId: number): General => ({
|
||||||
id,
|
id,
|
||||||
@@ -175,6 +178,19 @@ const schedule: TurnSchedule = {
|
|||||||
entries: [{ startMinute: 0, tickMinutes: 60 }],
|
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_출병', () => {
|
describe('che_출병', () => {
|
||||||
it('runs war battle and emits patches/logs', () => {
|
it('runs war battle and emits patches/logs', () => {
|
||||||
const attackerNation = buildNation(1);
|
const attackerNation = buildNation(1);
|
||||||
@@ -183,14 +199,18 @@ describe('che_출병', () => {
|
|||||||
const defenderCity = buildCity(2, defenderNation.id);
|
const defenderCity = buildCity(2, defenderNation.id);
|
||||||
const neutralCity = buildCity(3, 0);
|
const neutralCity = buildCity(3, 0);
|
||||||
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
|
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
|
||||||
|
attacker.officerLevel = 12;
|
||||||
attacker.turnTime = new Date('2000-01-01T00:00:00Z');
|
attacker.turnTime = new Date('2000-01-01T00:00:00Z');
|
||||||
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
|
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
|
||||||
|
defender.officerLevel = 12;
|
||||||
defender.crew = 0;
|
defender.crew = 0;
|
||||||
defenderCity.defence = 0;
|
defenderCity.defence = 0;
|
||||||
defenderCity.wall = 0;
|
defenderCity.wall = 0;
|
||||||
|
|
||||||
const definition = new ActionDefinition();
|
const definition = new ActionDefinition();
|
||||||
const context: Omit<DispatchResolveContext, 'addLog'> = {
|
const context: Omit<DispatchResolveContext, 'addLog'> & {
|
||||||
|
uniqueLottery: () => ItemModule;
|
||||||
|
} = {
|
||||||
general: attacker,
|
general: attacker,
|
||||||
city: attackerCity,
|
city: attackerCity,
|
||||||
nation: attackerNation,
|
nation: attackerNation,
|
||||||
@@ -246,9 +266,12 @@ describe('che_출병', () => {
|
|||||||
month: 1,
|
month: 1,
|
||||||
startYear: 180,
|
startYear: 180,
|
||||||
},
|
},
|
||||||
seedBase: 'test-seed',
|
seedBase: 'test-seed-2',
|
||||||
warConfig,
|
warConfig,
|
||||||
aftermathConfig,
|
aftermathConfig,
|
||||||
|
messageTime: new Date('2000-01-01T00:00:00.000Z'),
|
||||||
|
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
|
||||||
|
uniqueLottery: () => uniqueItem,
|
||||||
};
|
};
|
||||||
const resolution = resolveGeneralAction(
|
const resolution = resolveGeneralAction(
|
||||||
definition,
|
definition,
|
||||||
@@ -278,6 +301,44 @@ describe('che_출병', () => {
|
|||||||
effect.destNationId === defenderNation.id
|
effect.destNationId === defenderNation.id
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).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', () => {
|
it('orders equal-priority defender inputs by general number before the stable battle sort', () => {
|
||||||
@@ -347,6 +408,7 @@ describe('che_출병', () => {
|
|||||||
seedBase: 'route-layer-seed',
|
seedBase: 'route-layer-seed',
|
||||||
warConfig,
|
warConfig,
|
||||||
aftermathConfig,
|
aftermathConfig,
|
||||||
|
messageTime: new Date('2000-01-01T00:00:00.000Z'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolution = resolveGeneralAction(
|
const resolution = resolveGeneralAction(
|
||||||
|
|||||||
@@ -1,6 +1,96 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('finalizeLogEntry', () => {
|
||||||
it('uses an explicit draft year and month for pre-month logs', () => {
|
it('uses an explicit draft year and month for pre-month logs', () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||||
MESSAGE_MAILBOX_PUBLIC,
|
MESSAGE_MAILBOX_PUBLIC,
|
||||||
|
resolveMessageTargetIcon,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
type MessageDraft,
|
type MessageDraft,
|
||||||
type MessageRecordDraft,
|
type MessageRecordDraft,
|
||||||
@@ -88,7 +89,7 @@ describe('sendMessage', () => {
|
|||||||
expect(store.records[0]!.draft.mailbox).toBe(MESSAGE_MAILBOX_PUBLIC);
|
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 store = new InMemoryMessageStore();
|
||||||
const draft = buildDraft({
|
const draft = buildDraft({
|
||||||
msgType: 'diplomacy',
|
msgType: 'diplomacy',
|
||||||
@@ -98,8 +99,38 @@ describe('sendMessage', () => {
|
|||||||
|
|
||||||
await sendMessage(store, draft);
|
await sendMessage(store, draft);
|
||||||
|
|
||||||
const senderPayload = store.records[1]!.draft.payload.option ?? {};
|
expect(store.records[1]!.draft.payload.option).toBeNull();
|
||||||
expect(senderPayload).not.toHaveProperty('action');
|
});
|
||||||
expect(senderPayload).toMatchObject({ payload: 1 });
|
|
||||||
|
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 { 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', () => {
|
describe('che_등용 recruitment message', () => {
|
||||||
it('queues the Ref scout prompt with sender and receiver snapshots', () => {
|
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 sourceNation = { id: 1, name: '위', color: '#ffffff' };
|
||||||
const destinationNation = { id: 2, name: '촉', color: '#000000' };
|
const destinationNation = { id: 2, name: '촉', color: '#000000' };
|
||||||
const messageTime = new Date('0200-01-01T00:10:00.000Z');
|
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(
|
const result = new ActionResolver().resolve(
|
||||||
{
|
{
|
||||||
@@ -54,7 +71,7 @@ describe('che_등용 recruitment message', () => {
|
|||||||
nationId: sourceNation.id,
|
nationId: sourceNation.id,
|
||||||
nationName: sourceNation.name,
|
nationName: sourceNation.name,
|
||||||
color: sourceNation.color,
|
color: sourceNation.color,
|
||||||
icon: '',
|
icon: 'https://sam-image.hided.net/icons/default.jpg',
|
||||||
},
|
},
|
||||||
dest: {
|
dest: {
|
||||||
generalId: destination.id,
|
generalId: destination.id,
|
||||||
@@ -62,13 +79,16 @@ describe('che_등용 recruitment message', () => {
|
|||||||
nationId: destinationNation.id,
|
nationId: destinationNation.id,
|
||||||
nationName: destinationNation.name,
|
nationName: destinationNation.name,
|
||||||
color: destinationNation.color,
|
color: destinationNation.color,
|
||||||
icon: '',
|
icon: 'https://sam-image.hided.net/icons/default.jpg',
|
||||||
},
|
},
|
||||||
text: '위로 망명 권유 서신',
|
text: '위로 망명 권유 서신',
|
||||||
time: messageTime,
|
time: messageTime,
|
||||||
validUntil: new Date('9999-12-31T12:59:59.000Z'),
|
validUntil: new Date('9999-12-31T12:59:59.000Z'),
|
||||||
option: { action: 'scout' },
|
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 { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
|
||||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||||
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.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 = {
|
const MOCK_SCENARIO_BASE = {
|
||||||
title: 'Test',
|
title: 'Test',
|
||||||
@@ -224,6 +227,57 @@ describe('che_등용수락', () => {
|
|||||||
|
|
||||||
const { commandSpec } = await import('../../../src/actions/turn/general/che_등용수락.js');
|
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([
|
await runner.runTurn([
|
||||||
{
|
{
|
||||||
generalId: neutralGen.id,
|
generalId: neutralGen.id,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { General, Nation } from '../../../src/domain/entities.js';
|
|||||||
import { buildScenarioBootstrap } from '../../../src/world/bootstrap.js';
|
import { buildScenarioBootstrap } from '../../../src/world/bootstrap.js';
|
||||||
import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
|
import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
|
||||||
import type { MapDefinition } from '../../../src/world/types.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 = {
|
const MOCK_SCENARIO_BASE = {
|
||||||
title: 'Test',
|
title: 'Test',
|
||||||
@@ -56,19 +58,9 @@ const LINEAR_MAP: MapDefinition = {
|
|||||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('che_이동', () => {
|
const buildGeneral = (id: number, name: string): General => ({
|
||||||
it('applies movement side effects (gold/atmos/exp/leadership_exp)', async () => {
|
id,
|
||||||
const bootstrapResult = buildScenarioBootstrap({
|
name,
|
||||||
scenario: MOCK_SCENARIO_BASE,
|
|
||||||
map: LINEAR_MAP,
|
|
||||||
options: { defaultGeneralGold: 1000 },
|
|
||||||
});
|
|
||||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
|
||||||
const runner = new TestGameRunner(world, 200, 1);
|
|
||||||
|
|
||||||
const general: General = {
|
|
||||||
id: 1,
|
|
||||||
name: 'Mover',
|
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
cityId: 101,
|
cityId: 101,
|
||||||
troopId: 0,
|
troopId: 0,
|
||||||
@@ -93,8 +85,9 @@ describe('che_이동', () => {
|
|||||||
npcState: 0,
|
npcState: 0,
|
||||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
meta: { killturn: 24 },
|
meta: { killturn: 24 },
|
||||||
};
|
});
|
||||||
const nation: Nation = {
|
|
||||||
|
const buildNation = (level = 1): Nation => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'MyNation',
|
name: 'MyNation',
|
||||||
color: '#000',
|
color: '#000',
|
||||||
@@ -103,10 +96,23 @@ describe('che_이동', () => {
|
|||||||
gold: 0,
|
gold: 0,
|
||||||
rice: 0,
|
rice: 0,
|
||||||
power: 0,
|
power: 0,
|
||||||
level: 1,
|
level,
|
||||||
typeCode: 'che_def',
|
typeCode: 'che_def',
|
||||||
meta: {},
|
meta: {},
|
||||||
};
|
});
|
||||||
|
|
||||||
|
describe('che_이동', () => {
|
||||||
|
it('applies movement side effects (gold/atmos/exp/leadership_exp)', async () => {
|
||||||
|
const bootstrapResult = buildScenarioBootstrap({
|
||||||
|
scenario: MOCK_SCENARIO_BASE,
|
||||||
|
map: LINEAR_MAP,
|
||||||
|
options: { defaultGeneralGold: 1000 },
|
||||||
|
});
|
||||||
|
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||||
|
const runner = new TestGameRunner(world, 200, 1);
|
||||||
|
|
||||||
|
const general = buildGeneral(1, 'Mover');
|
||||||
|
const nation = buildNation();
|
||||||
world.snapshot.generals.push(general);
|
world.snapshot.generals.push(general);
|
||||||
world.snapshot.nations.push(nation);
|
world.snapshot.nations.push(nation);
|
||||||
|
|
||||||
@@ -134,4 +140,46 @@ describe('che_이동', () => {
|
|||||||
expect(updated?.experience).toBe(50);
|
expect(updated?.experience).toBe(50);
|
||||||
expect(updated?.meta.leadership_exp).toBe(1);
|
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 type { TurnCommandEnv, TurnCommandItemCatalogEntry } from '../../../src/actions/turn/commandEnv.js';
|
||||||
import { commandSpec as rebellionSpec } from '../../../src/actions/turn/general/che_모반시도.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 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 giftSpec } from '../../../src/actions/turn/general/che_증여.js';
|
||||||
import { commandSpec as disbandSpec } 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 foundNationSpec } from '../../../src/actions/turn/general/cr_건국.js';
|
||||||
import { commandSpec as tradeItemSpec } from '../../../src/actions/turn/general/che_장비매매.js';
|
import { commandSpec as tradeItemSpec } from '../../../src/actions/turn/general/che_장비매매.js';
|
||||||
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||||
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.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 = {
|
const SYSTEM_ENV: TurnCommandEnv = {
|
||||||
develCost: 100,
|
develCost: 100,
|
||||||
@@ -168,6 +172,31 @@ const makeSnapshot = (params: {
|
|||||||
initialEvents: [],
|
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', () => {
|
describe('migrated general commands', () => {
|
||||||
it('che_모반시도: 군주를 찬탈한다', async () => {
|
it('che_모반시도: 군주를 찬탈한다', async () => {
|
||||||
const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, experience: 1000 });
|
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);
|
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 () => {
|
it('cr_건국: 국가 정보를 건국 상태로 갱신한다', async () => {
|
||||||
const lord = makeGeneral({
|
const lord = makeGeneral({
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -390,7 +390,9 @@ describe('General Commands New Scenario', () => {
|
|||||||
|
|
||||||
const g1_after_resign = world.getGeneral(1)!;
|
const g1_after_resign = world.getGeneral(1)!;
|
||||||
expect(g1_after_resign.nationId).toBe(0);
|
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)
|
// 6. Retire (Needs age >= 60)
|
||||||
// Manually set age
|
// 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 휴식');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,9 +8,12 @@ import type { UnitSetDefinition } from '../src/world/types.js';
|
|||||||
import { resolveWarAftermath } from '../src/war/aftermath.js';
|
import { resolveWarAftermath } from '../src/war/aftermath.js';
|
||||||
import type { WarAftermathConfig } from '../src/war/types.js';
|
import type { WarAftermathConfig } from '../src/war/types.js';
|
||||||
import { LogFormat } from '../src/logging/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 { buildWarAftermathConfig, buildWarConfig } from '../src/actions/turn/actionContextHelpers.js';
|
||||||
import type { ScenarioConfig } from '../src/scenario/types.js';
|
import type { ScenarioConfig } from '../src/scenario/types.js';
|
||||||
|
|
||||||
|
const MESSAGE_TIME = new Date('0185-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
const buildUnitSet = (): UnitSetDefinition => ({
|
const buildUnitSet = (): UnitSetDefinition => ({
|
||||||
id: 'test',
|
id: 'test',
|
||||||
name: 'test',
|
name: 'test',
|
||||||
@@ -188,6 +191,7 @@ describe('war aftermath', () => {
|
|||||||
unitSet: buildUnitSet(),
|
unitSet: buildUnitSet(),
|
||||||
config: { ...buildConfig(), maxTechLevel: 15 },
|
config: { ...buildConfig(), maxTechLevel: 15 },
|
||||||
time: { year: 200, month: 1, startYear: 180 },
|
time: { year: 200, month: 1, startYear: 180 },
|
||||||
|
messageTime: MESSAGE_TIME,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(defenderNation.rice).toBe(985);
|
expect(defenderNation.rice).toBe(985);
|
||||||
@@ -232,6 +236,7 @@ describe('war aftermath', () => {
|
|||||||
month: 1,
|
month: 1,
|
||||||
startYear: 180,
|
startYear: 180,
|
||||||
},
|
},
|
||||||
|
messageTime: MESSAGE_TIME,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(attackerNation.meta.tech).toBe(Math.fround(1000.6));
|
expect(attackerNation.meta.tech).toBe(Math.fround(1000.6));
|
||||||
@@ -271,6 +276,7 @@ describe('war aftermath', () => {
|
|||||||
unitSet: buildUnitSet(),
|
unitSet: buildUnitSet(),
|
||||||
config: buildConfig(),
|
config: buildConfig(),
|
||||||
time: { year: 200, month: 1, startYear: 180 },
|
time: { year: 200, month: 1, startYear: 180 },
|
||||||
|
messageTime: MESSAGE_TIME,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(attackerCity.meta.dead).toBe(71);
|
expect(attackerCity.meta.dead).toBe(71);
|
||||||
@@ -320,6 +326,7 @@ describe('war aftermath', () => {
|
|||||||
month: 1,
|
month: 1,
|
||||||
startYear: 180,
|
startYear: 180,
|
||||||
},
|
},
|
||||||
|
messageTime: MESSAGE_TIME,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(defenderNation.capitalCityId).toBe(nextCapital.id);
|
expect(defenderNation.capitalCityId).toBe(nextCapital.id);
|
||||||
@@ -330,6 +337,7 @@ describe('war aftermath', () => {
|
|||||||
'수뇌는 <G><b>City3</b></>으로 집합되었습니다.',
|
'수뇌는 <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', () => {
|
it('uses the city battle phase, not retained casualties, for conquered supply-city rice', () => {
|
||||||
@@ -372,6 +380,7 @@ describe('war aftermath', () => {
|
|||||||
unitSet: buildUnitSet(),
|
unitSet: buildUnitSet(),
|
||||||
config: buildConfig(),
|
config: buildConfig(),
|
||||||
time: { year: 200, month: 1, startYear: 180 },
|
time: { year: 200, month: 1, startYear: 180 },
|
||||||
|
messageTime: MESSAGE_TIME,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(defenderNation.rice).toBe(6500);
|
expect(defenderNation.rice).toBe(6500);
|
||||||
@@ -389,6 +398,11 @@ describe('war aftermath', () => {
|
|||||||
const attacker = buildGeneral(1, 1, 1);
|
const attacker = buildGeneral(1, 1, 1);
|
||||||
attacker.officerLevel = 12;
|
attacker.officerLevel = 12;
|
||||||
const defender = buildGeneral(2, 2, 2);
|
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({
|
const outcome = resolveWarAftermath({
|
||||||
battle: {
|
battle: {
|
||||||
@@ -430,14 +444,16 @@ describe('war aftermath', () => {
|
|||||||
month: 1,
|
month: 1,
|
||||||
startYear: 180,
|
startYear: 180,
|
||||||
},
|
},
|
||||||
|
messageTime: MESSAGE_TIME,
|
||||||
rng,
|
rng,
|
||||||
|
legacyFlushSequence: new LegacyWarLogFlushSequence(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(outcome.conquest?.nationCollapsed).toBe(true);
|
expect(outcome.conquest?.nationCollapsed).toBe(true);
|
||||||
expect(attackerNation.gold).toBe(3600);
|
expect(attackerNation.gold).toBe(3600);
|
||||||
expect(attackerNation.rice).toBe(4600);
|
expect(attackerNation.rice).toBe(4600);
|
||||||
expect(defender.experience).toBe(90);
|
expect(defender.experience).toBe(1_800);
|
||||||
expect(defender.dedication).toBe(50);
|
expect(defender.dedication).toBe(1_000);
|
||||||
// Removed nations can remain in old persisted conflict data. They
|
// Removed nations can remain in old persisted conflict data. They
|
||||||
// must never receive ownership during a later conquest.
|
// must never receive ownership during a later conquest.
|
||||||
expect(defenderCity.nationId).toBe(attackerNation.id);
|
expect(defenderCity.nationId).toBe(attackerNation.id);
|
||||||
@@ -447,9 +463,14 @@ describe('war aftermath', () => {
|
|||||||
'<D><b>Nation2</b></>를 정복',
|
'<D><b>Nation2</b></>를 정복',
|
||||||
'<R><b>【멸망】</b></><D><b>Nation2</b></>는 <R>멸망</>했습니다.',
|
'<R><b>【멸망】</b></><D><b>Nation2</b></>는 <R>멸망</>했습니다.',
|
||||||
'<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</>을 획득했습니다.',
|
'<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', () => {
|
it('matches ruined-nation lord ordering and NPC appointment draws', () => {
|
||||||
@@ -464,7 +485,7 @@ describe('war aftermath', () => {
|
|||||||
npc.npcState = 2;
|
npc.npcState = 2;
|
||||||
|
|
||||||
const rangeDraws = [0.2, 0.21, 0.4, 0.41];
|
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 = {
|
const rng = {
|
||||||
nextRange: vi.fn(() => rangeDraws.shift()!),
|
nextRange: vi.fn(() => rangeDraws.shift()!),
|
||||||
nextBool,
|
nextBool,
|
||||||
@@ -495,6 +516,8 @@ describe('war aftermath', () => {
|
|||||||
joinRuinedNpcProbability: 0.1,
|
joinRuinedNpcProbability: 0.1,
|
||||||
},
|
},
|
||||||
time: { year: 186, month: 1, startYear: 179 },
|
time: { year: 186, month: 1, startYear: 179 },
|
||||||
|
messageTime: MESSAGE_TIME,
|
||||||
|
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
|
||||||
rng,
|
rng,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -503,6 +526,32 @@ describe('war aftermath', () => {
|
|||||||
expect(lord.gold).toBe(600);
|
expect(lord.gold).toBe(600);
|
||||||
expect(lord.rice).toBe(590);
|
expect(lord.rice).toBe(590);
|
||||||
expect(nextBool.mock.calls.map(([probability]) => probability)).toEqual([0.5, 0.1, 0.5]);
|
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([
|
expect(outcome.conquest?.ruinedNpcJoinPlans).toEqual([
|
||||||
{ generalId: npc.id, destNationId: attackerNation.id, joinTurn: 6 },
|
{ generalId: npc.id, destNationId: attackerNation.id, joinTurn: 6 },
|
||||||
]);
|
]);
|
||||||
@@ -523,10 +572,12 @@ describe('war aftermath', () => {
|
|||||||
const attackerCity = buildCity(1, 1);
|
const attackerCity = buildCity(1, 1);
|
||||||
const defenderCity = buildCity(2, 2);
|
const defenderCity = buildCity(2, 2);
|
||||||
const attacker = buildGeneral(1, 1, 1);
|
const attacker = buildGeneral(1, 1, 1);
|
||||||
|
attacker.officerLevel = 12;
|
||||||
const firstDefender = buildGeneral(2, 2, 2);
|
const firstDefender = buildGeneral(2, 2, 2);
|
||||||
const secondDefender = buildGeneral(3, 2, 2);
|
const secondDefender = buildGeneral(3, 2, 2);
|
||||||
secondDefender.crew = 0;
|
secondDefender.crew = 0;
|
||||||
const elsewhere = buildGeneral(4, 2, 3);
|
const elsewhere = buildGeneral(4, 2, 3);
|
||||||
|
elsewhere.officerLevel = 12;
|
||||||
const dispatchOrder: number[] = [];
|
const dispatchOrder: number[] = [];
|
||||||
const module: GeneralActionModule = {
|
const module: GeneralActionModule = {
|
||||||
eventHandlers: {
|
eventHandlers: {
|
||||||
@@ -562,8 +613,10 @@ describe('war aftermath', () => {
|
|||||||
month: 1,
|
month: 1,
|
||||||
startYear: 180,
|
startYear: 180,
|
||||||
},
|
},
|
||||||
|
messageTime: MESSAGE_TIME,
|
||||||
rng,
|
rng,
|
||||||
generalActionModules: [module],
|
generalActionModules: [module],
|
||||||
|
legacyFlushSequence: new LegacyWarLogFlushSequence(200),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(dispatchOrder).toEqual([firstDefender.id, secondDefender.id]);
|
expect(dispatchOrder).toEqual([firstDefender.id, secondDefender.id]);
|
||||||
@@ -585,6 +638,27 @@ describe('war aftermath', () => {
|
|||||||
LogFormat.MONTH,
|
LogFormat.MONTH,
|
||||||
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(outcome.generals.map((general) => general.id)).toEqual(
|
||||||
expect.arrayContaining([firstDefender.id, secondDefender.id])
|
expect.arrayContaining([firstDefender.id, secondDefender.id])
|
||||||
);
|
);
|
||||||
@@ -626,11 +700,21 @@ describe('war aftermath', () => {
|
|||||||
month: 1,
|
month: 1,
|
||||||
startYear: 180,
|
startYear: 180,
|
||||||
},
|
},
|
||||||
|
messageTime: MESSAGE_TIME,
|
||||||
|
legacyFlushSequence: new LegacyWarLogFlushSequence(300),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(outcome.conquest?.conquerNationId).toBe(4);
|
expect(outcome.conquest?.conquerNationId).toBe(4);
|
||||||
expect(defenderCity.nationId).toBe(4);
|
expect(defenderCity.nationId).toBe(4);
|
||||||
expect(defenderCity.meta.conflict_order).toEqual([]);
|
expect(defenderCity.meta.conflict_order).toEqual([]);
|
||||||
expect(attacker.cityId).toBe(1);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { UnitSetDefinition } from '../src/world/types.js';
|
|||||||
import { ActionLogger } from '../src/logging/actionLogger.js';
|
import { ActionLogger } from '../src/logging/actionLogger.js';
|
||||||
import { WarActionPipeline } from '../src/war/actions.js';
|
import { WarActionPipeline } from '../src/war/actions.js';
|
||||||
import { resolveWarBattle } from '../src/war/engine.js';
|
import { resolveWarBattle } from '../src/war/engine.js';
|
||||||
|
import { LegacyWarLogFlushSequence } from '../src/war/legacyFlushSequence.js';
|
||||||
import type { WarEngineConfig } from '../src/war/types.js';
|
import type { WarEngineConfig } from '../src/war/types.js';
|
||||||
import { WarCrewType } from '../src/war/crewType.js';
|
import { WarCrewType } from '../src/war/crewType.js';
|
||||||
import { loadWarTriggerModules } from '../src/war/triggers/index.js';
|
import { loadWarTriggerModules } from '../src/war/triggers/index.js';
|
||||||
@@ -535,6 +536,64 @@ describe('war triggers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveWarBattle', () => {
|
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 () => {
|
it('persists multi-use battle item charges and removes the last charge', async () => {
|
||||||
const general = buildGeneral(100);
|
const general = buildGeneral(100);
|
||||||
equipNewItem(general, 'item', 'event_충차', { charges: 2 });
|
equipNewItem(general, 'item', 'event_충차', { charges: 2 });
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user