fix: 유산 전투 특기 고정과 내역 스크롤 수정
초기화 시 전투 특기를 null로 정규화하고 이전 특기 배열을 보존해 다음 월 고정 배정이 daemon 재시작 없이 동작하게 한다. 다중 변경 내역이 문서 높이를 늘리도록 하고 API, 월간 persistence, 실제 Chromium 회귀 검증을 추가한다.
This commit is contained in:
@@ -257,7 +257,7 @@ const zPatchGeneral = z.object({
|
||||
intelligence: zFiniteNumber.optional(),
|
||||
})
|
||||
.optional(),
|
||||
specialWar: z.string().optional(),
|
||||
specialWar: z.string().nullable().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -734,10 +734,10 @@ async function handlePatchGeneral(
|
||||
...command.patch.stats,
|
||||
};
|
||||
}
|
||||
if (typeof command.patch.specialWar === 'string') {
|
||||
if (command.patch.specialWar !== undefined) {
|
||||
patch.role = {
|
||||
...general.role,
|
||||
specialWar: command.patch.specialWar,
|
||||
specialWar: command.patch.specialWar === 'None' ? null : command.patch.specialWar,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic';
|
||||
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import {
|
||||
createAddGlobalBetrayHandler,
|
||||
createAssignGeneralSpecialityHandler,
|
||||
} from '../src/turn/monthlySpecialityBetrayAction.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const event: TurnEvent = {
|
||||
@@ -177,6 +180,84 @@ describe('monthly speciality and betrayal actions', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['고정 후 초기화', ['reserve', 'reset']],
|
||||
['초기화 후 고정', ['reset', 'reserve']],
|
||||
] as const)('%s 순서에서도 다음 월에 지정한 전투 특기를 지급한다', async (_label, steps) => {
|
||||
const world = buildWorld();
|
||||
const initial = world.getGeneralById(3)!;
|
||||
const initialMeta = { ...initial.meta };
|
||||
delete initialMeta.inheritSpecificSpecialWar;
|
||||
world.updateGeneral(3, {
|
||||
role: { ...initial.role, specialWar: 'che_신산' },
|
||||
meta: initialMeta,
|
||||
});
|
||||
world.acknowledgeDirtyState(world.peekDirtyState());
|
||||
|
||||
const commandHandler = createTurnDaemonCommandHandler({ world });
|
||||
let requestIndex = 0;
|
||||
const dispatchPatch = async (patch: Extract<TurnDaemonCommand, { type: 'patchGeneral' }>['patch']) => {
|
||||
requestIndex += 1;
|
||||
const command = normalizeTurnDaemonCommand({
|
||||
requestId: `inherit-war-trait-${requestIndex}`,
|
||||
sentAt: '2026-08-21T00:00:00.000Z',
|
||||
command: { type: 'patchGeneral', generalId: 3, patch },
|
||||
});
|
||||
expect(command).not.toBeNull();
|
||||
await expect(commandHandler.handle(command!)).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
|
||||
};
|
||||
|
||||
for (const step of steps) {
|
||||
const current = world.getGeneralById(3)!;
|
||||
if (step === 'reserve') {
|
||||
await dispatchPatch({
|
||||
meta: { ...current.meta, inheritSpecificSpecialWar: 'che_의술' },
|
||||
});
|
||||
} else {
|
||||
await dispatchPatch({
|
||||
specialWar: null,
|
||||
meta: {
|
||||
...current.meta,
|
||||
inheritResetSpecialWar: 0,
|
||||
prev_types_special2: ['che_신산'],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(world.getGeneralById(3)?.role.specialWar).toBeNull();
|
||||
expect(world.getGeneralById(3)?.meta).toMatchObject({
|
||||
inheritSpecificSpecialWar: 'che_의술',
|
||||
prev_types_special2: ['che_신산'],
|
||||
});
|
||||
|
||||
await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], environment, event);
|
||||
|
||||
expect(world.getGeneralById(3)?.role.specialWar).toBe('che_의술');
|
||||
expect(world.getGeneralById(3)?.meta).not.toHaveProperty('inheritSpecificSpecialWar');
|
||||
expect(world.getGeneralById(3)?.meta.prev_types_special2).toEqual(['che_신산']);
|
||||
expect(
|
||||
world
|
||||
.peekDirtyState()
|
||||
.logs.filter((log) => log.generalId === 3)
|
||||
.map((log) => log.text)
|
||||
).toEqual(['특기 【<b><C>의술</></b>】을 습득', '특기 【<b><L>의술</></b>】을 익혔습니다!']);
|
||||
});
|
||||
|
||||
it('normalizes the legacy None sentinel before monthly eligibility checks', async () => {
|
||||
const world = buildWorld();
|
||||
const target = world.getGeneralById(3)!;
|
||||
world.updateGeneral(3, { role: { ...target.role, specialWar: 'che_신산' } });
|
||||
world.acknowledgeDirtyState(world.peekDirtyState());
|
||||
const commandHandler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(
|
||||
commandHandler.handle({ type: 'patchGeneral', generalId: 3, patch: { specialWar: 'None' } })
|
||||
).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
|
||||
|
||||
expect(world.getGeneralById(3)?.role.specialWar).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing before the three-year opening period ends', async () => {
|
||||
const world = buildWorld();
|
||||
await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], { ...environment, year: 192 }, event);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createAddGlobalBetrayHandler,
|
||||
createAssignGeneralSpecialityHandler,
|
||||
} from '../src/turn/monthlySpecialityBetrayAction.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
@@ -105,7 +106,7 @@ integration('monthly speciality and betrayal persistence', () => {
|
||||
}),
|
||||
buildGeneral(generalIds[1], {
|
||||
domestic: 'che_경작',
|
||||
war: null,
|
||||
war: 'che_신산',
|
||||
meta: {
|
||||
specage: 99,
|
||||
specage2: 30,
|
||||
@@ -198,6 +199,22 @@ integration('monthly speciality and betrayal persistence', () => {
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
|
||||
try {
|
||||
const reservedGeneral = world.getGeneralById(generalIds[1])!;
|
||||
const commandHandler = createTurnDaemonCommandHandler({ world });
|
||||
await expect(
|
||||
commandHandler.handle({
|
||||
type: 'patchGeneral',
|
||||
generalId: reservedGeneral.id,
|
||||
patch: {
|
||||
specialWar: null,
|
||||
meta: {
|
||||
...reservedGeneral.meta,
|
||||
inheritResetSpecialWar: 0,
|
||||
prev_types_special2: ['che_신산'],
|
||||
},
|
||||
},
|
||||
})
|
||||
).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
|
||||
await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z'));
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||
@@ -214,7 +231,12 @@ integration('monthly speciality and betrayal persistence', () => {
|
||||
expect(rows[0]?.specialCode).not.toBe('None');
|
||||
expect(rows[0]?.meta).toMatchObject({ betray: 2 });
|
||||
expect(rows[1]).toMatchObject({ special2Code: 'che_의술' });
|
||||
expect(rows[1]?.meta).toMatchObject({ betray: 3, marker: 2 });
|
||||
expect(rows[1]?.meta).toMatchObject({
|
||||
betray: 3,
|
||||
marker: 2,
|
||||
inheritResetSpecialWar: 0,
|
||||
prev_types_special2: ['che_신산'],
|
||||
});
|
||||
expect(rows[1]?.meta).not.toHaveProperty('inheritSpecificSpecialWar');
|
||||
expect(await db.logEntry.count({ where: { generalId: { in: [...generalIds] } } })).toBe(4);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user