fix: 커맨드 차등 생명주기와 로그 그래프를 보강
장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다. 실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
finalizeLogEntry,
|
||||
LogFormat,
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||
orderLegacyActionLoggerFlush,
|
||||
resolveInstantDiplomacyResponse,
|
||||
sendMessage,
|
||||
type GeneralActionEffect,
|
||||
@@ -121,7 +122,7 @@ const persistEffects = async (
|
||||
logs.push(effect.entry);
|
||||
}
|
||||
}
|
||||
await persistLogs(db, logs, year, month, at);
|
||||
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at);
|
||||
};
|
||||
|
||||
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
} from '../../turns/commandTable.js';
|
||||
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
||||
import {
|
||||
assertReservedTurnActionAvailable,
|
||||
buildEquipmentTradeItemOptions,
|
||||
parseReservedTurnArgs,
|
||||
parseRegisteredTurnArgs,
|
||||
TURN_COMMAND_NATION_COLORS,
|
||||
type TurnCommandInputOptions,
|
||||
} from '../../turns/commandInput.js';
|
||||
@@ -65,7 +66,7 @@ const buildBulkEntrySchema = (turnList: z.ZodType<number[]>) =>
|
||||
|
||||
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
|
||||
try {
|
||||
return await parseReservedTurnArgs(scope, action, args);
|
||||
return await parseRegisteredTurnArgs(scope, action, args);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@@ -75,6 +76,22 @@ const parseCommandArgs = async (scope: 'general' | 'nation', action: string, arg
|
||||
}
|
||||
};
|
||||
|
||||
const assertScenarioCommandAvailable = async (
|
||||
scope: 'general' | 'nation',
|
||||
action: string,
|
||||
worldState: WorldStateRow
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await assertReservedTurnActionAvailable(scope, action, asRecord(worldState.config).const);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: error instanceof Error ? error.message : 'Unavailable turn command.',
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await mutation();
|
||||
@@ -413,6 +430,7 @@ export const turnsRouter = router({
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
const args = await parseCommandArgs('general', input.action, input.args);
|
||||
const worldState = await getReservationWorldState(ctx);
|
||||
await assertScenarioCommandAvailable('general', input.action, worldState);
|
||||
await assertReservedTurnPermission(worldState, general, 'general', input.action, args);
|
||||
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
@@ -476,6 +494,7 @@ export const turnsRouter = router({
|
||||
);
|
||||
const worldState = await getReservationWorldState(ctx);
|
||||
for (const update of updates) {
|
||||
await assertScenarioCommandAvailable('general', update.action, worldState);
|
||||
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
|
||||
}
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
@@ -515,6 +534,7 @@ export const turnsRouter = router({
|
||||
}
|
||||
const args = await parseCommandArgs('nation', input.action, input.args);
|
||||
const worldState = await getReservationWorldState(ctx);
|
||||
await assertScenarioCommandAvailable('nation', input.action, worldState);
|
||||
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
|
||||
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
@@ -628,6 +648,7 @@ export const turnsRouter = router({
|
||||
);
|
||||
const worldState = await getReservationWorldState(ctx);
|
||||
for (const update of updates) {
|
||||
await assertScenarioCommandAvailable('nation', update.action, worldState);
|
||||
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
||||
}
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
isGeneralTurnCommandKey,
|
||||
isNationTurnCommandKey,
|
||||
loadGeneralTurnCommandSpecs,
|
||||
loadNationTurnCommandSpecs,
|
||||
type GeneralTurnCommandSpec,
|
||||
@@ -9,7 +11,7 @@ import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { loadTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
||||
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
||||
|
||||
export type TurnCommandOptionValue = string | number;
|
||||
|
||||
@@ -345,22 +347,35 @@ export const buildTurnCommandInputFields = (
|
||||
return Object.entries(properties).map(([key, schema]) => buildField(key, schema, required.has(key)));
|
||||
};
|
||||
|
||||
export const loadTurnCommandSpecs = async () => {
|
||||
const profile = await loadTurnCommandProfile();
|
||||
export const loadTurnCommandSpecs = async (scenarioConst?: unknown) => {
|
||||
const resolution = await loadScenarioTurnCommandProfile({ scenarioConst });
|
||||
const profile = resolution.profile;
|
||||
const [general, nation] = await Promise.all([
|
||||
loadGeneralTurnCommandSpecs(profile.general),
|
||||
loadNationTurnCommandSpecs(profile.nation),
|
||||
]);
|
||||
return { general, nation };
|
||||
return {
|
||||
general,
|
||||
nation,
|
||||
generalGroups: resolution.generalGroups,
|
||||
nationGroups: resolution.nationGroups,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseReservedTurnArgs = async (
|
||||
export const parseRegisteredTurnArgs = async (
|
||||
scope: 'general' | 'nation',
|
||||
action: string,
|
||||
rawArgs: unknown
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const specs = await loadTurnCommandSpecs();
|
||||
const spec = specs[scope].find((entry) => entry.key === action);
|
||||
const specs =
|
||||
scope === 'general'
|
||||
? isGeneralTurnCommandKey(action)
|
||||
? await loadGeneralTurnCommandSpecs([action])
|
||||
: []
|
||||
: isNationTurnCommandKey(action)
|
||||
? await loadNationTurnCommandSpecs([action])
|
||||
: [];
|
||||
const spec = specs[0];
|
||||
if (!spec) {
|
||||
throw new Error(`Unknown ${scope} turn command: ${action}`);
|
||||
}
|
||||
@@ -369,3 +384,24 @@ export const parseReservedTurnArgs = async (
|
||||
}
|
||||
return spec.argsSchema.parse(rawArgs);
|
||||
};
|
||||
|
||||
export const assertReservedTurnActionAvailable = async (
|
||||
scope: 'general' | 'nation',
|
||||
action: string,
|
||||
scenarioConst?: unknown
|
||||
): Promise<void> => {
|
||||
const specs = await loadTurnCommandSpecs(scenarioConst);
|
||||
if (!specs[scope].some((entry) => entry.key === action)) {
|
||||
throw new Error(`Unknown ${scope} turn command: ${action}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const parseReservedTurnArgs = async (
|
||||
scope: 'general' | 'nation',
|
||||
action: string,
|
||||
rawArgs: unknown,
|
||||
scenarioConst?: unknown
|
||||
): Promise<Record<string, unknown>> => {
|
||||
await assertReservedTurnActionAvailable(scope, action, scenarioConst);
|
||||
return parseRegisteredTurnArgs(scope, action, rawArgs);
|
||||
};
|
||||
|
||||
@@ -142,15 +142,6 @@ const REF_GENERAL_COMMAND_GROUPS = [
|
||||
commands: ReadonlyArray<GeneralTurnCommandKey>;
|
||||
}>;
|
||||
|
||||
const REF_GENERAL_CATEGORY_ORDER = new Map<string, number>(
|
||||
REF_GENERAL_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const)
|
||||
);
|
||||
const REF_GENERAL_COMMAND_POSITION = new Map<string, { category: string; index: number }>(
|
||||
REF_GENERAL_COMMAND_GROUPS.flatMap(({ category, commands }) =>
|
||||
commands.map((command, index) => [command, { category, index }] as const)
|
||||
)
|
||||
);
|
||||
|
||||
const REF_NATION_COMMAND_GROUPS = [
|
||||
{
|
||||
category: '휴식',
|
||||
@@ -190,15 +181,6 @@ const REF_NATION_COMMAND_GROUPS = [
|
||||
commands: ReadonlyArray<NationTurnCommandKey>;
|
||||
}>;
|
||||
|
||||
const REF_NATION_CATEGORY_ORDER = new Map<string, number>(
|
||||
REF_NATION_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const)
|
||||
);
|
||||
const REF_NATION_COMMAND_POSITION = new Map<string, { category: string; index: number }>(
|
||||
REF_NATION_COMMAND_GROUPS.flatMap(({ category, commands }) =>
|
||||
commands.map((command, index) => [command, { category, index }] as const)
|
||||
)
|
||||
);
|
||||
|
||||
const INPUT_REQUIREMENT_KINDS = new Set<RequirementKey['kind']>([
|
||||
'destGeneral',
|
||||
'destCity',
|
||||
@@ -746,34 +728,22 @@ const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: Stat
|
||||
}));
|
||||
};
|
||||
|
||||
const projectRefGeneralCommandGroups = (entries: CommandEntry[]): CommandEntry[] =>
|
||||
entries
|
||||
.map((entry, profileIndex) => {
|
||||
const refPosition = REF_GENERAL_COMMAND_POSITION.get(entry.definition.key as GeneralTurnCommandKey);
|
||||
return {
|
||||
entry: refPosition ? { ...entry, category: refPosition.category } : entry,
|
||||
categoryIndex:
|
||||
REF_GENERAL_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER,
|
||||
commandIndex: refPosition?.index ?? profileIndex,
|
||||
profileIndex,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.categoryIndex - right.categoryIndex ||
|
||||
left.commandIndex - right.commandIndex ||
|
||||
left.profileIndex - right.profileIndex
|
||||
)
|
||||
.map(({ entry }) => entry);
|
||||
type CommandGroupLayout = ReadonlyArray<{ category: string; commands: ReadonlyArray<string> }>;
|
||||
|
||||
const projectRefNationCommandGroups = (entries: CommandEntry[]): CommandEntry[] =>
|
||||
entries
|
||||
const projectCommandGroups = (entries: CommandEntry[], layout: CommandGroupLayout): CommandEntry[] => {
|
||||
const categoryOrder = new Map<string, number>(layout.map(({ category }, index) => [category, index] as const));
|
||||
const commandPosition = new Map<string, { category: string; index: number }>(
|
||||
layout.flatMap(({ category, commands }) =>
|
||||
commands.map((command, index) => [command, { category, index }] as const)
|
||||
)
|
||||
);
|
||||
|
||||
return entries
|
||||
.map((entry, profileIndex) => {
|
||||
const refPosition = REF_NATION_COMMAND_POSITION.get(entry.definition.key as NationTurnCommandKey);
|
||||
const refPosition = commandPosition.get(entry.definition.key);
|
||||
return {
|
||||
entry: refPosition ? { ...entry, category: refPosition.category } : entry,
|
||||
categoryIndex:
|
||||
REF_NATION_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER,
|
||||
categoryIndex: categoryOrder.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER,
|
||||
commandIndex: refPosition?.index ?? profileIndex,
|
||||
profileIndex,
|
||||
};
|
||||
@@ -785,6 +755,7 @@ const projectRefNationCommandGroups = (entries: CommandEntry[]): CommandEntry[]
|
||||
left.profileIndex - right.profileIndex
|
||||
)
|
||||
.map(({ entry }) => entry);
|
||||
};
|
||||
|
||||
export const buildTurnCommandTable = async (options: {
|
||||
worldState: WorldStateRow;
|
||||
@@ -814,7 +785,13 @@ export const buildTurnCommandTable = async (options: {
|
||||
};
|
||||
|
||||
const env = buildCommandEnv(options.worldState);
|
||||
const { general: generalSpecs, nation: nationSpecs } = await loadTurnCommandSpecs();
|
||||
const scenarioConst = asRecord(options.worldState.config).const;
|
||||
const {
|
||||
general: generalSpecs,
|
||||
nation: nationSpecs,
|
||||
generalGroups,
|
||||
nationGroups,
|
||||
} = await loadTurnCommandSpecs(scenarioConst);
|
||||
const generalEntries = buildEntries(env, generalSpecs, {
|
||||
foundingAvailable:
|
||||
options.realNationCount === undefined
|
||||
@@ -824,8 +801,12 @@ export const buildTurnCommandTable = async (options: {
|
||||
const nationEntries = buildEntries(env, nationSpecs);
|
||||
|
||||
return {
|
||||
general: buildGroups(projectRefGeneralCommandGroups(generalEntries), ctx, view),
|
||||
nation: buildGroups(projectRefNationCommandGroups(nationEntries), ctx, view),
|
||||
general: buildGroups(
|
||||
projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS),
|
||||
ctx,
|
||||
view
|
||||
),
|
||||
nation: buildGroups(projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), ctx, view),
|
||||
inputOptions: options.inputOptions ?? {
|
||||
cities: [],
|
||||
nations: [],
|
||||
@@ -850,7 +831,8 @@ export const evaluateReservedTurnPermission = async (options: {
|
||||
action: string;
|
||||
args: Record<string, unknown>;
|
||||
}): Promise<ConstraintResult> => {
|
||||
const specs = await loadTurnCommandSpecs();
|
||||
const scenarioConst = asRecord(options.worldState.config).const;
|
||||
const specs = await loadTurnCommandSpecs(scenarioConst);
|
||||
const spec = specs[options.scope].find((entry) => entry.key === options.action);
|
||||
if (!spec) {
|
||||
throw new Error(`Unknown ${options.scope} turn command: ${options.action}`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
loadGeneralTurnCommandSpecs,
|
||||
loadNationTurnCommandSpecs,
|
||||
} from '@sammo-ts/logic';
|
||||
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -98,6 +99,52 @@ describe('turn command argument input', () => {
|
||||
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
|
||||
});
|
||||
|
||||
it('accepts and rejects reserved commands from the real 904/905/910/912 world config', async () => {
|
||||
const scenarioConsts = Object.fromEntries(
|
||||
await Promise.all(
|
||||
[904, 905, 910, 912].map(async (scenarioId) => [
|
||||
scenarioId,
|
||||
(await loadScenarioDefinitionById(scenarioId)).config.const,
|
||||
])
|
||||
)
|
||||
) as Record<number, Record<string, unknown>>;
|
||||
|
||||
await expect(parseReservedTurnArgs('general', 'che_거병', {}, scenarioConsts[904])).rejects.toThrow(
|
||||
'Unknown general turn command: che_거병'
|
||||
);
|
||||
await expect(
|
||||
parseReservedTurnArgs('nation', 'che_선전포고', { destNationId: 2 }, scenarioConsts[904])
|
||||
).rejects.toThrow('Unknown nation turn command: che_선전포고');
|
||||
|
||||
await expect(
|
||||
parseReservedTurnArgs(
|
||||
'general',
|
||||
'che_무작위건국',
|
||||
{ nationName: '신국', nationType: 'che_도적', colorType: 1 },
|
||||
scenarioConsts[905]
|
||||
)
|
||||
).resolves.toEqual({ nationName: '신국', nationType: 'che_도적', colorType: 1 });
|
||||
await expect(parseReservedTurnArgs('nation', 'che_무작위수도이전', {}, scenarioConsts[905])).resolves.toEqual(
|
||||
{}
|
||||
);
|
||||
await expect(parseReservedTurnArgs('general', 'cr_맹훈련', {}, scenarioConsts[905])).rejects.toThrow(
|
||||
'Unknown general turn command: cr_맹훈련'
|
||||
);
|
||||
|
||||
await expect(parseReservedTurnArgs('general', 'cr_맹훈련', {}, scenarioConsts[910])).resolves.toEqual({});
|
||||
await expect(
|
||||
parseReservedTurnArgs('nation', 'cr_인구이동', { destCityId: 7, amount: 1234 }, scenarioConsts[910])
|
||||
).resolves.toEqual({ destCityId: 7, amount: 1234 });
|
||||
await expect(parseReservedTurnArgs('nation', 'che_무작위수도이전', {}, scenarioConsts[910])).rejects.toThrow(
|
||||
'Unknown nation turn command: che_무작위수도이전'
|
||||
);
|
||||
|
||||
await expect(parseReservedTurnArgs('nation', 'event_대검병연구', {}, scenarioConsts[912])).resolves.toEqual({});
|
||||
await expect(parseReservedTurnArgs('nation', 'cr_인구이동', {}, scenarioConsts[912])).rejects.toThrow(
|
||||
'Unknown nation turn command: cr_인구이동'
|
||||
);
|
||||
});
|
||||
|
||||
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
|
||||
const items = buildEquipmentTradeItemOptions({
|
||||
configConst: {},
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../src/context.js';
|
||||
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
|
||||
import type { GeneralActionModule, MapDefinition, UnitSetDefinition } from '@sammo-ts/logic';
|
||||
import { buildRecruitmentCommandInfo, buildTurnCommandTable } from '../src/turns/commandTable.js';
|
||||
|
||||
const buildWorldState = (joinMode = 'full'): WorldStateRow =>
|
||||
const buildWorldState = (joinMode = 'full', constOverrides: Record<string, unknown> = {}): WorldStateRow =>
|
||||
({
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
@@ -17,6 +18,7 @@ const buildWorldState = (joinMode = 'full'): WorldStateRow =>
|
||||
baseGold: 1000,
|
||||
baseRice: 1000,
|
||||
develCost: 100,
|
||||
...constOverrides,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
@@ -190,6 +192,89 @@ describe('buildTurnCommandTable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('projects scenario-specific command categories instead of the default profile', async () => {
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState('full', {
|
||||
availableGeneralCommand: {
|
||||
개인: ['휴식'],
|
||||
내정: ['che_물자조달'],
|
||||
군사: ['cr_맹훈련'],
|
||||
},
|
||||
availableChiefCommand: {
|
||||
휴식: ['휴식'],
|
||||
특수: ['cr_인구이동'],
|
||||
연구: ['event_대검병연구', 'event_화륜차연구'],
|
||||
},
|
||||
}),
|
||||
general: buildGeneral(),
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
nationGenerals: null,
|
||||
});
|
||||
|
||||
expect(table.general.map(({ category }) => category)).toEqual(['개인', '내정', '군사']);
|
||||
expect(table.general.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([
|
||||
'휴식',
|
||||
'che_물자조달',
|
||||
'cr_맹훈련',
|
||||
]);
|
||||
expect(table.nation.map(({ category }) => category)).toEqual(['휴식', '특수', '연구']);
|
||||
expect(table.nation.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([
|
||||
'휴식',
|
||||
'cr_인구이동',
|
||||
'event_대검병연구',
|
||||
'event_화륜차연구',
|
||||
]);
|
||||
});
|
||||
|
||||
it('projects the real 904/905/910/912 world command profiles into the API table', async () => {
|
||||
const buildScenarioTable = async (scenarioId: number) => {
|
||||
const scenario = await loadScenarioDefinitionById(scenarioId);
|
||||
return buildTurnCommandTable({
|
||||
worldState: buildWorldState('full', scenario.config.const),
|
||||
general: buildGeneral(),
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
nationGenerals: null,
|
||||
});
|
||||
};
|
||||
const commandCategory = (groups: Awaited<ReturnType<typeof buildScenarioTable>>['general'], key: string) =>
|
||||
groups.find((group) => group.values.some((command) => command.key === key))?.category;
|
||||
const nationCommandCategory = (groups: Awaited<ReturnType<typeof buildScenarioTable>>['nation'], key: string) =>
|
||||
groups.find((group) => group.values.some((command) => command.key === key))?.category;
|
||||
const [scenario904, scenario905, scenario910, scenario912] = await Promise.all(
|
||||
[904, 905, 910, 912].map(buildScenarioTable)
|
||||
);
|
||||
|
||||
expect(commandCategory(scenario904.general, 'che_물자조달')).toBe('내정');
|
||||
expect(commandCategory(scenario904.general, 'che_거병')).toBeUndefined();
|
||||
expect(nationCommandCategory(scenario904.nation, 'che_피장파장')).toBe('기타');
|
||||
expect(nationCommandCategory(scenario904.nation, 'che_선전포고')).toBeUndefined();
|
||||
expect(nationCommandCategory(scenario904.nation, 'che_부대탈퇴지시')).toBeUndefined();
|
||||
|
||||
expect(commandCategory(scenario905.general, 'che_무작위건국')).toBe('국가');
|
||||
expect(nationCommandCategory(scenario905.nation, 'che_무작위수도이전')).toBe('기타');
|
||||
|
||||
expect(commandCategory(scenario910.general, 'cr_맹훈련')).toBe('군사');
|
||||
expect(commandCategory(scenario910.general, 'cr_건국')).toBe('국가');
|
||||
expect(nationCommandCategory(scenario910.nation, 'cr_인구이동')).toBe('특수');
|
||||
|
||||
expect(nationCommandCategory(scenario912.nation, 'event_대검병연구')).toBe('연구');
|
||||
expect(
|
||||
scenario912.nation.find((group) => group.category === '연구')?.values.map((command) => command.key)
|
||||
).toEqual([
|
||||
'event_대검병연구',
|
||||
'event_극병연구',
|
||||
'event_화시병연구',
|
||||
'event_원융노병연구',
|
||||
'event_산저병연구',
|
||||
'event_음귀병연구',
|
||||
'event_무희연구',
|
||||
'event_상병연구',
|
||||
'event_화륜차연구',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps every default general and chief argument command inside the shared frontend field contract', async () => {
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
|
||||
@@ -49,7 +49,7 @@ import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLea
|
||||
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
|
||||
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
import { buildPersistedRankRows } from './rankData.js';
|
||||
import { buildInitialRankRows, buildPersistedRankRows } from './rankData.js';
|
||||
import { persistUnificationFinalization } from './unificationPersistence.js';
|
||||
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
||||
import { persistYearbookSnapshot } from './yearbookPersistence.js';
|
||||
@@ -769,17 +769,6 @@ const buildPersistedGeneralMeta = (
|
||||
return asJson(meta);
|
||||
};
|
||||
|
||||
const buildInitialRankRows = (
|
||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||
): Array<{ generalId: number; nationId: number; type: string; value: number }> =>
|
||||
buildPersistedRankRows(general).map((row) => ({
|
||||
...row,
|
||||
nationId: 0,
|
||||
// Ref Join은 전체 rank_data를 0으로 만든 직후 장수 생성에 사용한
|
||||
// 유산 포인트만 inherit_spent_dyn에 반영한다.
|
||||
value: row.type === 'inherit_spent_dyn' ? row.value : 0,
|
||||
}));
|
||||
|
||||
const RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
|
||||
|
||||
const upsertRankRows = async (
|
||||
|
||||
@@ -1933,9 +1933,17 @@ export class InMemoryTurnWorld {
|
||||
continue;
|
||||
}
|
||||
delete conflict[key];
|
||||
// Ref decodes a non-empty JSON object into a PHP array. Removing
|
||||
// its last nation key and encoding that value persists `[]`, not
|
||||
// `{}`. Preserve that observable storage shape until the next
|
||||
// world load (where an empty conflict is normalized for logic).
|
||||
const persistedConflict =
|
||||
Object.keys(conflict).length === 0
|
||||
? ([] as unknown as City['conflict'])
|
||||
: (conflict as City['conflict']);
|
||||
this.cities.set(city.id, {
|
||||
...city,
|
||||
conflict: conflict as City['conflict'],
|
||||
conflict: persistedConflict,
|
||||
});
|
||||
this.dirtyCityIds.add(city.id);
|
||||
}
|
||||
|
||||
@@ -64,12 +64,34 @@ export const buildPersistedRankRows = (general: RankedGeneralState): PersistedRa
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Ref GeneralBuilder/Join initializes every rank row in nation 0 with value 0.
|
||||
* The one exception is a user-creation inheritance debit already carried in
|
||||
* `inherit_spent_dyn`. Keep this persistence boundary shared by the database
|
||||
* hooks and differential projection.
|
||||
*/
|
||||
export const buildInitialRankRows = (general: RankedGeneralState): PersistedRankRow[] =>
|
||||
buildPersistedRankRows(general).map((row) => ({
|
||||
...row,
|
||||
nationId: 0,
|
||||
value: row.type === 'inherit_spent_dyn' ? row.value : 0,
|
||||
}));
|
||||
|
||||
export const buildLegacyComparableInitialRankRows = (
|
||||
general: RankedGeneralState
|
||||
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
|
||||
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
|
||||
return buildInitialRankRows(general).filter((row): row is PersistedRankRow & { type: LegacyRankDataType } =>
|
||||
legacyTypes.has(row.type)
|
||||
);
|
||||
};
|
||||
|
||||
export const buildLegacyComparableRankRows = (
|
||||
general: RankedGeneralState
|
||||
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
|
||||
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
|
||||
return buildPersistedRankRows(general).filter(
|
||||
(row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type)
|
||||
return buildPersistedRankRows(general).filter((row): row is PersistedRankRow & { type: LegacyRankDataType } =>
|
||||
legacyTypes.has(row.type)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getNextTurnAt,
|
||||
getBillByLevel,
|
||||
LEGACY_DEFAULT_MAX_LEVEL,
|
||||
orderLegacyActionLoggerFlush,
|
||||
type ItemModule,
|
||||
type UniqueLotteryRunner,
|
||||
} from '@sammo-ts/logic';
|
||||
@@ -121,6 +122,42 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
|
||||
'che_전투태세',
|
||||
]);
|
||||
|
||||
// 아래 Ref 커맨드는 성공 로그보다 addExperience/addDedication을 먼저 호출한다.
|
||||
// 이 차이는 같은 ActionLogger의 GENERAL/ACTION 버퍼 안에서 보이며, 등용수락은
|
||||
// 메시지 즉시 실행과 예약 턴 차등 fixture 모두 같은 순서를 사용한다.
|
||||
const LEGACY_PROGRESSION_BEFORE_ACTION_LOGS = new Set([
|
||||
'che_등용수락',
|
||||
'che_감축',
|
||||
'che_국기변경',
|
||||
'che_국호변경',
|
||||
'che_무작위수도이전',
|
||||
'che_증축',
|
||||
'che_천도',
|
||||
'che_초토화',
|
||||
'cr_인구이동',
|
||||
'event_극병연구',
|
||||
'event_대검병연구',
|
||||
'event_무희연구',
|
||||
'event_산저병연구',
|
||||
'event_상병연구',
|
||||
'event_원융노병연구',
|
||||
'event_음귀병연구',
|
||||
'event_화륜차연구',
|
||||
'event_화시병연구',
|
||||
]);
|
||||
|
||||
const orderLegacyCommandLogs = (
|
||||
actionKey: string,
|
||||
actionLogs: readonly LogEntryDraft[],
|
||||
progressionLogs: readonly LogEntryDraft[],
|
||||
postProgressionLogs: readonly LogEntryDraft[]
|
||||
): LogEntryDraft[] =>
|
||||
orderLegacyActionLoggerFlush(
|
||||
LEGACY_PROGRESSION_BEFORE_ACTION_LOGS.has(actionKey)
|
||||
? [...progressionLogs, ...actionLogs, ...postProgressionLogs]
|
||||
: [...actionLogs, ...progressionLogs, ...postProgressionLogs]
|
||||
);
|
||||
|
||||
export const applyLegacyGeneralProgression = (
|
||||
general: TurnGeneral,
|
||||
previousGeneral: TurnGeneral,
|
||||
@@ -152,36 +189,49 @@ export const applyLegacyGeneralProgression = (
|
||||
actionKey === 'che_선양' ||
|
||||
actionKey === 'che_출병' ||
|
||||
actionKey === 'che_물자조달';
|
||||
if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) {
|
||||
const preservesResolvedProcurementLevel = actionKey === 'che_물자조달';
|
||||
if (
|
||||
preservesResolvedProcurementLevel ||
|
||||
(!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience))
|
||||
) {
|
||||
const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0);
|
||||
const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel);
|
||||
meta.explevel = expLevel;
|
||||
if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) {
|
||||
const josaRo = JosaUtil.pick(String(expLevel), '로');
|
||||
const nextExpLevel = preservesResolvedProcurementLevel ? actionResolvedExpLevel : expLevel;
|
||||
meta.explevel = nextExpLevel;
|
||||
if (
|
||||
nextExpLevel !== previousExpLevel &&
|
||||
(preservesResolvedProcurementLevel || actionResolvedExpLevel !== nextExpLevel)
|
||||
) {
|
||||
const josaRo = JosaUtil.pick(String(nextExpLevel), '로');
|
||||
logs.push(
|
||||
createGeneralActionLog(
|
||||
general.id,
|
||||
expLevel > previousExpLevel
|
||||
? `<C>Lv ${expLevel}</>${josaRo} <C>레벨업</>!`
|
||||
: `<C>Lv ${expLevel}</>${josaRo} <R>레벨다운</>!`,
|
||||
nextExpLevel > previousExpLevel
|
||||
? `<C>Lv ${nextExpLevel}</>${josaRo} <C>레벨업</>!`
|
||||
: `<C>Lv ${nextExpLevel}</>${josaRo} <R>레벨다운</>!`,
|
||||
{ format: LogFormat.PLAIN }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) {
|
||||
if (
|
||||
preservesResolvedProcurementLevel ||
|
||||
(!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication))
|
||||
) {
|
||||
const previousDedicationLevel = readMetaNumber(previousGeneral.meta, 'dedlevel', 0);
|
||||
meta.dedlevel = dedicationLevel;
|
||||
if (dedicationLevel !== previousDedicationLevel) {
|
||||
const actionResolvedDedicationLevel = readMetaNumber(general.meta, 'dedlevel', previousDedicationLevel);
|
||||
const nextDedicationLevel = preservesResolvedProcurementLevel ? actionResolvedDedicationLevel : dedicationLevel;
|
||||
meta.dedlevel = nextDedicationLevel;
|
||||
if (nextDedicationLevel !== previousDedicationLevel) {
|
||||
const dedicationLevelText =
|
||||
dedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - dedicationLevel + 1}품관`;
|
||||
const billText = getBillByLevel(dedicationLevel).toLocaleString('en-US');
|
||||
nextDedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - nextDedicationLevel + 1}품관`;
|
||||
const billText = getBillByLevel(nextDedicationLevel).toLocaleString('en-US');
|
||||
const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로');
|
||||
const josaRoBill = JosaUtil.pick(billText, '로');
|
||||
logs.push(
|
||||
createGeneralActionLog(
|
||||
general.id,
|
||||
dedicationLevel > previousDedicationLevel
|
||||
nextDedicationLevel > previousDedicationLevel
|
||||
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
|
||||
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
|
||||
{ format: LogFormat.PLAIN }
|
||||
@@ -778,8 +828,14 @@ const createGeneralActionLog = (
|
||||
const resolveDefinition = (
|
||||
actionKey: string,
|
||||
definitions: Map<string, GeneralActionDefinition>,
|
||||
fallback: GeneralActionDefinition
|
||||
): GeneralActionDefinition => definitions.get(actionKey) ?? fallback;
|
||||
kind: 'general' | 'nation'
|
||||
): GeneralActionDefinition => {
|
||||
const definition = definitions.get(actionKey);
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown reserved ${kind} turn command: ${actionKey}`);
|
||||
}
|
||||
return definition;
|
||||
};
|
||||
|
||||
export const createReservedTurnHandler = async (options: {
|
||||
reservedTurns: InMemoryReservedTurnStore;
|
||||
@@ -790,6 +846,8 @@ export const createReservedTurnHandler = async (options: {
|
||||
getWorld: () => InMemoryTurnWorld | null;
|
||||
commandProfile?: TurnCommandProfile;
|
||||
commandEnv?: TurnCommandEnv;
|
||||
now?: () => Date;
|
||||
messageSharedIconBaseUrl?: string;
|
||||
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
|
||||
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
|
||||
calculateNpcNationFinance?: (
|
||||
@@ -957,6 +1015,9 @@ export const createReservedTurnHandler = async (options: {
|
||||
let currentGeneral = context.general;
|
||||
let currentCity = context.city;
|
||||
let currentNation = context.nation ?? null;
|
||||
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
|
||||
// 같은 장수 lifecycle의 국호변경은 뒤이은 유니크 획득 로그의 국호를 바꾸지 않는다.
|
||||
const legacyStaticNationName = currentNation?.name ?? '재야';
|
||||
|
||||
const runAction = (
|
||||
kind: 'nation' | 'general',
|
||||
@@ -973,7 +1034,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
completed: boolean;
|
||||
blockedReason?: string;
|
||||
} => {
|
||||
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
|
||||
const resolvedDefinition = resolveDefinition(command.action, definitionMap, kind);
|
||||
const rawArgs = extractArgsRecord(command.args);
|
||||
const parsedArgs = resolvedDefinition.parseArgs(rawArgs);
|
||||
let definition = resolvedDefinition;
|
||||
@@ -1098,12 +1159,15 @@ export const createReservedTurnHandler = async (options: {
|
||||
time: actionTime,
|
||||
maxTechLevel: env.maxTechLevel,
|
||||
uniqueLottery,
|
||||
legacyStaticNationName,
|
||||
};
|
||||
let specificContext = buildActionContext(
|
||||
actionKey,
|
||||
baseContext,
|
||||
{
|
||||
world: context.world,
|
||||
gameNow: options.now?.() ?? currentGeneral.turnTime,
|
||||
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
|
||||
scenarioConfig: options.scenarioConfig,
|
||||
scenarioMeta: options.scenarioMeta,
|
||||
map: options.map,
|
||||
@@ -1280,15 +1344,24 @@ export const createReservedTurnHandler = async (options: {
|
||||
};
|
||||
}
|
||||
}
|
||||
const progressionLogs: LogEntryDraft[] = [];
|
||||
if (!resolution.alternative && !usedFallback && resolution.completed) {
|
||||
currentGeneral = applyLegacyGeneralProgression(
|
||||
currentGeneral,
|
||||
generalBeforeExecution,
|
||||
actionKey,
|
||||
env,
|
||||
logs
|
||||
progressionLogs
|
||||
);
|
||||
}
|
||||
logs.push(
|
||||
...orderLegacyCommandLogs(
|
||||
actionKey,
|
||||
resolution.logs,
|
||||
progressionLogs,
|
||||
resolution.postProgressionLogs
|
||||
)
|
||||
);
|
||||
if (
|
||||
!resolution.alternative &&
|
||||
kind === 'nation' &&
|
||||
@@ -1418,7 +1491,6 @@ export const createReservedTurnHandler = async (options: {
|
||||
};
|
||||
}
|
||||
|
||||
logs.push(...resolution.logs);
|
||||
for (const nationId of resolution.destroyedNationIds ?? []) {
|
||||
destroyedNationIds.add(nationId);
|
||||
}
|
||||
@@ -1535,10 +1607,9 @@ export const createReservedTurnHandler = async (options: {
|
||||
if (resolution.created?.generals) {
|
||||
const newGenerals = resolution.created.generals as TurnGeneral[];
|
||||
createdGenerals.push(...newGenerals);
|
||||
if (worldOverlay) {
|
||||
for (const general of newGenerals) {
|
||||
worldOverlay.syncGeneral(general);
|
||||
}
|
||||
for (const general of newGenerals) {
|
||||
worldOverlay?.syncGeneral(general);
|
||||
options.reservedTurns.ensureGeneralTurns(general.id);
|
||||
}
|
||||
}
|
||||
if (resolution.created?.nations) {
|
||||
@@ -1990,7 +2061,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
src: messageTarget,
|
||||
dest: messageTarget,
|
||||
text: npcMessage,
|
||||
time: new Date(context.world.lastTurnTime),
|
||||
time: options.now?.() ?? new Date(context.world.lastTurnTime),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: {},
|
||||
});
|
||||
@@ -2438,6 +2509,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
},
|
||||
maxTechLevel: env.maxTechLevel,
|
||||
uniqueLottery,
|
||||
legacyStaticNationName: nation?.name ?? '재야',
|
||||
};
|
||||
const actionContext =
|
||||
buildActionContext(
|
||||
@@ -2476,7 +2548,10 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
);
|
||||
|
||||
if (input.actionKey === 'che_접경귀환' && (resolution.general as TurnGeneral).cityId === general.cityId) {
|
||||
for (const log of resolution.logs) {
|
||||
for (const log of orderLegacyActionLoggerFlush([
|
||||
...resolution.logs,
|
||||
...resolution.postProgressionLogs,
|
||||
])) {
|
||||
options.world.pushLog(log, general.turnTime);
|
||||
}
|
||||
return { ok: false, reason: '가까운 아국 도시가 없습니다.' };
|
||||
@@ -2514,7 +2589,11 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (input.actionKey === 'che_거병') {
|
||||
// Ref's immediate uprising and recruitment-accept commands both
|
||||
// finish their actor addExperience/addDedication calls before the
|
||||
// actor logger is applied. Keep the same level/rank state and logs
|
||||
// outside the ordinary reserved-turn lifecycle.
|
||||
if (input.actionKey === 'che_거병' || input.actionKey === 'che_등용수락') {
|
||||
nextGeneral = applyLegacyGeneralProgression(
|
||||
{
|
||||
...nextGeneral,
|
||||
@@ -2566,7 +2645,12 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
for (const troopId of resolution.deletedTroopIds ?? []) {
|
||||
options.world.removeTroop(troopId);
|
||||
}
|
||||
for (const log of [...resolution.logs, ...progressionLogs]) {
|
||||
for (const log of orderLegacyCommandLogs(
|
||||
input.actionKey,
|
||||
resolution.logs,
|
||||
progressionLogs,
|
||||
resolution.postProgressionLogs
|
||||
)) {
|
||||
options.world.pushLog(log, general.turnTime);
|
||||
}
|
||||
options.world.updateGeneral(input.generalId, nextGeneral);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic';
|
||||
import {
|
||||
parseTurnCommandProfile,
|
||||
resolveScenarioTurnCommandProfile,
|
||||
type ScenarioTurnCommandProfileResolution,
|
||||
type TurnCommandProfile,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { resolveWorkspaceRoot } from '../paths.js';
|
||||
|
||||
@@ -10,6 +15,7 @@ const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands
|
||||
|
||||
export interface TurnCommandProfileOptions {
|
||||
filePath?: string;
|
||||
scenarioConst?: unknown;
|
||||
}
|
||||
|
||||
const readCommandProfile = async (filePath: string): Promise<TurnCommandProfile> => {
|
||||
@@ -17,14 +23,13 @@ const readCommandProfile = async (filePath: string): Promise<TurnCommandProfile>
|
||||
return parseTurnCommandProfile(JSON.parse(raw) as unknown);
|
||||
};
|
||||
|
||||
export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise<TurnCommandProfile> => {
|
||||
export const loadScenarioTurnCommandProfile = async (
|
||||
options?: TurnCommandProfileOptions
|
||||
): Promise<ScenarioTurnCommandProfileResolution> => {
|
||||
const filePath = options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH;
|
||||
try {
|
||||
return await readCommandProfile(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return DEFAULT_TURN_COMMAND_PROFILE;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const fallback = await readCommandProfile(filePath);
|
||||
return resolveScenarioTurnCommandProfile(options?.scenarioConst, fallback);
|
||||
};
|
||||
|
||||
export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise<TurnCommandProfile> =>
|
||||
(await loadScenarioTurnCommandProfile(options)).profile;
|
||||
|
||||
@@ -663,11 +663,10 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
});
|
||||
const commandProfile =
|
||||
options.commandProfile ??
|
||||
(options.commandProfilePath
|
||||
? await loadTurnCommandProfile({
|
||||
filePath: options.commandProfilePath,
|
||||
})
|
||||
: await loadTurnCommandProfile());
|
||||
(await loadTurnCommandProfile({
|
||||
...(options.commandProfilePath ? { filePath: options.commandProfilePath } : {}),
|
||||
scenarioConst: snapshot.scenarioConfig.const,
|
||||
}));
|
||||
let worldRef: InMemoryTurnWorld | null = null;
|
||||
let redisConnector: RedisConnector | null = null;
|
||||
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
||||
@@ -730,6 +729,10 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
map: snapshot.map,
|
||||
unitSet: snapshot.unitSet,
|
||||
getWorld: () => worldRef,
|
||||
now: () => {
|
||||
const wallNow = new Date(clock.nowMs());
|
||||
return worldRef?.getGameNow(wallNow) ?? wallNow;
|
||||
},
|
||||
commandProfile,
|
||||
commandEnv: monthlyCommandEnv,
|
||||
calculateNpcNationFinance: (financeWorld, nation, currentMonth) =>
|
||||
|
||||
@@ -375,7 +375,7 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('builds all four Ref award logs with the original formats and labels', () => {
|
||||
it('builds all four Ref award logs in the original flush order with the original formats and labels', () => {
|
||||
const bidder = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
@@ -390,13 +390,6 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
itemRawName: '칠성검',
|
||||
})
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
generalId: 7,
|
||||
text: '<C>칠성검(+12)</>을 습득했습니다!',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
@@ -405,10 +398,11 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
text: '<C>칠성검(+12)</>을 습득',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
|
||||
generalId: 7,
|
||||
text: '<C>칠성검(+12)</>을 습득했습니다!',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.SYSTEM,
|
||||
@@ -416,6 +410,12 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
text: '<C><b>【보물수배】</b></><D><b>촉</b></>의 <Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('scenario general pool within one reserved turn', () => {
|
||||
state: buildState(),
|
||||
schedule,
|
||||
map,
|
||||
reservedTurnStoreOptions: { maxGeneralTurns: 10, maxNationTurns: 12 },
|
||||
reservedTurnStoreOptions: { maxGeneralTurns: 30, maxNationTurns: 12 },
|
||||
commandRngFactory: ({ actionKey }) =>
|
||||
actionKey === 'che_의병모집'
|
||||
? new RandUtil(new SequenceRNG([0, 0.26, 0.51, 0.76]))
|
||||
@@ -210,5 +210,29 @@ describe('scenario general pool within one reserved turn', () => {
|
||||
expect(created.map((general) => general.npcState).sort()).toEqual([3, 4, 4, 4]);
|
||||
expect(claims.every(Boolean)).toBe(true);
|
||||
expect(new Set(claims.map((claim) => claim?.poolEntryId))).toEqual(new Set([1, 2, 3, 4]));
|
||||
|
||||
const createdIds = created.map((general) => general.id);
|
||||
expect(harness.reservedTurnStore.peekDirtyState().generalInitializationIds).toEqual(createdIds);
|
||||
for (const generalId of createdIds) {
|
||||
expect(harness.reservedTurnStore.getGeneralTurns(generalId)).toEqual(
|
||||
Array.from({ length: 30 }, () => ({ action: '휴식', args: {} }))
|
||||
);
|
||||
}
|
||||
|
||||
await harness.reservedTurnStore.flushChanges();
|
||||
|
||||
const persistedRows = harness.mockPrisma.generalTurn.createMany.mock.calls.flatMap(([input]) => input.data);
|
||||
const persistedCreatedRows = persistedRows.filter((row) => createdIds.includes(row.generalId));
|
||||
expect(persistedCreatedRows).toHaveLength(createdIds.length * 30);
|
||||
for (const generalId of createdIds) {
|
||||
expect(persistedCreatedRows.filter((row) => row.generalId === generalId)).toEqual(
|
||||
Array.from({ length: 30 }, (_, turnIdx) => ({
|
||||
generalId,
|
||||
turnIdx,
|
||||
actionCode: '휴식',
|
||||
arg: {},
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,6 +193,34 @@ describe('legacy general-turn execution contract', () => {
|
||||
expect(resolved.meta).toMatchObject({ explevel: 25, dedlevel: 8 });
|
||||
});
|
||||
|
||||
it('preserves procurement-computed levels while emitting their Ref progression logs', () => {
|
||||
const previous = makeGeneral({
|
||||
experience: 995,
|
||||
dedication: 899,
|
||||
meta: { killturn: 24, explevel: 9, dedlevel: 3 },
|
||||
});
|
||||
const afterProcurement = makeGeneral({
|
||||
experience: 1_005,
|
||||
dedication: 901,
|
||||
meta: { killturn: 24, explevel: 10, dedlevel: 4 },
|
||||
});
|
||||
const logs: Array<{ text: string }> = [];
|
||||
|
||||
const resolved = applyLegacyGeneralProgression(
|
||||
afterProcurement,
|
||||
previous,
|
||||
'che_물자조달',
|
||||
{ maxStatLevel: 255, maxDedicationLevel: 30 } as never,
|
||||
logs as never
|
||||
);
|
||||
|
||||
expect(resolved.meta).toMatchObject({ explevel: 10, dedlevel: 4 });
|
||||
expect(logs.map((entry) => entry.text)).toEqual([
|
||||
'<C>Lv 10</>으로 <C>레벨업</>!',
|
||||
'<Y>27품관</>으로 <C>승급</>하여 봉록이 <C>1,200</>으로 <C>상승</>했습니다!',
|
||||
]);
|
||||
});
|
||||
|
||||
it('quantizes integer general columns at each in-memory DB mutation boundary', async () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(makeGeneral()),
|
||||
@@ -218,6 +246,23 @@ describe('legacy general-turn execution contract', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed instead of silently resting on an unknown queued command', async () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(makeGeneral()),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
});
|
||||
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'unknown-command', args: {} };
|
||||
const beforeGeneral = structuredClone(harness.world.getGeneralById(1));
|
||||
const beforeTurns = structuredClone(harness.reservedTurnStore.getGeneralTurns(1));
|
||||
|
||||
await expect(harness.runOneTick()).rejects.toThrow('Unknown reserved general turn command: unknown-command');
|
||||
expect(harness.world.getGeneralById(1)).toEqual(beforeGeneral);
|
||||
expect(harness.reservedTurnStore.getGeneralTurns(1)).toEqual(beforeTurns);
|
||||
expect(harness.world.peekDirtyState()).toMatchObject({ logs: [], messages: [] });
|
||||
});
|
||||
|
||||
it('keeps fractional nation rewards in the same general object until the following command is persisted', async () => {
|
||||
const twoCityMap = {
|
||||
...map,
|
||||
|
||||
@@ -200,6 +200,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
|
||||
return {
|
||||
world,
|
||||
worldRef,
|
||||
mockPrisma,
|
||||
reservedTurnStore,
|
||||
handler,
|
||||
processor,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import type { MapDefinition, ScenarioEffectKey, TurnSchedule } from '@sammo-ts/logic';
|
||||
import {
|
||||
LogCategory,
|
||||
LogFormat,
|
||||
LogScope,
|
||||
type MapDefinition,
|
||||
type ScenarioEffectKey,
|
||||
type TurnSchedule,
|
||||
} from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
@@ -562,6 +569,25 @@ describe('my information world commands', () => {
|
||||
experience: recruiter.experience + 100,
|
||||
dedication: recruiter.dedication + 100,
|
||||
});
|
||||
const actionLogs = fixture.world
|
||||
.consumeDirtyState()
|
||||
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
|
||||
expect(actionLogs.map((log) => log.text)).toEqual([
|
||||
expect.stringContaining('레벨업'),
|
||||
expect.stringContaining('승급'),
|
||||
expect.stringContaining('망명하여 수도로'),
|
||||
expect.stringContaining('레벨업'),
|
||||
expect.stringContaining('승급'),
|
||||
expect.stringContaining('등용에 성공했습니다.'),
|
||||
]);
|
||||
expect(actionLogs.map((log) => log.format)).toEqual([
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.MONTH,
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.MONTH,
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves the Ref uprising precheck order and messages after the game starts', async () => {
|
||||
|
||||
@@ -90,6 +90,8 @@ describe('도시 점령 시 국가 멸망 처리', () => {
|
||||
strongFrontCity.supplyState = 1;
|
||||
const conflictCity = cities.find((city) => city.id === 3)!;
|
||||
conflictCity.conflict = { 2: 100, 1: 50 };
|
||||
const emptiedConflictCity = cities.find((city) => city.id === 4)!;
|
||||
emptiedConflictCity.conflict = { 2: 25 };
|
||||
|
||||
const unitSet: UnitSetDefinition = {
|
||||
id: 'test_unit_set',
|
||||
@@ -291,6 +293,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
|
||||
expect(world.listEvents('destroy_nation')).toEqual([]);
|
||||
expect(world.getState().meta.block_change_scout).toBeUndefined();
|
||||
expect(world.getCityById(conflictCity.id)?.conflict).toEqual({ 1: 50 });
|
||||
expect(world.getCityById(emptiedConflictCity.id)?.conflict).toEqual([]);
|
||||
|
||||
const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
|
||||
expect(updatedWeakGeneral?.nationId).toBe(0);
|
||||
|
||||
@@ -251,6 +251,7 @@ describe('NPC 일반 내정 턴', () => {
|
||||
});
|
||||
await reservedTurnStore.loadAll();
|
||||
|
||||
const logicalGameNow = addMinutes(mockDate, 3);
|
||||
const wrapper = { world: null as InMemoryTurnWorld | null };
|
||||
const handler = await createReservedTurnHandler({
|
||||
reservedTurns: reservedTurnStore,
|
||||
@@ -259,6 +260,7 @@ describe('NPC 일반 내정 턴', () => {
|
||||
map: MINIMAL_MAP as any,
|
||||
unitSet: snapshot.unitSet,
|
||||
getWorld: () => wrapper.world,
|
||||
now: () => logicalGameNow,
|
||||
});
|
||||
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
@@ -296,6 +298,7 @@ describe('NPC 일반 내정 턴', () => {
|
||||
msgType: 'public',
|
||||
text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다',
|
||||
src: expect.objectContaining({ generalId: 1, generalName: 'NPC_무장', nationId: 1 }),
|
||||
time: logicalGameNow,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
|
||||
import {
|
||||
applyPersistedRankRowsToMeta,
|
||||
buildInitialRankRows,
|
||||
buildLegacyComparableInitialRankRows,
|
||||
buildLegacyComparableRankRows,
|
||||
buildPersistedRankRows,
|
||||
rankMetaKey,
|
||||
@@ -34,13 +36,39 @@ describe('rank data projection', () => {
|
||||
{ generalId: 7, nationId: 2, type: 'dex1', value: 12 },
|
||||
])
|
||||
);
|
||||
expect(buildLegacyComparableRankRows({
|
||||
id: 7,
|
||||
expect(
|
||||
buildLegacyComparableRankRows({
|
||||
id: 7,
|
||||
nationId: 2,
|
||||
experience: 10.5,
|
||||
dedication: 20.49,
|
||||
meta: {},
|
||||
})
|
||||
).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
|
||||
|
||||
const initialRows = buildInitialRankRows({
|
||||
id: 8,
|
||||
nationId: 2,
|
||||
experience: 10.5,
|
||||
dedication: 20.49,
|
||||
meta: {},
|
||||
})).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
|
||||
experience: 100,
|
||||
dedication: 200,
|
||||
meta: { rank_warnum: 9, inherit_spent_dyn: 7 },
|
||||
});
|
||||
expect(initialRows).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ generalId: 8, nationId: 0, type: 'experience', value: 0 },
|
||||
{ generalId: 8, nationId: 0, type: 'warnum', value: 0 },
|
||||
{ generalId: 8, nationId: 0, type: 'inherit_spent_dyn', value: 7 },
|
||||
])
|
||||
);
|
||||
expect(
|
||||
buildLegacyComparableInitialRankRows({
|
||||
id: 8,
|
||||
nationId: 2,
|
||||
experience: 100,
|
||||
dedication: 200,
|
||||
meta: {},
|
||||
})
|
||||
).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
|
||||
});
|
||||
|
||||
it('loads persisted rows into the same raw and prefixed meta keys used by commands', () => {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js';
|
||||
import { loadScenarioTurnCommandProfile } from '../src/turn/turnCommandProfile.js';
|
||||
|
||||
type CommandGroupManifest = ReadonlyArray<{
|
||||
category: string;
|
||||
commands: readonly string[];
|
||||
}>;
|
||||
|
||||
const defaultGeneralProfileManifest = [
|
||||
'che_거병',
|
||||
'che_임관',
|
||||
'che_장수대상임관',
|
||||
'che_랜덤임관',
|
||||
'che_귀환',
|
||||
'che_건국',
|
||||
'che_훈련',
|
||||
'che_단련',
|
||||
'che_숙련전환',
|
||||
'che_사기진작',
|
||||
'che_요양',
|
||||
'che_견문',
|
||||
'che_은퇴',
|
||||
'che_내정특기초기화',
|
||||
'che_전투특기초기화',
|
||||
'che_장비매매',
|
||||
'che_출병',
|
||||
'che_주민선정',
|
||||
'che_정착장려',
|
||||
'che_농지개간',
|
||||
'che_상업투자',
|
||||
'che_기술연구',
|
||||
'che_치안강화',
|
||||
'che_수비강화',
|
||||
'che_성벽보수',
|
||||
'che_선동',
|
||||
'che_탈취',
|
||||
'che_파괴',
|
||||
'che_화계',
|
||||
'che_집합',
|
||||
'che_인재탐색',
|
||||
'che_등용',
|
||||
'che_징병',
|
||||
'che_모병',
|
||||
'che_소집해제',
|
||||
'che_첩보',
|
||||
'che_군량매매',
|
||||
'che_물자조달',
|
||||
'che_증여',
|
||||
'che_헌납',
|
||||
'che_이동',
|
||||
'che_강행',
|
||||
'che_하야',
|
||||
'che_선양',
|
||||
'che_해산',
|
||||
'휴식',
|
||||
] as const;
|
||||
|
||||
const generalPersonalCommands = [
|
||||
'휴식',
|
||||
'che_요양',
|
||||
'che_단련',
|
||||
'che_숙련전환',
|
||||
'che_견문',
|
||||
'che_은퇴',
|
||||
'che_장비매매',
|
||||
'che_군량매매',
|
||||
'che_내정특기초기화',
|
||||
'che_전투특기초기화',
|
||||
] as const;
|
||||
const generalDomesticCommands = [
|
||||
'che_농지개간',
|
||||
'che_상업투자',
|
||||
'che_기술연구',
|
||||
'che_수비강화',
|
||||
'che_성벽보수',
|
||||
'che_치안강화',
|
||||
'che_정착장려',
|
||||
'che_주민선정',
|
||||
'che_물자조달',
|
||||
] as const;
|
||||
const generalMilitaryCommands = [
|
||||
'che_징병',
|
||||
'che_모병',
|
||||
'che_훈련',
|
||||
'che_사기진작',
|
||||
'che_출병',
|
||||
'che_집합',
|
||||
'che_소집해제',
|
||||
'che_첩보',
|
||||
] as const;
|
||||
const generalPersonnelCommands = ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_랜덤임관'] as const;
|
||||
const generalSchemeCommands = ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'] as const;
|
||||
const nationDiplomacyCommands = [
|
||||
'che_물자원조',
|
||||
'che_불가침제의',
|
||||
'che_선전포고',
|
||||
'che_종전제의',
|
||||
'che_불가침파기제의',
|
||||
] as const;
|
||||
|
||||
const scenarioProfileManifest: Record<
|
||||
number,
|
||||
{ generalGroups: CommandGroupManifest | null; nationGroups: CommandGroupManifest }
|
||||
> = {
|
||||
904: {
|
||||
generalGroups: [
|
||||
{ category: '개인', commands: generalPersonalCommands },
|
||||
{ category: '내정', commands: generalDomesticCommands },
|
||||
{ category: '군사', commands: generalMilitaryCommands },
|
||||
{ category: '인사', commands: generalPersonnelCommands },
|
||||
{ category: '계략', commands: generalSchemeCommands },
|
||||
{ category: '국가', commands: ['che_증여', 'che_헌납', 'che_하야'] },
|
||||
],
|
||||
nationGroups: [
|
||||
{ category: '휴식', commands: ['휴식'] },
|
||||
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] },
|
||||
{ category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] },
|
||||
{
|
||||
category: '전략',
|
||||
commands: [
|
||||
'che_필사즉생',
|
||||
'che_백성동원',
|
||||
'che_수몰',
|
||||
'che_허보',
|
||||
'che_의병모집',
|
||||
'che_이호경식',
|
||||
'che_급습',
|
||||
],
|
||||
},
|
||||
{ category: '기타', commands: ['che_피장파장', 'che_국기변경', 'che_국호변경'] },
|
||||
],
|
||||
},
|
||||
905: {
|
||||
generalGroups: [
|
||||
{ category: '개인', commands: generalPersonalCommands },
|
||||
{ category: '내정', commands: generalDomesticCommands },
|
||||
{ category: '군사', commands: generalMilitaryCommands },
|
||||
{ category: '인사', commands: generalPersonnelCommands },
|
||||
{ category: '계략', commands: generalSchemeCommands },
|
||||
{
|
||||
category: '국가',
|
||||
commands: ['che_증여', 'che_헌납', 'che_하야', 'che_거병', 'che_무작위건국', 'che_선양', 'che_해산'],
|
||||
},
|
||||
],
|
||||
nationGroups: [
|
||||
{ category: '휴식', commands: ['휴식'] },
|
||||
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] },
|
||||
{ category: '외교', commands: nationDiplomacyCommands },
|
||||
{ category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] },
|
||||
{
|
||||
category: '전략',
|
||||
commands: [
|
||||
'che_필사즉생',
|
||||
'che_백성동원',
|
||||
'che_수몰',
|
||||
'che_허보',
|
||||
'che_의병모집',
|
||||
'che_이호경식',
|
||||
'che_급습',
|
||||
'che_피장파장',
|
||||
],
|
||||
},
|
||||
{ category: '기타', commands: ['che_국기변경', 'che_국호변경', 'che_무작위수도이전'] },
|
||||
],
|
||||
},
|
||||
910: {
|
||||
generalGroups: [
|
||||
{ category: '개인', commands: generalPersonalCommands },
|
||||
{ category: '내정', commands: generalDomesticCommands },
|
||||
{
|
||||
category: '군사',
|
||||
commands: [
|
||||
'che_징병',
|
||||
'che_모병',
|
||||
'che_훈련',
|
||||
'che_사기진작',
|
||||
'cr_맹훈련',
|
||||
'che_출병',
|
||||
'che_집합',
|
||||
'che_소집해제',
|
||||
'che_첩보',
|
||||
],
|
||||
},
|
||||
{ category: '인사', commands: generalPersonnelCommands },
|
||||
{ category: '계략', commands: generalSchemeCommands },
|
||||
{
|
||||
category: '국가',
|
||||
commands: ['che_증여', 'che_헌납', 'che_하야', 'che_거병', 'cr_건국', 'che_선양', 'che_해산'],
|
||||
},
|
||||
],
|
||||
nationGroups: [
|
||||
{ category: '휴식', commands: ['휴식'] },
|
||||
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] },
|
||||
{ category: '외교', commands: nationDiplomacyCommands },
|
||||
{ category: '특수', commands: ['che_초토화', 'che_천도', 'cr_인구이동'] },
|
||||
{
|
||||
category: '전략',
|
||||
commands: [
|
||||
'che_필사즉생',
|
||||
'che_백성동원',
|
||||
'che_수몰',
|
||||
'che_허보',
|
||||
'che_의병모집',
|
||||
'che_이호경식',
|
||||
'che_급습',
|
||||
],
|
||||
},
|
||||
{ category: '기타', commands: ['che_피장파장', 'che_국기변경', 'che_국호변경'] },
|
||||
],
|
||||
},
|
||||
912: {
|
||||
generalGroups: null,
|
||||
nationGroups: [
|
||||
{ category: '휴식', commands: ['휴식'] },
|
||||
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] },
|
||||
{ category: '외교', commands: nationDiplomacyCommands },
|
||||
{ category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] },
|
||||
{
|
||||
category: '전략',
|
||||
commands: [
|
||||
'che_필사즉생',
|
||||
'che_백성동원',
|
||||
'che_수몰',
|
||||
'che_허보',
|
||||
'che_의병모집',
|
||||
'che_이호경식',
|
||||
'che_급습',
|
||||
'che_피장파장',
|
||||
],
|
||||
},
|
||||
{ category: '기타', commands: ['che_국기변경', 'che_국호변경'] },
|
||||
{
|
||||
category: '연구',
|
||||
commands: [
|
||||
'event_대검병연구',
|
||||
'event_극병연구',
|
||||
'event_화시병연구',
|
||||
'event_원융노병연구',
|
||||
'event_산저병연구',
|
||||
'event_음귀병연구',
|
||||
'event_무희연구',
|
||||
'event_상병연구',
|
||||
'event_화륜차연구',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const loadScenarioProfile = async (scenarioId: number) => {
|
||||
const scenario = await loadScenarioDefinitionById(scenarioId);
|
||||
return loadScenarioTurnCommandProfile({ scenarioConst: scenario.config.const });
|
||||
};
|
||||
|
||||
describe('scenario command profile resources', () => {
|
||||
it('fails closed when the configured base profile file is missing', async () => {
|
||||
await expect(
|
||||
loadScenarioTurnCommandProfile({ filePath: '/tmp/core2026-command-profile-does-not-exist.json' })
|
||||
).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it.each(Object.entries(scenarioProfileManifest))(
|
||||
'preserves scenario %s general/chief group order and the exact flattened product profile',
|
||||
async (scenarioId, manifest) => {
|
||||
const result = await loadScenarioProfile(Number(scenarioId));
|
||||
const expectedGeneral = manifest.generalGroups
|
||||
? manifest.generalGroups.flatMap((group) => group.commands)
|
||||
: defaultGeneralProfileManifest;
|
||||
const expectedNation = manifest.nationGroups.flatMap((group) => group.commands);
|
||||
|
||||
expect(result.generalGroups).toEqual(manifest.generalGroups);
|
||||
expect(result.nationGroups).toEqual(manifest.nationGroups);
|
||||
expect(result.profile.general).toEqual(expectedGeneral);
|
||||
expect(result.profile.nation).toEqual(expectedNation);
|
||||
expect(new Set(result.profile.general).size).toBe(result.profile.general.length);
|
||||
expect(new Set(result.profile.nation).size).toBe(result.profile.nation.length);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -39,7 +39,11 @@ const buildGeneral = (id: number): TurnGeneral => ({
|
||||
describe('unique lottery on general commands', () => {
|
||||
it('awards a unique item for eligible commands', async () => {
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const generals = [buildGeneral(1)];
|
||||
const lotteryGeneral = buildGeneral(1);
|
||||
lotteryGeneral.experience = 995;
|
||||
lotteryGeneral.dedication = 899;
|
||||
lotteryGeneral.meta = { killturn: 24, explevel: 9, dedlevel: 3 };
|
||||
const generals = [lotteryGeneral];
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: generals as any,
|
||||
cities: [
|
||||
@@ -172,6 +176,14 @@ describe('unique lottery on general commands', () => {
|
||||
expect(result.general?.role.items.weapon).toBe('che_무기_12_칠성검');
|
||||
const logTexts = (result.logs ?? []).map((entry) => entry.text);
|
||||
expect(logTexts.some((text) => text.includes('【아이템】'))).toBe(true);
|
||||
const actionIndex = logTexts.findIndex((text) => text.includes('훈련'));
|
||||
const levelIndex = logTexts.findIndex((text) => text.includes('레벨업'));
|
||||
const dedicationIndex = logTexts.findIndex((text) => text.includes('승급'));
|
||||
const uniqueIndex = logTexts.findIndex((text) => text.includes('습득했습니다'));
|
||||
expect([actionIndex, levelIndex, dedicationIndex, uniqueIndex].every((index) => index >= 0)).toBe(true);
|
||||
expect(actionIndex).toBeLessThan(levelIndex);
|
||||
expect(levelIndex).toBeLessThan(dedicationIndex);
|
||||
expect(dedicationIndex).toBeLessThan(uniqueIndex);
|
||||
});
|
||||
|
||||
it('does not award a unique item reserved by an active auction', async () => {
|
||||
|
||||
Reference in New Issue
Block a user