fix: 커맨드 차등 생명주기와 로그 그래프를 보강
장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다. 실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
@@ -116,5 +116,8 @@ describe('appointment global summary log format', () => {
|
||||
);
|
||||
|
||||
expectMonthlySummary(logs);
|
||||
expect(
|
||||
logs.find((entry) => entry.scope === LogScope.GENERAL && entry.category === LogCategory.HISTORY)?.format
|
||||
).toBe(LogFormat.YEAR_MONTH);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,18 +27,21 @@ describe('processGeneralActionWithFallback', () => {
|
||||
|
||||
const fallbackResolver: GeneralActionResolver = {
|
||||
key: 'fallbackCmd',
|
||||
resolve: () => ({
|
||||
effects: [
|
||||
{
|
||||
type: 'log',
|
||||
entry: { text: 'Primary failed', scope: 'general', category: 'action', format: 'month' },
|
||||
} as any,
|
||||
],
|
||||
alternative: {
|
||||
commandKey: 'alternativeCmd',
|
||||
args: { foo: 'bar' },
|
||||
},
|
||||
}),
|
||||
resolve: (context) => {
|
||||
context.addPostProgressionLog?.('Primary post-progression');
|
||||
return {
|
||||
effects: [
|
||||
{
|
||||
type: 'log',
|
||||
entry: { text: 'Primary failed', scope: 'general', category: 'action', format: 'month' },
|
||||
} as any,
|
||||
],
|
||||
alternative: {
|
||||
commandKey: 'alternativeCmd',
|
||||
args: { foo: 'bar' },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const alternativeResolver: GeneralActionResolver = {
|
||||
@@ -100,18 +103,8 @@ describe('processGeneralActionWithFallback', () => {
|
||||
mockLoader
|
||||
);
|
||||
|
||||
// It should eventually execute alternativeResolver
|
||||
// BUT resolveGeneralAction creates a FRESH resolution from the FINAL resolver.
|
||||
// It does NOT merge logs currently. (As per my implementation comment)
|
||||
// Wait, did I implement log merging? No.
|
||||
// I implemented a simple loop that re-runs `resolveGeneralAction`.
|
||||
// So the final resolution comes from `alternativeResolver`.
|
||||
|
||||
// Let's verify what we expect.
|
||||
// If we want legacy parity, we might expect logs from the first attempt too.
|
||||
// But for now, let's verify the loop works.
|
||||
|
||||
expect(resolution.logs).toHaveLength(2); // 'Primary failed' + 'Alternative executed...'
|
||||
expect(resolution.postProgressionLogs.map((entry) => entry.text)).toEqual(['Primary post-progression']);
|
||||
expect(resolution.alternative).toBeUndefined(); // The final one succeeded
|
||||
expect(mockLoader.load).toHaveBeenCalledWith('alternativeCmd');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { City, General, Nation } from '../../../src/domain/entities.js';
|
||||
import type { GeneralActionResolveContext } from '../../../src/actions/engine.js';
|
||||
import { ActionDefinition as TradeAction } from '../../../src/actions/turn/general/che_군량매매.js';
|
||||
import { ActionResolver as ResignAction } from '../../../src/actions/turn/general/che_하야.js';
|
||||
import { ActionResolver as RetireAction } from '../../../src/actions/turn/general/che_은퇴.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
import { finalizeLogEntry } from '../../../src/logging/entries.js';
|
||||
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js';
|
||||
|
||||
const makeGeneral = (overrides: Partial<General> = {}): General =>
|
||||
({
|
||||
id: 1,
|
||||
name: '검증장수',
|
||||
nationId: 2,
|
||||
cityId: 3,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
officerLevel: 1,
|
||||
experience: 100,
|
||||
dedication: 100,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 100,
|
||||
crewTypeId: 1,
|
||||
train: 100,
|
||||
atmos: 100,
|
||||
injury: 0,
|
||||
age: 60,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: {},
|
||||
...overrides,
|
||||
}) as General;
|
||||
|
||||
const nation = {
|
||||
id: 2,
|
||||
name: '검증국',
|
||||
color: '#ff0000',
|
||||
capitalCityId: 3,
|
||||
chiefGeneralId: 1,
|
||||
gold: 10_000,
|
||||
rice: 10_000,
|
||||
power: 0,
|
||||
level: 1,
|
||||
typeCode: 'che_def',
|
||||
meta: { gennum: 1 },
|
||||
} satisfies Nation;
|
||||
|
||||
const city = {
|
||||
id: 3,
|
||||
name: '검증도시',
|
||||
nationId: nation.id,
|
||||
level: 1,
|
||||
state: 0,
|
||||
population: 20_000,
|
||||
populationMax: 50_000,
|
||||
agriculture: 500,
|
||||
agricultureMax: 1_000,
|
||||
commerce: 500,
|
||||
commerceMax: 1_000,
|
||||
security: 500,
|
||||
securityMax: 1_000,
|
||||
defence: 300,
|
||||
defenceMax: 1_000,
|
||||
wall: 300,
|
||||
wallMax: 1_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
meta: { trade: 100 },
|
||||
} satisfies City;
|
||||
|
||||
const createLogSink =
|
||||
(logs: LogEntryDraft[]): GeneralActionResolveContext['addLog'] =>
|
||||
(text, options = {}) => {
|
||||
const entry: LogEntryDraft = {
|
||||
scope: options.scope ?? LogScope.GENERAL,
|
||||
category: options.category ?? LogCategory.ACTION,
|
||||
text,
|
||||
...options,
|
||||
};
|
||||
if (entry.scope === LogScope.GENERAL && entry.generalId === undefined) {
|
||||
entry.generalId = 1;
|
||||
}
|
||||
logs.push(entry);
|
||||
};
|
||||
|
||||
const expectMonthlyPersistence = (entry: LogEntryDraft, legacyWrongFormat: LogFormat): void => {
|
||||
expect(entry.format).toBe(LogFormat.MONTH);
|
||||
|
||||
const persisted = finalizeLogEntry(entry, { year: 186, month: 9 });
|
||||
const mutant = finalizeLogEntry({ ...entry, format: legacyWrongFormat }, { year: 186, month: 9 });
|
||||
|
||||
expect(persisted?.text).toMatch(/^<C>●<\/>9월:/u);
|
||||
expect(mutant?.text).not.toBe(persisted?.text);
|
||||
expect(mutant?.text).not.toMatch(/^<C>●<\/>9월:/u);
|
||||
};
|
||||
|
||||
describe('general command Ref log format parity', () => {
|
||||
it.each([
|
||||
{ buyRice: true, amount: 100 },
|
||||
{ buyRice: false, amount: 100 },
|
||||
])('keeps che_군량매매 action logs on the Ref monthly format for $buyRice', (args) => {
|
||||
const logs: LogEntryDraft[] = [];
|
||||
const action = new TradeAction();
|
||||
|
||||
action.resolve(
|
||||
{
|
||||
general: makeGeneral(),
|
||||
city,
|
||||
nation: { ...nation },
|
||||
rng: { nextFloat1: () => 0 },
|
||||
addLog: createLogSink(logs),
|
||||
} as unknown as Parameters<typeof action.resolve>[0],
|
||||
args
|
||||
);
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expectMonthlyPersistence(logs[0]!, LogFormat.PLAIN);
|
||||
});
|
||||
|
||||
it('keeps the che_하야 system summary on the Ref monthly format', () => {
|
||||
const logs: LogEntryDraft[] = [];
|
||||
const action = new ResignAction({ defaultNpcGold: 1_000, defaultNpcRice: 1_000 } as TurnCommandEnv);
|
||||
|
||||
action.resolve(
|
||||
{
|
||||
general: makeGeneral(),
|
||||
nation,
|
||||
troopMembers: [],
|
||||
rng: {},
|
||||
addLog: createLogSink(logs),
|
||||
} as unknown as Parameters<typeof action.resolve>[0],
|
||||
{}
|
||||
);
|
||||
|
||||
const summary = logs.find((entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY);
|
||||
expect(summary).toBeDefined();
|
||||
expectMonthlyPersistence(summary!, LogFormat.RAWTEXT);
|
||||
});
|
||||
|
||||
it('keeps the che_은퇴 system summary on the Ref monthly format', () => {
|
||||
const logs: LogEntryDraft[] = [];
|
||||
const action = new RetireAction();
|
||||
|
||||
action.resolve(
|
||||
{
|
||||
general: makeGeneral(),
|
||||
rng: {},
|
||||
addLog: createLogSink(logs),
|
||||
} as unknown as Parameters<typeof action.resolve>[0],
|
||||
{}
|
||||
);
|
||||
|
||||
const summary = logs.find((entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY);
|
||||
expect(summary).toBeDefined();
|
||||
expectMonthlyPersistence(summary!, LogFormat.RAWTEXT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { City, General, GeneralTriggerState, Nation } from '../../../src/domain/entities.js';
|
||||
import {
|
||||
resolveGeneralAction,
|
||||
type GeneralActionResolveInputContext,
|
||||
type GeneralActionResolver,
|
||||
} from '../../../src/actions/engine.js';
|
||||
import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn/nation/che_천도.js';
|
||||
import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js';
|
||||
import { ActionDefinition as ReduceCityAction } from '../../../src/actions/turn/nation/che_감축.js';
|
||||
import { ActionDefinition as RandomMoveCapitalAction } from '../../../src/actions/turn/nation/che_무작위수도이전.js';
|
||||
import { ActionDefinition as ScorchedEarthAction } from '../../../src/actions/turn/nation/che_초토화.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
|
||||
import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '../../../src/logging/types.js';
|
||||
import type { TurnSchedule } from '../../../src/turn/calendar.js';
|
||||
import type { MapDefinition } from '../../../src/world/types.js';
|
||||
|
||||
const ENV: TurnCommandEnv = {
|
||||
develCost: 100,
|
||||
trainDelta: 30,
|
||||
atmosDelta: 30,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
sabotageDefaultProb: 0.5,
|
||||
sabotageProbCoefByStat: 0.1,
|
||||
sabotageDefenceCoefByGeneralCount: 0.1,
|
||||
sabotageDamageMin: 10,
|
||||
sabotageDamageMax: 30,
|
||||
openingPartYear: 3,
|
||||
maxGeneral: 500,
|
||||
defaultNpcGold: 1_000,
|
||||
defaultNpcRice: 1_000,
|
||||
defaultCrewTypeId: 1_100,
|
||||
defaultSpecialDomestic: null,
|
||||
defaultSpecialWar: null,
|
||||
initialNationGenLimit: 10,
|
||||
maxTechLevel: 12,
|
||||
baseGold: 1_000,
|
||||
baseRice: 2_000,
|
||||
maxResourceActionAmount: 10_000,
|
||||
};
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 60 }] };
|
||||
const rng = {
|
||||
nextFloat1: () => 0,
|
||||
nextBool: () => false,
|
||||
nextInt: () => 0,
|
||||
};
|
||||
|
||||
const makeGeneral = (id: number, name = '운영자'): General => ({
|
||||
id,
|
||||
name,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||
experience: 1_000,
|
||||
dedication: 1_000,
|
||||
officerLevel: id === 1 ? 12 : 1,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 100,
|
||||
crewTypeId: 1_100,
|
||||
train: 100,
|
||||
atmos: 100,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24, betray: 0 },
|
||||
});
|
||||
|
||||
const makeNation = (): Nation => ({
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#111111',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: 1,
|
||||
gold: 1_000_000,
|
||||
rice: 1_000_000,
|
||||
power: 1_000,
|
||||
level: 1,
|
||||
typeCode: 'che_명가',
|
||||
meta: { capset: 0, can_무작위수도이전: 1, surlimit: 0 },
|
||||
});
|
||||
|
||||
const makeCity = (id: number, name: string, nationId: number): City => ({
|
||||
id,
|
||||
name,
|
||||
nationId,
|
||||
level: 5,
|
||||
state: 0,
|
||||
population: 200_000,
|
||||
populationMax: 300_000,
|
||||
agriculture: 3_000,
|
||||
agricultureMax: 4_000,
|
||||
commerce: 3_000,
|
||||
commerceMax: 4_000,
|
||||
security: 3_000,
|
||||
securityMax: 4_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 3_000,
|
||||
defenceMax: 4_000,
|
||||
wall: 3_000,
|
||||
wallMax: 4_000,
|
||||
conflict: {},
|
||||
meta: { trust: 80, trade: 100 },
|
||||
});
|
||||
|
||||
const mapStats = {
|
||||
population: 200_000,
|
||||
agriculture: 3_000,
|
||||
commerce: 3_000,
|
||||
security: 3_000,
|
||||
defence: 3_000,
|
||||
wall: 3_000,
|
||||
};
|
||||
|
||||
const map: MapDefinition = {
|
||||
id: 'nation-capital-log-parity',
|
||||
name: '국가 명령 로그',
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '허창',
|
||||
level: 5,
|
||||
region: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
connections: [2],
|
||||
initial: mapStats,
|
||||
max: mapStats,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '낙양',
|
||||
level: 5,
|
||||
region: 1,
|
||||
position: { x: 1, y: 0 },
|
||||
connections: [1],
|
||||
initial: mapStats,
|
||||
max: mapStats,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const resolveLogs = <Args>(
|
||||
resolver: GeneralActionResolver<GeneralTriggerState, Args>,
|
||||
context: GeneralActionResolveInputContext & Record<string, unknown>,
|
||||
args: Args
|
||||
): LogEntryDraft[] =>
|
||||
orderLegacyActionLoggerFlush(
|
||||
resolveGeneralAction(resolver, context, { now: new Date('2026-08-23T00:00:00.000Z'), schedule }, args).logs
|
||||
);
|
||||
|
||||
const projectLogs = (logs: readonly LogEntryDraft[]) =>
|
||||
logs.map((log) => [
|
||||
log.scope,
|
||||
log.category,
|
||||
log.generalId ?? null,
|
||||
log.nationId ?? null,
|
||||
log.format,
|
||||
log.legacyFlushGroup ?? 0,
|
||||
log.text,
|
||||
]);
|
||||
|
||||
const expectedActorLoggerFlush = (params: {
|
||||
actorId?: number;
|
||||
nationId?: number;
|
||||
generalHistory: string;
|
||||
generalAction: string;
|
||||
nationHistory: string;
|
||||
globalHistory: string;
|
||||
globalSummary: string;
|
||||
}) => [
|
||||
[LogScope.GENERAL, LogCategory.HISTORY, params.actorId ?? 1, null, LogFormat.YEAR_MONTH, 0, params.generalHistory],
|
||||
[LogScope.GENERAL, LogCategory.ACTION, params.actorId ?? 1, null, LogFormat.MONTH, 0, params.generalAction],
|
||||
[LogScope.NATION, LogCategory.HISTORY, null, params.nationId ?? 1, LogFormat.YEAR_MONTH, 0, params.nationHistory],
|
||||
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0, params.globalHistory],
|
||||
[LogScope.SYSTEM, LogCategory.SUMMARY, null, null, LogFormat.MONTH, 0, params.globalSummary],
|
||||
];
|
||||
|
||||
describe('nation capital command Ref ActionLogger parity', () => {
|
||||
it('che_천도', () => {
|
||||
const general = makeGeneral(1);
|
||||
const nation = makeNation();
|
||||
const capitalCity = makeCity(1, '허창', 1);
|
||||
const destCity = makeCity(2, '낙양', 1);
|
||||
const logs = resolveLogs(
|
||||
new MoveCapitalAction(ENV),
|
||||
{ general, city: capitalCity, nation, destCity, map, nationCities: [capitalCity, destCity], rng },
|
||||
{ destCityID: destCity.id }
|
||||
);
|
||||
|
||||
expect(projectLogs(logs)).toEqual(
|
||||
expectedActorLoggerFlush({
|
||||
generalHistory: '<G><b>낙양</b></>으로 <M>천도</>명령',
|
||||
generalAction: '<G><b>낙양</b></>으로 천도했습니다.',
|
||||
nationHistory: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>천도</> 명령',
|
||||
globalHistory: '<S><b>【천도】</b></><D><b>위</b></>가 <G><b>낙양</b></>으로 <M>천도</>하였습니다.',
|
||||
globalSummary: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>천도</>를 명령하였습니다.',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
action: '증축',
|
||||
resolver: new ExpandCityAction(ENV),
|
||||
globalHistoryPrefix: '<C><b>【증축】</b></>',
|
||||
},
|
||||
{
|
||||
action: '감축',
|
||||
resolver: new ReduceCityAction(ENV),
|
||||
globalHistoryPrefix: '<M><b>【감축】</b></>',
|
||||
},
|
||||
])('che_$action', ({ action, resolver, globalHistoryPrefix }) => {
|
||||
const general = makeGeneral(1);
|
||||
const nation = makeNation();
|
||||
const capitalCity = makeCity(1, '낙양', 1);
|
||||
const logs = resolveLogs(resolver, { general, city: capitalCity, nation, capitalCity, rng }, {});
|
||||
|
||||
expect(projectLogs(logs)).toEqual(
|
||||
expectedActorLoggerFlush({
|
||||
generalHistory: `<G><b>낙양</b></>을 <M>${action}</>`,
|
||||
generalAction: `<G><b>낙양</b></>을 ${action}했습니다.`,
|
||||
nationHistory: `<Y>운영자</>가 <G><b>낙양</b></>을 <M>${action}</>`,
|
||||
globalHistory: `${globalHistoryPrefix}<D><b>위</b></>가 <G><b>낙양</b></>을 <M>${action}</>하였습니다.`,
|
||||
globalSummary: `<Y>운영자</>가 <G><b>낙양</b></>을 <M>${action}</>하였습니다.`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('che_무작위수도이전', () => {
|
||||
const general = makeGeneral(1);
|
||||
const follower = makeGeneral(2, '부하');
|
||||
const nation = makeNation();
|
||||
const capitalCity = makeCity(1, '허창', 1);
|
||||
const destCity = makeCity(2, '낙양', 0);
|
||||
const logs = resolveLogs(
|
||||
new RandomMoveCapitalAction(),
|
||||
{
|
||||
general,
|
||||
city: capitalCity,
|
||||
nation,
|
||||
neutralCandidateCities: [destCity],
|
||||
nationGenerals: [general, follower],
|
||||
oldCapitalCity: capitalCity,
|
||||
rng,
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(projectLogs(logs)).toEqual([
|
||||
[
|
||||
LogScope.GENERAL,
|
||||
LogCategory.ACTION,
|
||||
follower.id,
|
||||
null,
|
||||
LogFormat.PLAIN,
|
||||
-1,
|
||||
'국가 수도를 <G><b>낙양</b></>으로 옮겼습니다.',
|
||||
],
|
||||
...expectedActorLoggerFlush({
|
||||
generalHistory: '<G><b>낙양</b></>으로 <M>무작위 수도 이전</>',
|
||||
generalAction: '<G><b>낙양</b></>으로 국가를 옮겼습니다.',
|
||||
nationHistory: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>무작위 수도 이전</>',
|
||||
globalHistory:
|
||||
'<S><b>【무작위 수도 이전】</b></><D><b>위</b></>가 <G><b>낙양</b></>으로 <M>수도 이전</>하였습니다.',
|
||||
globalSummary: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>수도 이전</>하였습니다.',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('che_초토화', () => {
|
||||
const general = makeGeneral(1);
|
||||
const follower = makeGeneral(2, '부하');
|
||||
const nation = makeNation();
|
||||
const capitalCity = makeCity(1, '허창', 1);
|
||||
const destCity = makeCity(2, '낙양', 1);
|
||||
const logs = resolveLogs(
|
||||
new ScorchedEarthAction(),
|
||||
{
|
||||
general,
|
||||
city: capitalCity,
|
||||
nation,
|
||||
destCity,
|
||||
destNation: nation,
|
||||
friendlyGenerals: [general, follower],
|
||||
rng,
|
||||
},
|
||||
{ destCityId: destCity.id }
|
||||
);
|
||||
|
||||
expect(projectLogs(logs)).toEqual(
|
||||
expectedActorLoggerFlush({
|
||||
generalHistory: '<G><b>낙양</b></>을 <M>초토화</> 명령',
|
||||
generalAction: '<G><b>낙양</b></>을 초토화했습니다.',
|
||||
nationHistory: '<Y>운영자</>가 <G><b>낙양</b></>을 <M>초토화</> 명령',
|
||||
globalHistory: '<S><b>【초토화】</b></><D><b>위</b></>가 <G><b>낙양</b></>을 <M>초토화</>하였습니다.',
|
||||
globalSummary: '<Y>운영자</>가 <G><b>낙양</b></>을 <M>초토화</>하였습니다.',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { City, General, Nation } from '../../../src/domain/entities.js';
|
||||
import { resolveGeneralAction, type TurnScheduleContext } from '../../../src/actions/engine.js';
|
||||
import { ActionDefinition as TroopKickAction } from '../../../src/actions/turn/nation/che_부대탈퇴지시.js';
|
||||
import { ActionDefinition as PopulationMoveAction } from '../../../src/actions/turn/nation/cr_인구이동.js';
|
||||
import { ActionResolver as MobilizePeopleAction } from '../../../src/actions/turn/nation/che_백성동원.js';
|
||||
import {
|
||||
ActionResolver as VolunteerRecruitAction,
|
||||
type VolunteerRecruitEnvironment,
|
||||
} from '../../../src/actions/turn/nation/che_의병모집.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
|
||||
import { finalizeLogEntry } from '../../../src/logging/entries.js';
|
||||
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js';
|
||||
|
||||
const scheduleContext: TurnScheduleContext = {
|
||||
now: new Date('2026-08-23T00:00:00.000Z'),
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
|
||||
};
|
||||
|
||||
const makeGeneral = (overrides: Partial<General> = {}): General =>
|
||||
({
|
||||
id: 1,
|
||||
name: '군주',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
officerLevel: 12,
|
||||
experience: 1_000,
|
||||
dedication: 1_000,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 100,
|
||||
crewTypeId: 1,
|
||||
train: 100,
|
||||
atmos: 100,
|
||||
injury: 0,
|
||||
age: 30,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: {},
|
||||
...overrides,
|
||||
}) as General;
|
||||
|
||||
const makeNation = (overrides: Partial<Nation> = {}): Nation => ({
|
||||
id: 1,
|
||||
name: '검증국',
|
||||
color: '#ff0000',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: 1,
|
||||
gold: 10_000,
|
||||
rice: 10_000,
|
||||
power: 0,
|
||||
level: 1,
|
||||
typeCode: 'che_def',
|
||||
meta: { gennum: 2, strategic_cmd_limit: 0 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeCity = (overrides: Partial<City> = {}): City => ({
|
||||
id: 1,
|
||||
name: '성도',
|
||||
nationId: 1,
|
||||
level: 1,
|
||||
state: 0,
|
||||
population: 50_000,
|
||||
populationMax: 100_000,
|
||||
agriculture: 500,
|
||||
agricultureMax: 1_000,
|
||||
commerce: 500,
|
||||
commerceMax: 1_000,
|
||||
security: 500,
|
||||
securityMax: 1_000,
|
||||
defence: 300,
|
||||
defenceMax: 1_000,
|
||||
wall: 300,
|
||||
wallMax: 1_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
meta: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeRng = (): RandUtil => new RandUtil(new ConstantRNG(0));
|
||||
|
||||
const expectMonthlyPersistence = (entry: LogEntryDraft, wrongFormat: LogFormat): void => {
|
||||
expect(entry.format).toBe(LogFormat.MONTH);
|
||||
const persisted = finalizeLogEntry(entry, { year: 186, month: 9 });
|
||||
const mutant = finalizeLogEntry({ ...entry, format: wrongFormat }, { year: 186, month: 9 });
|
||||
|
||||
expect(persisted?.text).toMatch(/^<C>●<\/>9월:/u);
|
||||
expect(mutant?.text).not.toBe(persisted?.text);
|
||||
expect(mutant?.text).not.toMatch(/^<C>●<\/>9월:/u);
|
||||
};
|
||||
|
||||
describe('nation command Ref log parity', () => {
|
||||
it('flushes che_부대탈퇴지시 actor then target with the Ref monthly format', () => {
|
||||
const actor = makeGeneral();
|
||||
const target = makeGeneral({ id: 2, name: '부대원', troopId: 3 });
|
||||
const resolution = resolveGeneralAction(
|
||||
new TroopKickAction(),
|
||||
{
|
||||
general: actor,
|
||||
nation: makeNation(),
|
||||
city: makeCity(),
|
||||
destGeneral: target,
|
||||
rng: makeRng(),
|
||||
} as never,
|
||||
scheduleContext,
|
||||
{ destGeneralId: target.id }
|
||||
);
|
||||
const logs = orderLegacyActionLoggerFlush(resolution.logs);
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs.map((entry) => entry.generalId)).toEqual([actor.id, target.id]);
|
||||
expect(logs.map((entry) => entry.text)).toEqual([
|
||||
'<Y>부대원</>에게 부대 탈퇴를 지시했습니다.',
|
||||
'<Y>군주</>에게 부대 탈퇴를 지시 받았습니다.',
|
||||
]);
|
||||
expect(logs[1]?.legacyFlushGroup).toBe(1);
|
||||
expectMonthlyPersistence(logs[1]!, LogFormat.PLAIN);
|
||||
});
|
||||
|
||||
it('keeps cr_인구이동 population text ungrouped like PHP integer interpolation', () => {
|
||||
const actor = makeGeneral();
|
||||
const source = makeCity();
|
||||
const destination = makeCity({ id: 2, name: '락양', population: 10_000 });
|
||||
const resolution = resolveGeneralAction(
|
||||
new PopulationMoveAction({ develCost: 100, baseGold: 1_000, baseRice: 1_000 } as TurnCommandEnv),
|
||||
{
|
||||
general: actor,
|
||||
nation: makeNation(),
|
||||
city: source,
|
||||
destCity: destination,
|
||||
destNation: makeNation(),
|
||||
rng: makeRng(),
|
||||
} as never,
|
||||
scheduleContext,
|
||||
{ destCityId: destination.id, amount: 10_000 }
|
||||
);
|
||||
const [entry] = resolution.logs;
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: actor.id,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<G><b>락양</b></>으로 인구 <C>10000</>명을 옮겼습니다.',
|
||||
});
|
||||
expect(entry?.text).not.toBe('<G><b>락양</b></>으로 인구 <C>10,000</>명을 옮겼습니다.');
|
||||
});
|
||||
|
||||
it('keeps che_백성동원 notification and history streams distinct in Ref flush order', () => {
|
||||
const actor = makeGeneral();
|
||||
const target = makeGeneral({ id: 2, name: '동료' });
|
||||
const nation = makeNation();
|
||||
const destination = makeCity();
|
||||
const resolution = resolveGeneralAction(
|
||||
new MobilizePeopleAction([], 10),
|
||||
{
|
||||
general: actor,
|
||||
nation,
|
||||
city: makeCity(),
|
||||
destCity: destination,
|
||||
friendlyGenerals: [actor, target],
|
||||
rng: makeRng(),
|
||||
} as never,
|
||||
scheduleContext,
|
||||
{ destCityId: destination.id }
|
||||
);
|
||||
const logs = orderLegacyActionLoggerFlush(resolution.logs);
|
||||
|
||||
expect(logs.map((entry) => [entry.scope, entry.category, entry.generalId, entry.nationId])).toEqual([
|
||||
[LogScope.GENERAL, LogCategory.ACTION, target.id, undefined],
|
||||
[LogScope.GENERAL, LogCategory.HISTORY, actor.id, undefined],
|
||||
[LogScope.GENERAL, LogCategory.ACTION, actor.id, undefined],
|
||||
[LogScope.NATION, LogCategory.HISTORY, undefined, nation.id],
|
||||
]);
|
||||
expect(logs[0]).toMatchObject({
|
||||
text: '<Y>군주</>가 <G><b>성도</b></>에 <M>백성동원</>을 하였습니다.',
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
});
|
||||
expect(logs[3]).toMatchObject({
|
||||
text: '<Y>군주</>가 <G><b>성도</b></>에 <M>백성동원</>을 발동',
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
expect(logs[3]?.text).not.toBe(logs[0]?.text);
|
||||
});
|
||||
|
||||
it('preserves che_의병모집 actor history markup and Ref flush order', () => {
|
||||
const actor = makeGeneral();
|
||||
const target = makeGeneral({ id: 2, name: '동료' });
|
||||
const nation = makeNation();
|
||||
const environment: VolunteerRecruitEnvironment = {
|
||||
openingPartYear: 0,
|
||||
initialNationGenLimit: 10,
|
||||
defaultNpcGold: 1_000,
|
||||
defaultNpcRice: 1_000,
|
||||
defaultCrewTypeId: 1,
|
||||
defaultSpecialDomestic: null,
|
||||
defaultSpecialWar: null,
|
||||
createCountBase: 0,
|
||||
createCountDivisor: 8,
|
||||
};
|
||||
const resolution = resolveGeneralAction(
|
||||
new VolunteerRecruitAction([], environment),
|
||||
{
|
||||
general: actor,
|
||||
nation,
|
||||
city: makeCity(),
|
||||
rng: makeRng(),
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
startYear: 180,
|
||||
centennialRules: {
|
||||
defaultStatMin: 15,
|
||||
defaultStatMax: 80,
|
||||
defaultStatTotal: 165,
|
||||
maxStatLevel: 255,
|
||||
defaultSpecialDomestic: null,
|
||||
dexLimit: 1_000_000,
|
||||
},
|
||||
centennialNpcDexTargetRatio: 0.4,
|
||||
averageNationGeneralCount: 0,
|
||||
nationAverageStats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
nationAverageExperience: 0,
|
||||
nationAverageDedication: 0,
|
||||
nationAverageDex: [100, 100, 100, 100, 100],
|
||||
friendlyGenerals: [actor, target],
|
||||
createGeneralId: () => 3,
|
||||
turnTermSeconds: 60,
|
||||
turnTimeBase: new Date('0190-01-01T00:00:00.000Z'),
|
||||
ticksPerSecond: 1,
|
||||
} as never,
|
||||
scheduleContext,
|
||||
{}
|
||||
);
|
||||
const logs = orderLegacyActionLoggerFlush(resolution.logs);
|
||||
|
||||
expect(logs.map((entry) => [entry.scope, entry.category, entry.generalId, entry.nationId])).toEqual([
|
||||
[LogScope.GENERAL, LogCategory.ACTION, target.id, undefined],
|
||||
[LogScope.GENERAL, LogCategory.HISTORY, actor.id, undefined],
|
||||
[LogScope.GENERAL, LogCategory.ACTION, actor.id, undefined],
|
||||
[LogScope.NATION, LogCategory.HISTORY, undefined, nation.id],
|
||||
]);
|
||||
expect(logs[0]).toMatchObject({
|
||||
text: '<Y>군주</>가 <M>의병모집</>을 발동하였습니다.',
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: -1,
|
||||
});
|
||||
expect(logs[1]).toMatchObject({
|
||||
text: '<M>의병모집</>을 발동',
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
expect(logs[1]?.text).not.toBe('의병모집 발동');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { RandomGenerator } from '@sammo-ts/common';
|
||||
|
||||
import type { GeneralActionOutcome, GeneralActionResolveContext } from '../../../src/actions/engine.js';
|
||||
import type { City, General, Nation } from '../../../src/domain/entities.js';
|
||||
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
|
||||
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js';
|
||||
import {
|
||||
ActionResolver as DegradeRelationsResolver,
|
||||
type DegradeRelationsResolveContext,
|
||||
} from '../../../src/actions/turn/nation/che_이호경식.js';
|
||||
import { ActionResolver as RaidResolver, type RaidResolveContext } from '../../../src/actions/turn/nation/che_급습.js';
|
||||
import {
|
||||
ActionResolver as LastStandResolver,
|
||||
type DesperateFightResolveContext,
|
||||
} from '../../../src/actions/turn/nation/che_필사즉생.js';
|
||||
import {
|
||||
ActionResolver as DeceptionResolver,
|
||||
type DeceptionResolveContext,
|
||||
} from '../../../src/actions/turn/nation/che_허보.js';
|
||||
import {
|
||||
ActionResolver as CounterStrategyResolver,
|
||||
type CounterStrategyResolveContext,
|
||||
} from '../../../src/actions/turn/nation/che_피장파장.js';
|
||||
|
||||
const rng: RandomGenerator = {
|
||||
nextFloat1: () => 0.5,
|
||||
nextBool: () => false,
|
||||
nextInt: (minInclusive) => minInclusive,
|
||||
};
|
||||
|
||||
const buildGeneral = (id: number, nationId: number, cityId: number, name: string): General => ({
|
||||
id,
|
||||
name,
|
||||
nationId,
|
||||
cityId,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||
experience: 100,
|
||||
dedication: 100,
|
||||
officerLevel: id === 1 ? 12 : 1,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 100,
|
||||
crewTypeId: 1,
|
||||
train: 80,
|
||||
atmos: 80,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
});
|
||||
|
||||
const buildNation = (id: number, name: string, chiefGeneralId: number | null): Nation => ({
|
||||
id,
|
||||
name,
|
||||
color: '#000000',
|
||||
capitalCityId: id * 100,
|
||||
chiefGeneralId,
|
||||
gold: 10_000,
|
||||
rice: 10_000,
|
||||
power: 100,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: { gennum: 3, strategic_cmd_limit: 0 },
|
||||
});
|
||||
|
||||
const buildCity = (id: number, nationId: number, name: string): City => ({
|
||||
id,
|
||||
name,
|
||||
nationId,
|
||||
level: 1,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 20_000,
|
||||
agriculture: 500,
|
||||
agricultureMax: 1_000,
|
||||
commerce: 500,
|
||||
commerceMax: 1_000,
|
||||
security: 500,
|
||||
securityMax: 1_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 300,
|
||||
defenceMax: 1_000,
|
||||
wall: 300,
|
||||
wallMax: 1_000,
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const buildFixture = () => {
|
||||
const actor = buildGeneral(1, 10, 100, '가람');
|
||||
const friendlyTargets = [buildGeneral(2, 10, 100, '아군일'), buildGeneral(3, 10, 100, '아군이')];
|
||||
const destTargets = [buildGeneral(4, 20, 200, '적군일'), buildGeneral(5, 20, 200, '적군이')];
|
||||
return {
|
||||
actor,
|
||||
friendlyTargets,
|
||||
destTargets,
|
||||
nation: buildNation(10, '촉', actor.id),
|
||||
destNation: buildNation(20, '위', destTargets[0]!.id),
|
||||
destCity: buildCity(200, 20, '업'),
|
||||
safeDestCity: buildCity(201, 20, '평원'),
|
||||
};
|
||||
};
|
||||
|
||||
const createActorLogSink = (actorId: number, logs: LogEntryDraft[]): GeneralActionResolveContext['addLog'] => {
|
||||
return (text, options = {}) => {
|
||||
const entry: LogEntryDraft = {
|
||||
scope: options.scope ?? LogScope.GENERAL,
|
||||
category: options.category ?? LogCategory.ACTION,
|
||||
text,
|
||||
format: options.format ?? LogFormat.MONTH,
|
||||
...options,
|
||||
};
|
||||
if (entry.scope === LogScope.GENERAL && entry.generalId === undefined) {
|
||||
entry.generalId = actorId;
|
||||
}
|
||||
logs.push(entry);
|
||||
};
|
||||
};
|
||||
|
||||
const collectLogs = (
|
||||
actorId: number,
|
||||
resolve: (addLog: GeneralActionResolveContext['addLog']) => GeneralActionOutcome
|
||||
): LogEntryDraft[] => {
|
||||
const logs: LogEntryDraft[] = [];
|
||||
const outcome = resolve(createActorLogSink(actorId, logs));
|
||||
for (const effect of outcome.effects) {
|
||||
if (effect.type === 'log') {
|
||||
logs.push(effect.entry);
|
||||
}
|
||||
}
|
||||
return logs;
|
||||
};
|
||||
|
||||
interface RefFlushExpectation {
|
||||
actorId: number;
|
||||
sourceNationId: number;
|
||||
destNationId?: number;
|
||||
friendlyTargetIds: number[];
|
||||
destTargetIds: number[];
|
||||
friendlyText: string;
|
||||
destText?: string;
|
||||
destNationText?: string;
|
||||
destNationFormat?: LogFormat;
|
||||
actorHistoryText: string;
|
||||
actorActionText: string;
|
||||
sourceNationText: string;
|
||||
}
|
||||
|
||||
const projectLog = (entry: LogEntryDraft) => ({
|
||||
scope: entry.scope,
|
||||
category: entry.category,
|
||||
owner:
|
||||
entry.generalId !== undefined
|
||||
? `general:${entry.generalId}`
|
||||
: entry.nationId !== undefined
|
||||
? `nation:${entry.nationId}`
|
||||
: 'none',
|
||||
text: entry.text,
|
||||
format: entry.format,
|
||||
group: entry.legacyFlushGroup ?? 0,
|
||||
});
|
||||
|
||||
const expectRefFlush = (logs: LogEntryDraft[], expected: RefFlushExpectation): void => {
|
||||
const internalEpochCount =
|
||||
expected.friendlyTargetIds.length + expected.destTargetIds.length + (expected.destNationText ? 1 : 0);
|
||||
const firstInternalGroup = -internalEpochCount;
|
||||
const expectedLogs = [
|
||||
...expected.friendlyTargetIds.map((generalId, index) => ({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
owner: `general:${generalId}`,
|
||||
text: expected.friendlyText,
|
||||
format: LogFormat.PLAIN,
|
||||
group: firstInternalGroup + index,
|
||||
})),
|
||||
...expected.destTargetIds.map((generalId, index) => ({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
owner: `general:${generalId}`,
|
||||
text: expected.destText,
|
||||
format: LogFormat.PLAIN,
|
||||
group: firstInternalGroup + expected.friendlyTargetIds.length + index,
|
||||
})),
|
||||
...(expected.destNationText
|
||||
? [
|
||||
{
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
owner: `nation:${expected.destNationId}`,
|
||||
text: expected.destNationText,
|
||||
format: expected.destNationFormat,
|
||||
group: -1,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
owner: `general:${expected.actorId}`,
|
||||
text: expected.actorHistoryText,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
group: 0,
|
||||
},
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
owner: `general:${expected.actorId}`,
|
||||
text: expected.actorActionText,
|
||||
format: LogFormat.MONTH,
|
||||
group: 0,
|
||||
},
|
||||
{
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
owner: `nation:${expected.sourceNationId}`,
|
||||
text: expected.sourceNationText,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
group: 0,
|
||||
},
|
||||
];
|
||||
|
||||
expect(orderLegacyActionLoggerFlush(logs).map(projectLog)).toEqual(expectedLogs);
|
||||
};
|
||||
|
||||
describe('nation deception command Ref log parity', () => {
|
||||
it('preserves che_이호경식 logger epochs, texts, categories, and formats', () => {
|
||||
const fixture = buildFixture();
|
||||
const logs = collectLogs(fixture.actor.id, (addLog) =>
|
||||
new DegradeRelationsResolver([]).resolve(
|
||||
{
|
||||
general: fixture.actor,
|
||||
nation: fixture.nation,
|
||||
destNation: fixture.destNation,
|
||||
diplomacy: { state: 0, term: 3 },
|
||||
reverseDiplomacy: { state: 0, term: 3 },
|
||||
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
|
||||
destNationGenerals: fixture.destTargets,
|
||||
rng,
|
||||
addLog,
|
||||
} satisfies DegradeRelationsResolveContext,
|
||||
{ destNationId: fixture.destNation.id }
|
||||
)
|
||||
);
|
||||
|
||||
expectRefFlush(logs, {
|
||||
actorId: fixture.actor.id,
|
||||
sourceNationId: fixture.nation.id,
|
||||
destNationId: fixture.destNation.id,
|
||||
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
|
||||
destTargetIds: fixture.destTargets.map((general) => general.id),
|
||||
friendlyText: '<Y>가람</>이 <G><b>위</b></>에 <M>이호경식</>을 발동하였습니다.',
|
||||
destText: '<D><b>촉</b></>이 아국에 <M>이호경식</>을 발동하였습니다.',
|
||||
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국에 <M>이호경식</>을 발동',
|
||||
destNationFormat: LogFormat.YEAR_MONTH,
|
||||
actorHistoryText: '<D><b>위</b></>에 <M>이호경식</>을 발동',
|
||||
actorActionText: '이호경식 발동!',
|
||||
sourceNationText: '<Y>가람</>이 <D><b>위</b></>에 <M>이호경식</>을 발동',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves che_급습 logger epochs, texts, categories, and formats', () => {
|
||||
const fixture = buildFixture();
|
||||
const logs = collectLogs(fixture.actor.id, (addLog) =>
|
||||
new RaidResolver([]).resolve(
|
||||
{
|
||||
general: fixture.actor,
|
||||
nation: fixture.nation,
|
||||
destNation: fixture.destNation,
|
||||
diplomacy: { state: 1, term: 18 },
|
||||
reverseDiplomacy: { state: 1, term: 18 },
|
||||
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
|
||||
destNationGenerals: fixture.destTargets,
|
||||
rng,
|
||||
addLog,
|
||||
} satisfies RaidResolveContext,
|
||||
{ destNationId: fixture.destNation.id }
|
||||
)
|
||||
);
|
||||
|
||||
expectRefFlush(logs, {
|
||||
actorId: fixture.actor.id,
|
||||
sourceNationId: fixture.nation.id,
|
||||
destNationId: fixture.destNation.id,
|
||||
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
|
||||
destTargetIds: fixture.destTargets.map((general) => general.id),
|
||||
friendlyText: '<Y>가람</>이 <G><b>위</b></>에 <M>급습</>을 발동하였습니다.',
|
||||
destText: '아국에 <M>급습</>이 발동되었습니다.',
|
||||
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국에 <M>급습</>을 발동',
|
||||
destNationFormat: LogFormat.YEAR_MONTH,
|
||||
actorHistoryText: '<D><b>위</b></>에 <M>급습</>을 발동',
|
||||
actorActionText: '급습 발동!',
|
||||
sourceNationText: '<Y>가람</>이 <D><b>위</b></>에 <M>급습</>을 발동',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves che_필사즉생 target applyDB epochs before the actor logger', () => {
|
||||
const fixture = buildFixture();
|
||||
const logs = collectLogs(fixture.actor.id, (addLog) =>
|
||||
new LastStandResolver([]).resolve(
|
||||
{
|
||||
general: fixture.actor,
|
||||
nation: fixture.nation,
|
||||
nationGenerals: [fixture.actor, ...fixture.friendlyTargets],
|
||||
rng,
|
||||
addLog,
|
||||
} satisfies DesperateFightResolveContext,
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
expectRefFlush(logs, {
|
||||
actorId: fixture.actor.id,
|
||||
sourceNationId: fixture.nation.id,
|
||||
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
|
||||
destTargetIds: [],
|
||||
friendlyText: '<Y>가람</>이 <M>필사즉생</>을 발동하였습니다.',
|
||||
actorHistoryText: '<M>필사즉생</>을 발동',
|
||||
actorActionText: '필사즉생 발동!',
|
||||
sourceNationText: '<Y>가람</>이 <M>필사즉생</>을 발동',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves che_허보 per-general applyDB epochs and plain target-nation history', () => {
|
||||
const fixture = buildFixture();
|
||||
const deceptionRng: RandomGenerator = { ...rng, nextInt: () => 1 };
|
||||
const logs = collectLogs(fixture.actor.id, (addLog) =>
|
||||
new DeceptionResolver([]).resolve(
|
||||
{
|
||||
general: fixture.actor,
|
||||
nation: fixture.nation,
|
||||
destNation: fixture.destNation,
|
||||
destCity: fixture.destCity,
|
||||
destCityGenerals: fixture.destTargets,
|
||||
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
|
||||
destNationSupplyCities: [fixture.destCity, fixture.safeDestCity],
|
||||
rng: deceptionRng,
|
||||
addLog,
|
||||
} satisfies DeceptionResolveContext,
|
||||
{ destCityId: fixture.destCity.id }
|
||||
)
|
||||
);
|
||||
|
||||
expectRefFlush(logs, {
|
||||
actorId: fixture.actor.id,
|
||||
sourceNationId: fixture.nation.id,
|
||||
destNationId: fixture.destNation.id,
|
||||
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
|
||||
destTargetIds: fixture.destTargets.map((general) => general.id),
|
||||
friendlyText: '<Y>가람</>이 <G><b>업</b></>에 <M>허보</>를 발동하였습니다.',
|
||||
destText: '상대의 <M>허보</>에 당했다!',
|
||||
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국의 <G><b>업</b></>에 <M>허보</>를 발동',
|
||||
destNationFormat: LogFormat.PLAIN,
|
||||
actorHistoryText: '<G><b>업</b></>에 <M>허보</>를 발동',
|
||||
actorActionText: '허보 발동!',
|
||||
sourceNationText: '<Y>가람</>이 <G><b>업</b></>에 <M>허보</>를 발동',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves che_피장파장 logger epochs and year-month target-nation history', () => {
|
||||
const fixture = buildFixture();
|
||||
const logs = collectLogs(fixture.actor.id, (addLog) =>
|
||||
new CounterStrategyResolver([]).resolve(
|
||||
{
|
||||
general: fixture.actor,
|
||||
nation: fixture.nation,
|
||||
destNation: fixture.destNation,
|
||||
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
|
||||
destNationGenerals: fixture.destTargets,
|
||||
currentYearMonth: 2_231,
|
||||
rng,
|
||||
addLog,
|
||||
} satisfies CounterStrategyResolveContext,
|
||||
{ destNationId: fixture.destNation.id, commandType: 'che_허보' }
|
||||
)
|
||||
);
|
||||
|
||||
expectRefFlush(logs, {
|
||||
actorId: fixture.actor.id,
|
||||
sourceNationId: fixture.nation.id,
|
||||
destNationId: fixture.destNation.id,
|
||||
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
|
||||
destTargetIds: fixture.destTargets.map((general) => general.id),
|
||||
friendlyText: '<Y>가람</>이 <G><b>위</b></>에 <G><b>허보</b></> 전략의 <M>피장파장</>을 발동하였습니다.',
|
||||
destText: '아국에 <G><b>허보</b></> 전략의 <M>피장파장</>이 발동되었습니다.',
|
||||
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국에 <G><b>허보</b></> <M>피장파장</>을 발동',
|
||||
destNationFormat: LogFormat.YEAR_MONTH,
|
||||
actorHistoryText: '<D><b>위</b></>에 <G><b>허보</b></> <M>피장파장</>을 발동',
|
||||
actorActionText: '<G><b>허보</b></> 전략의 피장파장 발동!',
|
||||
sourceNationText: '<Y>가람</>이 <D><b>위</b></>에 <G><b>허보</b></> <M>피장파장</>을 발동',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { City, General, Nation } from '../../../src/domain/entities.js';
|
||||
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||
import { evaluateConstraints } from '../../../src/constraints/evaluate.js';
|
||||
import { resolveGeneralAction } from '../../../src/actions/engine.js';
|
||||
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
|
||||
import type { MapDefinition } from '../../../src/world/types.js';
|
||||
import type { TurnSchedule } from '../../../src/turn/calendar.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
@@ -355,6 +356,8 @@ describe('Nation Missing Actions', () => {
|
||||
expect(definition.parseArgs({ destNationId: 2, amountList: [-1, 10] })).toBeNull();
|
||||
|
||||
const general = buildGeneral(1, 1, 1);
|
||||
const sourceChief = buildGeneral(2, 1, 1, 'SourceChief');
|
||||
const destChief = buildGeneral(3, 2, 2, 'DestChief');
|
||||
const nation = { ...buildNation(1), gold: 1000, rice: 1000 };
|
||||
const destNation = { ...buildNation(2), gold: 100, rice: 100 };
|
||||
const resolution = resolveGeneralAction(
|
||||
@@ -364,8 +367,8 @@ describe('Nation Missing Actions', () => {
|
||||
city: buildCity(1, 1),
|
||||
nation,
|
||||
destNation,
|
||||
friendlyChiefs: [general],
|
||||
destNationChiefs: [],
|
||||
friendlyChiefs: [general, sourceChief],
|
||||
destNationChiefs: [destChief],
|
||||
rng: {} as any,
|
||||
addLog: () => {},
|
||||
} as any,
|
||||
@@ -388,6 +391,16 @@ describe('Nation Missing Actions', () => {
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const orderedLogs = orderLegacyActionLoggerFlush(resolution.logs);
|
||||
expect(orderedLogs.map((log) => log.legacyFlushGroup ?? 0)).toEqual([-1, -1, 0, 0, 0, 0, 0, 1]);
|
||||
expect(orderedLogs.slice(0, 2).map((log) => log.generalId)).toEqual([sourceChief.id, destChief.id]);
|
||||
expect(orderedLogs.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
nationId: destNation.id,
|
||||
legacyFlushGroup: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('che_초토화: blocks when diplomacy limit exists', () => {
|
||||
|
||||
@@ -109,16 +109,21 @@ describe('nation volunteer recruitment lifespan', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const created = createdEffect.general as General & { bornYear?: number; deadYear?: number };
|
||||
const created = createdEffect.general as General & { affinity?: number; bornYear?: number; deadYear?: number };
|
||||
expect(created).toMatchObject({
|
||||
name: 'ⓖ장수',
|
||||
affinity: 1,
|
||||
bornYear: 170,
|
||||
deadYear: 200,
|
||||
experience: 2_000,
|
||||
dedication: 2_000,
|
||||
meta: {
|
||||
affinity: 1,
|
||||
birthYear: 170,
|
||||
deathYear: 200,
|
||||
npc_org: 4,
|
||||
explevel: 0,
|
||||
dedlevel: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,6 +130,7 @@ describe('talent scout scenario general pool', () => {
|
||||
imageServer: 1,
|
||||
role: { specialDomestic: null, specialWar: null },
|
||||
meta: {
|
||||
npc_org: 3,
|
||||
dex1: 12,
|
||||
dex2: 24,
|
||||
dex3: 36,
|
||||
|
||||
@@ -73,6 +73,7 @@ describe('instant diplomatic response parity', () => {
|
||||
const logs = result.effects.filter((effect) => effect.type === 'log');
|
||||
expect(logs).toHaveLength(4);
|
||||
expect(logs.map((effect) => effect.entry.generalId)).toEqual([11, 11, 22, 22]);
|
||||
expect(logs.map((effect) => effect.entry.legacyFlushGroup ?? 0)).toEqual([0, 0, 1, 1]);
|
||||
});
|
||||
|
||||
it('counts an accepted pact down through every month and returns both directions to trade', () => {
|
||||
@@ -123,6 +124,7 @@ describe('instant diplomatic response parity', () => {
|
||||
expect(result.actionKey).toBe('che_불가침파기수락');
|
||||
expect(result.refreshFront).toBe(false);
|
||||
expect(logs).toHaveLength(6);
|
||||
expect(logs.map((effect) => effect.entry.legacyFlushGroup ?? 0)).toEqual([0, 0, 0, 0, 1, 1]);
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
entry: expect.objectContaining({
|
||||
@@ -135,6 +137,7 @@ describe('instant diplomatic response parity', () => {
|
||||
|
||||
it('creates both nation histories and requests front refresh for stop-war', () => {
|
||||
const result = resolveInstantDiplomacyResponse(context, { action: 'stopWar' });
|
||||
const logs = result.effects.filter((effect) => effect.type === 'log');
|
||||
const nationLogIds = result.effects.flatMap((effect) =>
|
||||
effect.type === 'log' && effect.entry.scope === LogScope.NATION ? [effect.entry.nationId] : []
|
||||
);
|
||||
@@ -142,6 +145,7 @@ describe('instant diplomatic response parity', () => {
|
||||
expect(result.actionKey).toBe('che_종전수락');
|
||||
expect(result.refreshFront).toBe(true);
|
||||
expect(nationLogIds).toEqual([1, 2]);
|
||||
expect(logs.map((effect) => effect.entry.legacyFlushGroup ?? 0)).toEqual([0, 0, 0, 0, 0, 1, 1, 1]);
|
||||
});
|
||||
|
||||
it('recomputes only requested nation fronts with legacy priority', () => {
|
||||
|
||||
@@ -7,6 +7,9 @@ import type { DispatchResolveContext } from '../src/actions/turn/general/che_출
|
||||
import type { TurnSchedule } from '../src/turn/calendar.js';
|
||||
import type { WarAftermathConfig, WarEngineConfig } from '../src/war/types.js';
|
||||
import type { UnitSetDefinition } from '../src/world/types.js';
|
||||
import type { ItemModule } from '../src/items/types.js';
|
||||
import { orderLegacyActionLoggerFlush } from '../src/logging/actionLogger.js';
|
||||
import { LogCategory, LogFormat, LogScope } from '../src/logging/types.js';
|
||||
|
||||
const buildGeneral = (id: number, nationId: number, cityId: number): General => ({
|
||||
id,
|
||||
@@ -175,6 +178,19 @@ const schedule: TurnSchedule = {
|
||||
entries: [{ startMinute: 0, tickMinutes: 60 }],
|
||||
};
|
||||
|
||||
const uniqueItem: ItemModule = {
|
||||
key: 'test_unique',
|
||||
name: '테스트 유니크',
|
||||
rawName: '테스트 유니크',
|
||||
info: 'test',
|
||||
slot: 'item',
|
||||
cost: null,
|
||||
buyable: false,
|
||||
consumable: false,
|
||||
reqSecu: 0,
|
||||
unique: true,
|
||||
};
|
||||
|
||||
describe('che_출병', () => {
|
||||
it('runs war battle and emits patches/logs', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
@@ -183,14 +199,18 @@ describe('che_출병', () => {
|
||||
const defenderCity = buildCity(2, defenderNation.id);
|
||||
const neutralCity = buildCity(3, 0);
|
||||
const attacker = buildGeneral(1, attackerNation.id, attackerCity.id);
|
||||
attacker.officerLevel = 12;
|
||||
attacker.turnTime = new Date('2000-01-01T00:00:00Z');
|
||||
const defender = buildGeneral(2, defenderNation.id, defenderCity.id);
|
||||
defender.officerLevel = 12;
|
||||
defender.crew = 0;
|
||||
defenderCity.defence = 0;
|
||||
defenderCity.wall = 0;
|
||||
|
||||
const definition = new ActionDefinition();
|
||||
const context: Omit<DispatchResolveContext, 'addLog'> = {
|
||||
const context: Omit<DispatchResolveContext, 'addLog'> & {
|
||||
uniqueLottery: () => ItemModule;
|
||||
} = {
|
||||
general: attacker,
|
||||
city: attackerCity,
|
||||
nation: attackerNation,
|
||||
@@ -246,9 +266,12 @@ describe('che_출병', () => {
|
||||
month: 1,
|
||||
startYear: 180,
|
||||
},
|
||||
seedBase: 'test-seed',
|
||||
seedBase: 'test-seed-2',
|
||||
warConfig,
|
||||
aftermathConfig,
|
||||
messageTime: new Date('2000-01-01T00:00:00.000Z'),
|
||||
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
|
||||
uniqueLottery: () => uniqueItem,
|
||||
};
|
||||
const resolution = resolveGeneralAction(
|
||||
definition,
|
||||
@@ -278,6 +301,44 @@ describe('che_출병', () => {
|
||||
effect.destNationId === defenderNation.id
|
||||
)
|
||||
).toBe(true);
|
||||
expect(resolution.effects.find((effect) => effect.type === 'message:add')).toEqual({
|
||||
type: 'message:add',
|
||||
draft: expect.objectContaining({
|
||||
msgType: 'private',
|
||||
dest: expect.objectContaining({
|
||||
generalId: defender.id,
|
||||
nationId: 0,
|
||||
nationName: '재야',
|
||||
icon: 'https://ref.example/image/icons/default.jpg',
|
||||
}),
|
||||
text: 'Nation1로 망명 권유 서신',
|
||||
option: { action: 'scout' },
|
||||
sendDestOnly: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const uniqueGroups = new Set(resolution.postProgressionLogs.map((log) => log.legacyFlushGroup));
|
||||
expect(uniqueGroups.size).toBe(1);
|
||||
const [internalFinalGroup] = uniqueGroups;
|
||||
expect(internalFinalGroup).toBeTypeOf('number');
|
||||
expect(internalFinalGroup).toBeLessThan(0);
|
||||
expect(resolution.logs.find((log) => log.text.includes('정복으로 금'))?.legacyFlushGroup).toBe(
|
||||
internalFinalGroup
|
||||
);
|
||||
|
||||
const outerProgressionLog = {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
generalId: attacker.id,
|
||||
text: 'outer progression',
|
||||
} as const;
|
||||
const ordered = orderLegacyActionLoggerFlush([
|
||||
...resolution.logs,
|
||||
...resolution.postProgressionLogs,
|
||||
outerProgressionLog,
|
||||
]);
|
||||
expect(ordered.at(-1)?.text).toBe(outerProgressionLog.text);
|
||||
});
|
||||
|
||||
it('orders equal-priority defender inputs by general number before the stable battle sort', () => {
|
||||
@@ -347,6 +408,7 @@ describe('che_출병', () => {
|
||||
seedBase: 'route-layer-seed',
|
||||
warConfig,
|
||||
aftermathConfig,
|
||||
messageTime: new Date('2000-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const resolution = resolveGeneralAction(
|
||||
|
||||
@@ -1,6 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { finalizeLogEntry, LogCategory, LogFormat, LogScope } from '../src/index.js';
|
||||
import {
|
||||
ActionLogger,
|
||||
finalizeLogEntry,
|
||||
LogCategory,
|
||||
LogFormat,
|
||||
LogScope,
|
||||
orderLegacyActionLoggerFlush,
|
||||
} from '../src/index.js';
|
||||
|
||||
describe('ActionLogger', () => {
|
||||
it('flushes Ref category buffers in physical persistence order', () => {
|
||||
const logger = new ActionLogger({ generalId: 7, nationId: 3 });
|
||||
|
||||
logger.pushGlobalActionLog('global action');
|
||||
logger.pushGlobalHistoryLog('global history');
|
||||
logger.pushNationHistoryLog('nation history');
|
||||
logger.pushGeneralActionLog(['first action', 'second action']);
|
||||
logger.pushGeneralHistoryLog('general history');
|
||||
|
||||
expect(logger.flush().map((entry) => entry.text)).toEqual([
|
||||
'general history',
|
||||
'first action',
|
||||
'second action',
|
||||
'nation history',
|
||||
'global history',
|
||||
'global action',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a separately flushed logger after every category of the earlier logger', () => {
|
||||
expect(
|
||||
orderLegacyActionLoggerFlush([
|
||||
{
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: 1,
|
||||
text: 'actor nation history',
|
||||
},
|
||||
{
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: 2,
|
||||
text: 'destination nation history',
|
||||
legacyFlushGroup: 1,
|
||||
},
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
text: 'global history',
|
||||
},
|
||||
]).map((entry) => entry.text)
|
||||
).toEqual(['actor nation history', 'global history', 'destination nation history']);
|
||||
});
|
||||
|
||||
it('preserves early, actor, and destructor flush stages before applying category buckets', () => {
|
||||
const ordered = orderLegacyActionLoggerFlush([
|
||||
{
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: 1,
|
||||
text: 'actor nation history',
|
||||
},
|
||||
{
|
||||
scope: LogScope.NATION,
|
||||
category: LogCategory.HISTORY,
|
||||
nationId: 2,
|
||||
text: 'destructor nation history',
|
||||
legacyFlushGroup: 1,
|
||||
},
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: 7,
|
||||
text: 'early chief action',
|
||||
legacyFlushGroup: -1,
|
||||
},
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
text: 'actor global history',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ordered.map((entry) => entry.text)).toEqual([
|
||||
'early chief action',
|
||||
'actor nation history',
|
||||
'actor global history',
|
||||
'destructor nation history',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeLogEntry', () => {
|
||||
it('uses an explicit draft year and month for pre-month logs', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||
MESSAGE_MAILBOX_PUBLIC,
|
||||
resolveMessageTargetIcon,
|
||||
sendMessage,
|
||||
type MessageDraft,
|
||||
type MessageRecordDraft,
|
||||
@@ -88,7 +89,7 @@ describe('sendMessage', () => {
|
||||
expect(store.records[0]!.draft.mailbox).toBe(MESSAGE_MAILBOX_PUBLIC);
|
||||
});
|
||||
|
||||
it('removes diplomacy action from sender copy', async () => {
|
||||
it('clears the entire actionable diplomacy option from the sender copy', async () => {
|
||||
const store = new InMemoryMessageStore();
|
||||
const draft = buildDraft({
|
||||
msgType: 'diplomacy',
|
||||
@@ -98,8 +99,38 @@ describe('sendMessage', () => {
|
||||
|
||||
await sendMessage(store, draft);
|
||||
|
||||
const senderPayload = store.records[1]!.draft.payload.option ?? {};
|
||||
expect(senderPayload).not.toHaveProperty('action');
|
||||
expect(senderPayload).toMatchObject({ payload: 1 });
|
||||
expect(store.records[1]!.draft.payload.option).toBeNull();
|
||||
});
|
||||
|
||||
it('can persist only the receiver copy like Ref Message::send(true)', async () => {
|
||||
const store = new InMemoryMessageStore();
|
||||
const draft = buildDraft({ sendDestOnly: true });
|
||||
|
||||
const result = await sendMessage(store, draft);
|
||||
|
||||
expect(result).toEqual({ receiverId: 1 });
|
||||
expect(store.records).toHaveLength(1);
|
||||
expect(store.records[0]!.draft.mailbox).toBe(draft.dest.generalId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMessageTargetIcon', () => {
|
||||
it('uses the product shared origin by default and accepts an explicit differential origin', () => {
|
||||
expect(resolveMessageTargetIcon()).toBe('https://sam-image.hided.net/icons/default.jpg');
|
||||
expect(resolveMessageTargetIcon(null, 'https://dev-sam-ref.hided.net/image/icons/')).toBe(
|
||||
'https://dev-sam-ref.hided.net/image/icons/default.jpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a non-default shared picture and legacy user-icon marker visible', () => {
|
||||
expect(
|
||||
resolveMessageTargetIcon(
|
||||
{ picture: '장수/관우.png', imageServer: 0 },
|
||||
'https://dev-sam-ref.hided.net/image/icons'
|
||||
)
|
||||
).toBe('https://dev-sam-ref.hided.net/image/icons/장수/관우.png');
|
||||
expect(resolveMessageTargetIcon({ picture: 'users/custom.webp', imageServer: 1 })).toBe(
|
||||
'd_pic/users/custom.webp'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ActionDefinition } from '../../src/actions/turn/nation/che_몰수.js';
|
||||
|
||||
describe('che_몰수 NPC message icon', () => {
|
||||
it('uses the target general non-default shared picture for both payload targets', () => {
|
||||
const actor = {
|
||||
id: 1,
|
||||
name: '집행자',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 12,
|
||||
role: { personality: null, specialDomestic: null, specialWar: null, items: {} },
|
||||
injury: 0,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
crew: 0,
|
||||
crewTypeId: 1100,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
};
|
||||
const target = {
|
||||
...actor,
|
||||
id: 3,
|
||||
name: '몰수NPC',
|
||||
gold: 100,
|
||||
npcState: 2,
|
||||
picture: 'npc/custom.png',
|
||||
imageServer: 0,
|
||||
};
|
||||
const definition = new ActionDefinition({
|
||||
npcSeizureMessageProb: 1,
|
||||
maxResourceActionAmount: 1_000_000,
|
||||
} as never);
|
||||
|
||||
const result = definition.resolve(
|
||||
{
|
||||
general: actor,
|
||||
nation: { id: 1, name: '아국', color: '#123456', gold: 0, rice: 0 },
|
||||
destGeneral: target,
|
||||
messageTime: new Date('0190-01-01T00:00:00.000Z'),
|
||||
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
|
||||
rng: {
|
||||
nextBool: () => true,
|
||||
nextInt: () => 0,
|
||||
},
|
||||
} as never,
|
||||
{ isGold: true, amount: 100, destGeneralID: target.id }
|
||||
);
|
||||
|
||||
expect(result.effects).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'message:add',
|
||||
draft: expect.objectContaining({
|
||||
src: expect.objectContaining({ icon: 'https://ref.example/image/icons/npc/custom.png' }),
|
||||
dest: expect.objectContaining({ icon: 'https://ref.example/image/icons/npc/custom.png' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ActionResolver } from '../../../src/actions/turn/general/che_등용.js';
|
||||
import { ActionResolver, actionContextBuilder } from '../../../src/actions/turn/general/che_등용.js';
|
||||
|
||||
describe('che_등용 recruitment message', () => {
|
||||
it('queues the Ref scout prompt with sender and receiver snapshots', () => {
|
||||
@@ -30,6 +30,23 @@ describe('che_등용 recruitment message', () => {
|
||||
const sourceNation = { id: 1, name: '위', color: '#ffffff' };
|
||||
const destinationNation = { id: 2, name: '촉', color: '#000000' };
|
||||
const messageTime = new Date('0200-01-01T00:10:00.000Z');
|
||||
const actorTurnTime = new Date('0200-01-01T00:00:00.000Z');
|
||||
|
||||
const builtContext = actionContextBuilder(
|
||||
{
|
||||
general: { ...general, turnTime: actorTurnTime },
|
||||
nation: sourceNation,
|
||||
rng: {},
|
||||
} as never,
|
||||
{
|
||||
gameNow: messageTime,
|
||||
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
|
||||
actionArgs: { destGeneralId: destination.id },
|
||||
worldRef: { getGeneralById: () => destination },
|
||||
scenarioConfig: { const: {} },
|
||||
} as never
|
||||
);
|
||||
expect(builtContext).toMatchObject({ messageTime });
|
||||
|
||||
const result = new ActionResolver().resolve(
|
||||
{
|
||||
@@ -54,7 +71,7 @@ describe('che_등용 recruitment message', () => {
|
||||
nationId: sourceNation.id,
|
||||
nationName: sourceNation.name,
|
||||
color: sourceNation.color,
|
||||
icon: '',
|
||||
icon: 'https://sam-image.hided.net/icons/default.jpg',
|
||||
},
|
||||
dest: {
|
||||
generalId: destination.id,
|
||||
@@ -62,13 +79,16 @@ describe('che_등용 recruitment message', () => {
|
||||
nationId: destinationNation.id,
|
||||
nationName: destinationNation.name,
|
||||
color: destinationNation.color,
|
||||
icon: '',
|
||||
icon: 'https://sam-image.hided.net/icons/default.jpg',
|
||||
},
|
||||
text: '위로 망명 권유 서신',
|
||||
time: messageTime,
|
||||
validUntil: new Date('9999-12-31T12:59:59.000Z'),
|
||||
option: { action: 'scout' },
|
||||
sendDestOnly: true,
|
||||
},
|
||||
});
|
||||
expect(result.effects.filter((effect) => effect.type === 'log')).toEqual([]);
|
||||
expect(logs).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,9 @@ import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
|
||||
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||
import { resolveGeneralAction } from '../../../src/actions/engine.js';
|
||||
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
|
||||
import { LogCategory, LogFormat, LogScope } from '../../../src/logging/types.js';
|
||||
|
||||
const MOCK_SCENARIO_BASE = {
|
||||
title: 'Test',
|
||||
@@ -224,6 +227,57 @@ describe('che_등용수락', () => {
|
||||
|
||||
const { commandSpec } = await import('../../../src/actions/turn/general/che_등용수락.js');
|
||||
|
||||
const orderingResolution = resolveGeneralAction(
|
||||
commandSpec.createDefinition(systemEnv),
|
||||
{
|
||||
general: structuredClone(neutralGen),
|
||||
city: structuredClone(world.getCity(neutralGen.cityId)),
|
||||
nation: null,
|
||||
destNation: structuredClone(nation2),
|
||||
destGeneral: structuredClone(recruiterGen),
|
||||
rng: {} as any,
|
||||
addLog: () => {},
|
||||
} as any,
|
||||
{
|
||||
now: new Date('2026-08-23T00:00:00.000Z'),
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
|
||||
},
|
||||
{ destNationId: 2, destGeneralId: 2 }
|
||||
);
|
||||
const recruiterLogs = orderLegacyActionLoggerFlush(orderingResolution.logs).filter(
|
||||
(log) => log.generalId === recruiterGen.id
|
||||
);
|
||||
expect(recruiterLogs.map((log) => [log.category, log.legacyFlushGroup])).toEqual([
|
||||
[LogCategory.HISTORY, 1],
|
||||
[LogCategory.ACTION, 1],
|
||||
[LogCategory.ACTION, 1],
|
||||
[LogCategory.ACTION, 1],
|
||||
]);
|
||||
expect(recruiterLogs.map((log) => log.text)).toEqual([
|
||||
expect.stringContaining('등용에 성공'),
|
||||
expect.stringContaining('레벨업'),
|
||||
expect.stringContaining('승급'),
|
||||
expect.stringContaining('등용에 성공했습니다.'),
|
||||
]);
|
||||
expect(recruiterLogs.map((log) => log.format)).toEqual([
|
||||
LogFormat.YEAR_MONTH,
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.PLAIN,
|
||||
LogFormat.MONTH,
|
||||
]);
|
||||
expect(
|
||||
orderingResolution.logs.find(
|
||||
(log) =>
|
||||
log.scope === LogScope.GENERAL &&
|
||||
log.category === LogCategory.ACTION &&
|
||||
log.text.includes('망명하여 수도로')
|
||||
)?.format
|
||||
).toBe(LogFormat.MONTH);
|
||||
expect(
|
||||
orderingResolution.logs.find((log) => log.scope === LogScope.SYSTEM && log.category === LogCategory.SUMMARY)
|
||||
?.format
|
||||
).toBe(LogFormat.MONTH);
|
||||
|
||||
await runner.runTurn([
|
||||
{
|
||||
generalId: neutralGen.id,
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { General, Nation } from '../../../src/domain/entities.js';
|
||||
import { buildScenarioBootstrap } from '../../../src/world/bootstrap.js';
|
||||
import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
|
||||
import type { MapDefinition } from '../../../src/world/types.js';
|
||||
import { resolveGeneralAction } from '../../../src/actions/engine.js';
|
||||
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
|
||||
|
||||
const MOCK_SCENARIO_BASE = {
|
||||
title: 'Test',
|
||||
@@ -56,6 +58,49 @@ const LINEAR_MAP: MapDefinition = {
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
};
|
||||
|
||||
const buildGeneral = (id: number, name: string): General => ({
|
||||
id,
|
||||
name,
|
||||
nationId: 1,
|
||||
cityId: 101,
|
||||
troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 10,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
});
|
||||
|
||||
const buildNation = (level = 1): Nation => ({
|
||||
id: 1,
|
||||
name: 'MyNation',
|
||||
color: '#000',
|
||||
capitalCityId: 101,
|
||||
chiefGeneralId: 1,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
level,
|
||||
typeCode: 'che_def',
|
||||
meta: {},
|
||||
});
|
||||
|
||||
describe('che_이동', () => {
|
||||
it('applies movement side effects (gold/atmos/exp/leadership_exp)', async () => {
|
||||
const bootstrapResult = buildScenarioBootstrap({
|
||||
@@ -66,47 +111,8 @@ describe('che_이동', () => {
|
||||
const world = new InMemoryWorld(bootstrapResult.snapshot);
|
||||
const runner = new TestGameRunner(world, 200, 1);
|
||||
|
||||
const general: General = {
|
||||
id: 1,
|
||||
name: 'Mover',
|
||||
nationId: 1,
|
||||
cityId: 101,
|
||||
troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 10,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
};
|
||||
const nation: Nation = {
|
||||
id: 1,
|
||||
name: 'MyNation',
|
||||
color: '#000',
|
||||
capitalCityId: 101,
|
||||
chiefGeneralId: 1,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
level: 1,
|
||||
typeCode: 'che_def',
|
||||
meta: {},
|
||||
};
|
||||
const general = buildGeneral(1, 'Mover');
|
||||
const nation = buildNation();
|
||||
world.snapshot.generals.push(general);
|
||||
world.snapshot.nations.push(nation);
|
||||
|
||||
@@ -134,4 +140,46 @@ describe('che_이동', () => {
|
||||
expect(updated?.experience).toBe(50);
|
||||
expect(updated?.meta.leadership_exp).toBe(1);
|
||||
});
|
||||
|
||||
it('logs roaming followers before the actor logger flush', async () => {
|
||||
const leader = { ...buildGeneral(1, 'Leader'), officerLevel: 12 };
|
||||
const follower = buildGeneral(2, 'Follower');
|
||||
const nation = buildNation(0);
|
||||
const moveDef = (await import('../../../src/actions/turn/general/che_이동.js')).commandSpec.createDefinition(
|
||||
{} as any
|
||||
);
|
||||
|
||||
const resolution = resolveGeneralAction(
|
||||
moveDef,
|
||||
{
|
||||
general: leader,
|
||||
nation,
|
||||
moveGenerals: [leader, follower],
|
||||
map: LINEAR_MAP,
|
||||
develCost: 100,
|
||||
rng: {} as any,
|
||||
addLog: () => {},
|
||||
} as any,
|
||||
{
|
||||
now: new Date('2026-08-23T00:00:00.000Z'),
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
|
||||
},
|
||||
{ destCityId: 102 }
|
||||
);
|
||||
|
||||
expect(resolution.patches?.generals).toContainEqual({
|
||||
id: follower.id,
|
||||
patch: expect.objectContaining({ cityId: 102 }),
|
||||
});
|
||||
const orderedLogs = orderLegacyActionLoggerFlush(resolution.logs);
|
||||
expect(orderedLogs.map((log) => log.generalId)).toEqual([follower.id, leader.id]);
|
||||
expect(orderedLogs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
text: '방랑군 세력이 <G><b>City2</b></>로 이동했습니다.',
|
||||
generalId: follower.id,
|
||||
legacyFlushGroup: -1,
|
||||
})
|
||||
);
|
||||
expect(orderedLogs[1]?.legacyFlushGroup).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ActionResolver } from '../../../src/actions/turn/general/che_하야.js';
|
||||
|
||||
describe('che_하야 Ref ordering', () => {
|
||||
it('resets belong before refreshing max_belong', () => {
|
||||
const general = {
|
||||
id: 1,
|
||||
name: '하야자',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
experience: 100,
|
||||
dedication: 100,
|
||||
officerLevel: 5,
|
||||
gold: 100,
|
||||
rice: 100,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
injury: 0,
|
||||
age: 30,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
role: { items: {} },
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { belong: 10 },
|
||||
};
|
||||
const nation = {
|
||||
id: 1,
|
||||
name: '소속국',
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
meta: { gennum: 1 },
|
||||
};
|
||||
|
||||
const result = new ActionResolver({ defaultNpcGold: 1_000, defaultNpcRice: 1_000 } as never).resolve(
|
||||
{
|
||||
general,
|
||||
nation,
|
||||
troopMembers: [],
|
||||
addLog: () => {},
|
||||
} as never,
|
||||
{}
|
||||
);
|
||||
const generalPatch = result.effects.find(
|
||||
(effect) => effect.type === 'general:patch' && effect.targetId === general.id
|
||||
);
|
||||
|
||||
expect(generalPatch).toMatchObject({
|
||||
type: 'general:patch',
|
||||
patch: { meta: { belong: 0, max_belong: 0 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,16 @@ import { MINIMAL_MAP } from '../../fixtures/minimalMap.js';
|
||||
import type { TurnCommandEnv, TurnCommandItemCatalogEntry } from '../../../src/actions/turn/commandEnv.js';
|
||||
import { commandSpec as rebellionSpec } from '../../../src/actions/turn/general/che_모반시도.js';
|
||||
import { commandSpec as abdicationSpec } from '../../../src/actions/turn/general/che_선양.js';
|
||||
import { commandSpec as uprisingSpec } from '../../../src/actions/turn/general/che_거병.js';
|
||||
import { commandSpec as giftSpec } from '../../../src/actions/turn/general/che_증여.js';
|
||||
import { commandSpec as disbandSpec } from '../../../src/actions/turn/general/che_해산.js';
|
||||
import { commandSpec as foundNationSpec } from '../../../src/actions/turn/general/cr_건국.js';
|
||||
import { commandSpec as tradeItemSpec } from '../../../src/actions/turn/general/che_장비매매.js';
|
||||
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
|
||||
import { resolveGeneralAction } from '../../../src/actions/engine.js';
|
||||
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
|
||||
import { LogCategory, LogFormat, LogScope } from '../../../src/logging/types.js';
|
||||
|
||||
const SYSTEM_ENV: TurnCommandEnv = {
|
||||
develCost: 100,
|
||||
@@ -168,6 +172,31 @@ const makeSnapshot = (params: {
|
||||
initialEvents: [],
|
||||
});
|
||||
|
||||
const resolveForLogOrdering = (
|
||||
resolver: any,
|
||||
general: General,
|
||||
city: City,
|
||||
nation: Nation | null,
|
||||
args: unknown,
|
||||
context: Record<string, unknown>
|
||||
) =>
|
||||
resolveGeneralAction(
|
||||
resolver,
|
||||
{
|
||||
general,
|
||||
city,
|
||||
nation,
|
||||
rng: {} as any,
|
||||
addLog: () => {},
|
||||
...context,
|
||||
} as any,
|
||||
{
|
||||
now: new Date('2026-08-23T00:00:00.000Z'),
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
|
||||
},
|
||||
args
|
||||
);
|
||||
|
||||
describe('migrated general commands', () => {
|
||||
it('che_모반시도: 군주를 찬탈한다', async () => {
|
||||
const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, experience: 1000 });
|
||||
@@ -319,6 +348,208 @@ describe('migrated general commands', () => {
|
||||
expect(world.getNation(1)!.meta.collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('별도 General logger를 actor applyDB 전후 순서로 flush한다', () => {
|
||||
const city = makeCity({ id: 1, nationId: 1 });
|
||||
const nation = makeNation({ id: 1, name: '위', chiefGeneralId: 1, capitalCityId: 1 });
|
||||
|
||||
const rebel = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '중신', officerLevel: 2 });
|
||||
const displacedLord = makeGeneral({
|
||||
id: 1,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
name: '군주',
|
||||
officerLevel: 12,
|
||||
experience: 1000,
|
||||
});
|
||||
const rebellionLogs = orderLegacyActionLoggerFlush(
|
||||
resolveForLogOrdering(
|
||||
rebellionSpec.createDefinition(SYSTEM_ENV),
|
||||
rebel,
|
||||
city,
|
||||
nation,
|
||||
{},
|
||||
{
|
||||
nationGenerals: [displacedLord, rebel],
|
||||
}
|
||||
).logs
|
||||
);
|
||||
expect(
|
||||
rebellionLogs
|
||||
.filter((log) => log.generalId === displacedLord.id)
|
||||
.map((log) => [log.category, log.legacyFlushGroup])
|
||||
).toEqual([
|
||||
[LogCategory.HISTORY, 1],
|
||||
[LogCategory.ACTION, 1],
|
||||
]);
|
||||
|
||||
const abdicator = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12 });
|
||||
const recipient = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '후계자' });
|
||||
const abdicationLogs = orderLegacyActionLoggerFlush(
|
||||
resolveForLogOrdering(
|
||||
abdicationSpec.createDefinition(SYSTEM_ENV),
|
||||
abdicator,
|
||||
city,
|
||||
nation,
|
||||
{ destGeneralID: recipient.id },
|
||||
{ destGeneral: recipient }
|
||||
).logs
|
||||
);
|
||||
expect(
|
||||
abdicationLogs
|
||||
.filter((log) => log.generalId === recipient.id)
|
||||
.map((log) => [log.category, log.legacyFlushGroup])
|
||||
).toEqual([
|
||||
[LogCategory.HISTORY, 1],
|
||||
[LogCategory.ACTION, 1],
|
||||
]);
|
||||
|
||||
const giver = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 });
|
||||
const receiver = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 });
|
||||
const giftLogs = orderLegacyActionLoggerFlush(
|
||||
resolveForLogOrdering(
|
||||
giftSpec.createDefinition(SYSTEM_ENV),
|
||||
giver,
|
||||
city,
|
||||
nation,
|
||||
{ isGold: true, amount: 500, destGeneralID: receiver.id },
|
||||
{ destGeneral: receiver }
|
||||
).logs
|
||||
);
|
||||
expect(giftLogs.map((log) => [log.generalId, log.legacyFlushGroup ?? 0])).toEqual([
|
||||
[giver.id, 0],
|
||||
[receiver.id, 1],
|
||||
]);
|
||||
|
||||
const disbandActor = makeGeneral({ id: 10, nationId: 1, cityId: 1, name: '방랑군주', officerLevel: 12 });
|
||||
const member2 = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '부하2' });
|
||||
const member3 = makeGeneral({ id: 3, nationId: 1, cityId: 1, name: '부하3' });
|
||||
const wanderingNation = makeNation({
|
||||
id: 1,
|
||||
name: '방랑군',
|
||||
chiefGeneralId: disbandActor.id,
|
||||
capitalCityId: 1,
|
||||
level: 0,
|
||||
typeCode: 'None',
|
||||
});
|
||||
const disbandLogs = orderLegacyActionLoggerFlush(
|
||||
resolveForLogOrdering(
|
||||
disbandSpec.createDefinition(SYSTEM_ENV),
|
||||
disbandActor,
|
||||
city,
|
||||
wanderingNation,
|
||||
{},
|
||||
{
|
||||
nationGenerals: [disbandActor, member3, member2],
|
||||
nationCities: [city],
|
||||
currentYearMonth: 201 * 12 + 2 - 1,
|
||||
initYearMonth: 201 * 12 + 1 - 1,
|
||||
}
|
||||
).logs
|
||||
);
|
||||
const nonActorDestructionLogs = disbandLogs.filter(
|
||||
(log) =>
|
||||
log.scope === LogScope.GENERAL && log.generalId !== disbandActor.id && log.text.includes('<R>멸망</>')
|
||||
);
|
||||
expect(nonActorDestructionLogs.map((log) => [log.generalId, log.category, log.legacyFlushGroup])).toEqual([
|
||||
[member2.id, LogCategory.HISTORY, -2],
|
||||
[member2.id, LogCategory.ACTION, -2],
|
||||
[member3.id, LogCategory.HISTORY, -1],
|
||||
[member3.id, LogCategory.ACTION, -1],
|
||||
]);
|
||||
const actorLogs = disbandLogs.filter(
|
||||
(log) => log.scope === LogScope.GENERAL && log.generalId === disbandActor.id
|
||||
);
|
||||
expect(actorLogs).toHaveLength(4);
|
||||
expect(actorLogs.every((log) => (log.legacyFlushGroup ?? 0) === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('선양·모반시도의 Ref logger format과 거병 중립 nation flush 계약을 보존한다', () => {
|
||||
const city = makeCity({ id: 1, nationId: 1 });
|
||||
const nation = makeNation({ id: 1, name: '위', chiefGeneralId: 1, capitalCityId: 1 });
|
||||
const projectRoute = (logs: ReturnType<typeof orderLegacyActionLoggerFlush>) =>
|
||||
logs.map((log) => [
|
||||
log.scope,
|
||||
log.category,
|
||||
log.generalId ?? null,
|
||||
log.nationId ?? null,
|
||||
log.format,
|
||||
log.legacyFlushGroup ?? 0,
|
||||
]);
|
||||
|
||||
const rebel = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '중신', officerLevel: 2 });
|
||||
const displacedLord = makeGeneral({
|
||||
id: 1,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
name: '군주',
|
||||
officerLevel: 12,
|
||||
experience: 1_000,
|
||||
});
|
||||
const rebellionLogs = orderLegacyActionLoggerFlush(
|
||||
resolveForLogOrdering(
|
||||
rebellionSpec.createDefinition(SYSTEM_ENV),
|
||||
rebel,
|
||||
city,
|
||||
nation,
|
||||
{},
|
||||
{ nationGenerals: [displacedLord, rebel] }
|
||||
).logs
|
||||
);
|
||||
expect(projectRoute(rebellionLogs)).toEqual([
|
||||
[LogScope.GENERAL, LogCategory.HISTORY, rebel.id, null, LogFormat.YEAR_MONTH, 0],
|
||||
[LogScope.GENERAL, LogCategory.ACTION, rebel.id, null, LogFormat.MONTH, 0],
|
||||
[LogScope.NATION, LogCategory.HISTORY, null, nation.id, LogFormat.YEAR_MONTH, 0],
|
||||
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0],
|
||||
[LogScope.GENERAL, LogCategory.HISTORY, displacedLord.id, null, LogFormat.YEAR_MONTH, 1],
|
||||
[LogScope.GENERAL, LogCategory.ACTION, displacedLord.id, null, LogFormat.MONTH, 1],
|
||||
]);
|
||||
|
||||
const abdicator = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12 });
|
||||
const recipient = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '후계자' });
|
||||
const abdicationLogs = orderLegacyActionLoggerFlush(
|
||||
resolveForLogOrdering(
|
||||
abdicationSpec.createDefinition(SYSTEM_ENV),
|
||||
abdicator,
|
||||
city,
|
||||
nation,
|
||||
{ destGeneralID: recipient.id },
|
||||
{ destGeneral: recipient }
|
||||
).logs
|
||||
);
|
||||
expect(projectRoute(abdicationLogs)).toEqual([
|
||||
[LogScope.GENERAL, LogCategory.HISTORY, abdicator.id, null, LogFormat.YEAR_MONTH, 0],
|
||||
[LogScope.GENERAL, LogCategory.ACTION, abdicator.id, null, LogFormat.MONTH, 0],
|
||||
[LogScope.NATION, LogCategory.HISTORY, null, nation.id, LogFormat.YEAR_MONTH, 0],
|
||||
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0],
|
||||
[LogScope.GENERAL, LogCategory.HISTORY, recipient.id, null, LogFormat.YEAR_MONTH, 1],
|
||||
[LogScope.GENERAL, LogCategory.ACTION, recipient.id, null, LogFormat.MONTH, 1],
|
||||
]);
|
||||
|
||||
const founder = makeGeneral({ id: 7, nationId: 0, cityId: 1, name: '운영자' });
|
||||
const uprisingLogs = orderLegacyActionLoggerFlush(
|
||||
resolveForLogOrdering(
|
||||
uprisingSpec.createDefinition(SYSTEM_ENV),
|
||||
founder,
|
||||
city,
|
||||
null,
|
||||
{},
|
||||
{
|
||||
createNationId: () => 2,
|
||||
listNations: () => [],
|
||||
scenarioId: 1,
|
||||
baseRice: 1_000,
|
||||
}
|
||||
).logs
|
||||
);
|
||||
expect(projectRoute(uprisingLogs)).toEqual([
|
||||
[LogScope.GENERAL, LogCategory.HISTORY, founder.id, null, LogFormat.YEAR_MONTH, 0],
|
||||
[LogScope.GENERAL, LogCategory.ACTION, founder.id, null, LogFormat.MONTH, 0],
|
||||
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0],
|
||||
[LogScope.SYSTEM, LogCategory.SUMMARY, null, null, LogFormat.MONTH, 0],
|
||||
]);
|
||||
expect(uprisingLogs.some((log) => log.scope === LogScope.NATION)).toBe(false);
|
||||
});
|
||||
|
||||
it('cr_건국: 국가 정보를 건국 상태로 갱신한다', async () => {
|
||||
const lord = makeGeneral({
|
||||
id: 1,
|
||||
|
||||
@@ -390,7 +390,9 @@ describe('General Commands New Scenario', () => {
|
||||
|
||||
const g1_after_resign = world.getGeneral(1)!;
|
||||
expect(g1_after_resign.nationId).toBe(0);
|
||||
expect(g1_after_resign.meta.max_belong).toBe(18);
|
||||
// Ref clears belong before refreshing max_belong, so the existing
|
||||
// historical maximum is retained instead of the pre-resign belong.
|
||||
expect(g1_after_resign.meta.max_belong).toBe(12);
|
||||
|
||||
// 6. Retire (Needs age >= 60)
|
||||
// Manually set age
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
parseTurnCommandProfile,
|
||||
resolveScenarioTurnCommandProfile,
|
||||
type TurnCommandProfile,
|
||||
} from '../src/actions/turn/commandProfile.js';
|
||||
|
||||
const fallback: TurnCommandProfile = {
|
||||
general: ['휴식', 'che_훈련'],
|
||||
nation: ['휴식', 'che_발령'],
|
||||
};
|
||||
|
||||
describe('scenario turn command profile', () => {
|
||||
it('fails closed for malformed, duplicate, or rest-less base profiles', () => {
|
||||
expect(() => parseTurnCommandProfile({ general: ['휴식'], nation: '휴식' })).toThrow(
|
||||
'Invalid turn command profile'
|
||||
);
|
||||
expect(() => parseTurnCommandProfile({ general: ['휴식', '휴식'], nation: ['휴식'] })).toThrow(
|
||||
'Duplicate general command key'
|
||||
);
|
||||
expect(() => parseTurnCommandProfile({ general: ['che_훈련'], nation: ['휴식'] })).toThrow('must include 휴식');
|
||||
});
|
||||
|
||||
it('keeps the base profile when the scenario does not override command groups', () => {
|
||||
expect(resolveScenarioTurnCommandProfile({}, fallback)).toEqual({
|
||||
profile: fallback,
|
||||
generalGroups: null,
|
||||
nationGroups: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('flattens Ref command groups while preserving category and command order', () => {
|
||||
const result = resolveScenarioTurnCommandProfile(
|
||||
{
|
||||
availableGeneralCommand: {
|
||||
개인: ['휴식'],
|
||||
군사: ['cr_맹훈련', 'che_훈련'],
|
||||
},
|
||||
availableChiefCommand: {
|
||||
휴식: ['휴식'],
|
||||
연구: ['event_대검병연구'],
|
||||
},
|
||||
},
|
||||
fallback
|
||||
);
|
||||
|
||||
expect(result.profile).toEqual({
|
||||
general: ['휴식', 'cr_맹훈련', 'che_훈련'],
|
||||
nation: ['휴식', 'event_대검병연구'],
|
||||
});
|
||||
expect(result.generalGroups).toEqual([
|
||||
{ category: '개인', commands: ['휴식'] },
|
||||
{ category: '군사', commands: ['cr_맹훈련', 'che_훈련'] },
|
||||
]);
|
||||
expect(result.nationGroups).toEqual([
|
||||
{ category: '휴식', commands: ['휴식'] },
|
||||
{ category: '연구', commands: ['event_대검병연구'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails closed for unknown, duplicate, or fallback-less scenario commands', () => {
|
||||
expect(() => resolveScenarioTurnCommandProfile('invalid', fallback)).toThrow(
|
||||
'Scenario const must be an object'
|
||||
);
|
||||
expect(() =>
|
||||
resolveScenarioTurnCommandProfile(
|
||||
{ availableGeneralCommand: { 개인: ['휴식', 'unknown-command'] } },
|
||||
fallback
|
||||
)
|
||||
).toThrow('Unknown scenario general command key');
|
||||
expect(() =>
|
||||
resolveScenarioTurnCommandProfile({ availableChiefCommand: { 휴식: ['휴식'], 기타: ['휴식'] } }, fallback)
|
||||
).toThrow('Duplicate scenario nation command key');
|
||||
expect(() =>
|
||||
resolveScenarioTurnCommandProfile({ availableGeneralCommand: { 군사: ['che_훈련'] } }, fallback)
|
||||
).toThrow('must include 휴식');
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,12 @@ import type { UnitSetDefinition } from '../src/world/types.js';
|
||||
import { resolveWarAftermath } from '../src/war/aftermath.js';
|
||||
import type { WarAftermathConfig } from '../src/war/types.js';
|
||||
import { LogFormat } from '../src/logging/types.js';
|
||||
import { LegacyWarLogFlushSequence } from '../src/war/legacyFlushSequence.js';
|
||||
import { buildWarAftermathConfig, buildWarConfig } from '../src/actions/turn/actionContextHelpers.js';
|
||||
import type { ScenarioConfig } from '../src/scenario/types.js';
|
||||
|
||||
const MESSAGE_TIME = new Date('0185-01-01T00:00:00.000Z');
|
||||
|
||||
const buildUnitSet = (): UnitSetDefinition => ({
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
@@ -188,6 +191,7 @@ describe('war aftermath', () => {
|
||||
unitSet: buildUnitSet(),
|
||||
config: { ...buildConfig(), maxTechLevel: 15 },
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
messageTime: MESSAGE_TIME,
|
||||
});
|
||||
|
||||
expect(defenderNation.rice).toBe(985);
|
||||
@@ -232,6 +236,7 @@ describe('war aftermath', () => {
|
||||
month: 1,
|
||||
startYear: 180,
|
||||
},
|
||||
messageTime: MESSAGE_TIME,
|
||||
});
|
||||
|
||||
expect(attackerNation.meta.tech).toBe(Math.fround(1000.6));
|
||||
@@ -271,6 +276,7 @@ describe('war aftermath', () => {
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
messageTime: MESSAGE_TIME,
|
||||
});
|
||||
|
||||
expect(attackerCity.meta.dead).toBe(71);
|
||||
@@ -320,6 +326,7 @@ describe('war aftermath', () => {
|
||||
month: 1,
|
||||
startYear: 180,
|
||||
},
|
||||
messageTime: MESSAGE_TIME,
|
||||
});
|
||||
|
||||
expect(defenderNation.capitalCityId).toBe(nextCapital.id);
|
||||
@@ -330,6 +337,7 @@ describe('war aftermath', () => {
|
||||
'수뇌는 <G><b>City3</b></>으로 집합되었습니다.',
|
||||
])
|
||||
);
|
||||
expect(outcome.logs.find((log) => log.text.startsWith('수뇌는'))?.format).toBe(LogFormat.MONTH);
|
||||
});
|
||||
|
||||
it('uses the city battle phase, not retained casualties, for conquered supply-city rice', () => {
|
||||
@@ -372,6 +380,7 @@ describe('war aftermath', () => {
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
messageTime: MESSAGE_TIME,
|
||||
});
|
||||
|
||||
expect(defenderNation.rice).toBe(6500);
|
||||
@@ -389,6 +398,11 @@ describe('war aftermath', () => {
|
||||
const attacker = buildGeneral(1, 1, 1);
|
||||
attacker.officerLevel = 12;
|
||||
const defender = buildGeneral(2, 2, 2);
|
||||
defender.officerLevel = 12;
|
||||
defender.experience = 2_000;
|
||||
defender.dedication = 2_000;
|
||||
defender.meta.explevel = 14;
|
||||
defender.meta.dedlevel = 3;
|
||||
|
||||
const outcome = resolveWarAftermath({
|
||||
battle: {
|
||||
@@ -430,14 +444,16 @@ describe('war aftermath', () => {
|
||||
month: 1,
|
||||
startYear: 180,
|
||||
},
|
||||
messageTime: MESSAGE_TIME,
|
||||
rng,
|
||||
legacyFlushSequence: new LegacyWarLogFlushSequence(100),
|
||||
});
|
||||
|
||||
expect(outcome.conquest?.nationCollapsed).toBe(true);
|
||||
expect(attackerNation.gold).toBe(3600);
|
||||
expect(attackerNation.rice).toBe(4600);
|
||||
expect(defender.experience).toBe(90);
|
||||
expect(defender.dedication).toBe(50);
|
||||
expect(defender.experience).toBe(1_800);
|
||||
expect(defender.dedication).toBe(1_000);
|
||||
// Removed nations can remain in old persisted conflict data. They
|
||||
// must never receive ownership during a later conquest.
|
||||
expect(defenderCity.nationId).toBe(attackerNation.id);
|
||||
@@ -447,9 +463,14 @@ describe('war aftermath', () => {
|
||||
'<D><b>Nation2</b></>를 정복',
|
||||
'<R><b>【멸망】</b></><D><b>Nation2</b></>는 <R>멸망</>했습니다.',
|
||||
'<D><b>Nation2</b></>가 <R>멸망</>했습니다.',
|
||||
'<C>Lv 13</>으로 <R>레벨다운</>!',
|
||||
'<Y>27품관</>으로 <C>승급</>하여 봉록이 <C>1,200</>으로 <C>상승</>했습니다!',
|
||||
'<D><b>Nation2</b></> 정복으로 금<C>2,600</> 쌀<C>3,600</>을 획득했습니다.',
|
||||
])
|
||||
);
|
||||
expect(outcome.logs.filter((log) => log.generalId === defender.id).map((log) => log.legacyFlushGroup)).toEqual([
|
||||
103, 103, 103, 103, 103,
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches ruined-nation lord ordering and NPC appointment draws', () => {
|
||||
@@ -464,7 +485,7 @@ describe('war aftermath', () => {
|
||||
npc.npcState = 2;
|
||||
|
||||
const rangeDraws = [0.2, 0.21, 0.4, 0.41];
|
||||
const nextBool = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||
const nextBool = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||
const rng = {
|
||||
nextRange: vi.fn(() => rangeDraws.shift()!),
|
||||
nextBool,
|
||||
@@ -495,6 +516,8 @@ describe('war aftermath', () => {
|
||||
joinRuinedNpcProbability: 0.1,
|
||||
},
|
||||
time: { year: 186, month: 1, startYear: 179 },
|
||||
messageTime: MESSAGE_TIME,
|
||||
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
|
||||
rng,
|
||||
});
|
||||
|
||||
@@ -503,6 +526,32 @@ describe('war aftermath', () => {
|
||||
expect(lord.gold).toBe(600);
|
||||
expect(lord.rice).toBe(590);
|
||||
expect(nextBool.mock.calls.map(([probability]) => probability)).toEqual([0.5, 0.1, 0.5]);
|
||||
expect(outcome.conquest?.messages).toEqual([
|
||||
{
|
||||
msgType: 'private',
|
||||
src: {
|
||||
generalId: attacker.id,
|
||||
generalName: attacker.name,
|
||||
nationId: attackerNation.id,
|
||||
nationName: attackerNation.name,
|
||||
color: attackerNation.color,
|
||||
icon: 'https://ref.example/image/icons/default.jpg',
|
||||
},
|
||||
dest: {
|
||||
generalId: npc.id,
|
||||
generalName: npc.name,
|
||||
nationId: 0,
|
||||
nationName: '재야',
|
||||
color: '#000000',
|
||||
icon: 'https://ref.example/image/icons/default.jpg',
|
||||
},
|
||||
text: 'Nation1로 망명 권유 서신',
|
||||
time: MESSAGE_TIME,
|
||||
validUntil: new Date('9999-12-31T12:59:59.000Z'),
|
||||
option: { action: 'scout' },
|
||||
sendDestOnly: true,
|
||||
},
|
||||
]);
|
||||
expect(outcome.conquest?.ruinedNpcJoinPlans).toEqual([
|
||||
{ generalId: npc.id, destNationId: attackerNation.id, joinTurn: 6 },
|
||||
]);
|
||||
@@ -523,10 +572,12 @@ describe('war aftermath', () => {
|
||||
const attackerCity = buildCity(1, 1);
|
||||
const defenderCity = buildCity(2, 2);
|
||||
const attacker = buildGeneral(1, 1, 1);
|
||||
attacker.officerLevel = 12;
|
||||
const firstDefender = buildGeneral(2, 2, 2);
|
||||
const secondDefender = buildGeneral(3, 2, 2);
|
||||
secondDefender.crew = 0;
|
||||
const elsewhere = buildGeneral(4, 2, 3);
|
||||
elsewhere.officerLevel = 12;
|
||||
const dispatchOrder: number[] = [];
|
||||
const module: GeneralActionModule = {
|
||||
eventHandlers: {
|
||||
@@ -562,8 +613,10 @@ describe('war aftermath', () => {
|
||||
month: 1,
|
||||
startYear: 180,
|
||||
},
|
||||
messageTime: MESSAGE_TIME,
|
||||
rng,
|
||||
generalActionModules: [module],
|
||||
legacyFlushSequence: new LegacyWarLogFlushSequence(200),
|
||||
});
|
||||
|
||||
expect(dispatchOrder).toEqual([firstDefender.id, secondDefender.id]);
|
||||
@@ -585,6 +638,27 @@ describe('war aftermath', () => {
|
||||
LogFormat.MONTH,
|
||||
LogFormat.MONTH,
|
||||
]);
|
||||
expect(
|
||||
outcome.logs.filter((log) => log.text.startsWith('점령 이벤트')).map((log) => log.legacyFlushGroup)
|
||||
).toEqual([200, 201]);
|
||||
expect(
|
||||
outcome.logs.find((log) => log.nationId === defenderNation.id && log.text.includes('<O>함락</>'))
|
||||
?.legacyFlushGroup
|
||||
).toBe(202);
|
||||
expect(
|
||||
outcome.logs.find((log) => log.text.includes(`Nation${defenderNation.id}</b></>를 정복`))?.legacyFlushGroup
|
||||
).toBe(203);
|
||||
expect(
|
||||
outcome.logs
|
||||
.filter((log) => log.text.includes('도주하며'))
|
||||
.map((log) => [log.generalId, log.legacyFlushGroup])
|
||||
).toEqual([
|
||||
[firstDefender.id, 204],
|
||||
[secondDefender.id, 205],
|
||||
[elsewhere.id, 206],
|
||||
]);
|
||||
expect(outcome.logs.find((log) => log.text.includes('【멸망】'))?.legacyFlushGroup).toBe(206);
|
||||
expect(outcome.logs.find((log) => log.text.includes('정복으로 금'))?.legacyFlushGroup).toBeUndefined();
|
||||
expect(outcome.generals.map((general) => general.id)).toEqual(
|
||||
expect.arrayContaining([firstDefender.id, secondDefender.id])
|
||||
);
|
||||
@@ -626,11 +700,21 @@ describe('war aftermath', () => {
|
||||
month: 1,
|
||||
startYear: 180,
|
||||
},
|
||||
messageTime: MESSAGE_TIME,
|
||||
legacyFlushSequence: new LegacyWarLogFlushSequence(300),
|
||||
});
|
||||
|
||||
expect(outcome.conquest?.conquerNationId).toBe(4);
|
||||
expect(defenderCity.nationId).toBe(4);
|
||||
expect(defenderCity.meta.conflict_order).toEqual([]);
|
||||
expect(attacker.cityId).toBe(1);
|
||||
expect(outcome.logs.find((log) => log.nationId === defenderNation.id)?.legacyFlushGroup).toBe(300);
|
||||
expect(outcome.logs.find((log) => log.nationId === firstNation.id)?.legacyFlushGroup).toBe(301);
|
||||
expect(
|
||||
outcome.logs
|
||||
.filter((log) => log.generalId === attacker.id || log.scope === 'SYSTEM')
|
||||
.filter((log) => log.text.includes('영토분쟁') || log.text.includes('점령'))
|
||||
.every((log) => log.legacyFlushGroup === undefined)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { UnitSetDefinition } from '../src/world/types.js';
|
||||
import { ActionLogger } from '../src/logging/actionLogger.js';
|
||||
import { WarActionPipeline } from '../src/war/actions.js';
|
||||
import { resolveWarBattle } from '../src/war/engine.js';
|
||||
import { LegacyWarLogFlushSequence } from '../src/war/legacyFlushSequence.js';
|
||||
import type { WarEngineConfig } from '../src/war/types.js';
|
||||
import { WarCrewType } from '../src/war/crewType.js';
|
||||
import { loadWarTriggerModules } from '../src/war/triggers/index.js';
|
||||
@@ -535,6 +536,64 @@ describe('war triggers', () => {
|
||||
});
|
||||
|
||||
describe('resolveWarBattle', () => {
|
||||
it('flushes each fought defender before the attacker with a unique legacy epoch', () => {
|
||||
const attackerNation = buildNation();
|
||||
const defenderNation = { ...buildNation(), id: 2, name: 'DefenderNation', capitalCityId: 2 };
|
||||
const attackerCity = buildCity();
|
||||
const defenderCity = {
|
||||
...buildCity(),
|
||||
id: 2,
|
||||
name: 'DefenderCity',
|
||||
nationId: defenderNation.id,
|
||||
defence: 0,
|
||||
wall: 0,
|
||||
};
|
||||
const attacker = { ...buildGeneral(100), crew: 10_000, rice: 100_000 };
|
||||
const firstDefender = {
|
||||
...buildGeneral(10),
|
||||
id: 2,
|
||||
name: 'FirstDefender',
|
||||
nationId: defenderNation.id,
|
||||
cityId: defenderCity.id,
|
||||
crew: 1,
|
||||
};
|
||||
const secondDefender = {
|
||||
...buildGeneral(10),
|
||||
id: 3,
|
||||
name: 'SecondDefender',
|
||||
nationId: defenderNation.id,
|
||||
cityId: defenderCity.id,
|
||||
crew: 1,
|
||||
};
|
||||
const outcome = resolveWarBattle({
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
attacker: { general: attacker, city: attackerCity, nation: attackerNation },
|
||||
defenders: [
|
||||
{ general: firstDefender, city: defenderCity, nation: defenderNation },
|
||||
{ general: secondDefender, city: defenderCity, nation: defenderNation },
|
||||
],
|
||||
defenderCity,
|
||||
defenderNation,
|
||||
legacyFlushSequence: new LegacyWarLogFlushSequence(100),
|
||||
});
|
||||
|
||||
const groupsFor = (generalId: number): number[] => [
|
||||
...new Set(
|
||||
outcome.logs
|
||||
.filter((log) => log.generalId === generalId)
|
||||
.map((log) => log.legacyFlushGroup)
|
||||
.filter((group): group is number => group !== undefined)
|
||||
),
|
||||
];
|
||||
expect(groupsFor(firstDefender.id)).toEqual([100]);
|
||||
expect(groupsFor(secondDefender.id)).toEqual([101]);
|
||||
// group 102 is the city applyDB/rollback epoch. Ref then flushes the attacker.
|
||||
expect(groupsFor(attacker.id)).toEqual([103]);
|
||||
});
|
||||
|
||||
it('persists multi-use battle item charges and removes the last charge', async () => {
|
||||
const general = buildGeneral(100);
|
||||
equipNewItem(general, 'item', 'event_충차', { charges: 2 });
|
||||
|
||||
Reference in New Issue
Block a user