fix: 유산 전투 특기 고정과 내역 스크롤 수정

초기화 시 전투 특기를 null로 정규화하고 이전 특기 배열을 보존해 다음 월 고정 배정이 daemon 재시작 없이 동작하게 한다. 다중 변경 내역이 문서 높이를 늘리도록 하고 API, 월간 persistence, 실제 Chromium 회귀 검증을 추가한다.
This commit is contained in:
2026-08-21 04:45:38 +00:00
parent 8e616ed07b
commit a403652761
10 changed files with 273 additions and 19 deletions
+9 -5
View File
@@ -72,6 +72,11 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
const readStringList = (raw: unknown): string[] => {
const parsed = typeof raw === 'string' ? parseJson<unknown>(raw) : raw;
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
};
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
@@ -133,7 +138,7 @@ const patchGeneral = async (
strength?: number;
intelligence?: number;
};
specialWar?: string;
specialWar?: string | null;
}
): Promise<void> => {
const result = await ctx.turnDaemon.requestCommand({
@@ -530,16 +535,15 @@ export const inheritRouter = router({
}
const meta = asRecord(general.meta);
const prevList =
parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
const prevList = readStringList(meta.prev_types_special2);
prevList.push(general.special2Code);
await patchGeneral(ctx, general.id, {
specialWar: 'None',
specialWar: null,
meta: {
...meta,
inheritResetSpecialWar: nextLevel,
prev_types_special2: JSON.stringify(prevList),
prev_types_special2: prevList,
},
});
+84
View File
@@ -331,6 +331,90 @@ describe('inherit router actor and permission boundaries', () => {
);
});
it('reserves the selected Ref war trait and charges the authenticated owner once', async () => {
const fixture = buildContext({
inheritancePoint: 5_000,
configConst: { availableSpecialWar: ['che_의술'] },
});
await expect(
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral',
generalId: 7,
patch: { meta: { inheritSpecificSpecialWar: 'che_의술' } },
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '4000 포인트로 다음 전투 특기로 의술 지정',
},
});
});
it('does not dispatch or charge when a different war trait is already reserved', async () => {
const fixture = buildContext({
inheritancePoint: 5_000,
general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }),
configConst: { availableSpecialWar: ['che_의술'] },
});
await expect(
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' });
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('resets the current war trait to the in-memory null sentinel and preserves Ref history as an array', async () => {
const fixture = buildContext({
inheritancePoint: 2_000,
general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }),
});
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral',
generalId: 7,
patch: {
specialWar: null,
meta: {
prev_types_special2: ['che_돌격', 'che_선봉'],
marker: 3,
inheritResetSpecialWar: 0,
},
},
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '1000 포인트로 전투 특기 초기화',
},
});
});
it('does not dispatch or charge when the current war trait is already blank', async () => {
const fixture = buildContext({ inheritancePoint: 2_000, general: buildGeneral({ special2Code: 'None' }) });
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '이미 전투 특기가 공란입니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('queues Ref-compatible nextTurnTimeBase without moving the current scheduled turn', async () => {
const fixture = buildContext({
inheritancePoint: 2_000,
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -796,12 +796,12 @@ onMounted(() => {
width: min(100%, 1000px);
margin: 0 auto;
border: 1px solid #888;
overflow: hidden;
overflow-x: hidden;
box-sizing: border-box;
position: relative;
padding: 0 7px;
color: #fff;
height: 1597px;
min-height: 1597px;
font: 14px/21px var(--sammo-font-sans);
}
@@ -1017,7 +1017,7 @@ a:not(.legacy-button):focus-visible {
}
.inherit-page {
height: 3047.5px;
min-height: 3047.5px;
}
.shop-item .buy-button {