fix: 유산 전투 특기 고정과 내역 스크롤 수정
초기화 시 전투 특기를 null로 정규화하고 이전 특기 배열을 보존해 다음 월 고정 배정이 daemon 재시작 없이 동작하게 한다. 다중 변경 내역이 문서 높이를 늘리도록 하고 API, 월간 persistence, 실제 Chromium 회귀 검증을 추가한다.
This commit is contained in:
@@ -72,6 +72,11 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
|||||||
|
|
||||||
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
|
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 readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
|
||||||
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
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)));
|
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;
|
strength?: number;
|
||||||
intelligence?: number;
|
intelligence?: number;
|
||||||
};
|
};
|
||||||
specialWar?: string;
|
specialWar?: string | null;
|
||||||
}
|
}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
@@ -530,16 +535,15 @@ export const inheritRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta = asRecord(general.meta);
|
const meta = asRecord(general.meta);
|
||||||
const prevList =
|
const prevList = readStringList(meta.prev_types_special2);
|
||||||
parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
|
|
||||||
prevList.push(general.special2Code);
|
prevList.push(general.special2Code);
|
||||||
|
|
||||||
await patchGeneral(ctx, general.id, {
|
await patchGeneral(ctx, general.id, {
|
||||||
specialWar: 'None',
|
specialWar: null,
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
...meta,
|
||||||
inheritResetSpecialWar: nextLevel,
|
inheritResetSpecialWar: nextLevel,
|
||||||
prev_types_special2: JSON.stringify(prevList),
|
prev_types_special2: prevList,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
it('queues Ref-compatible nextTurnTimeBase without moving the current scheduled turn', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
inheritancePoint: 2_000,
|
inheritancePoint: 2_000,
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ const zPatchGeneral = z.object({
|
|||||||
intelligence: zFiniteNumber.optional(),
|
intelligence: zFiniteNumber.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
specialWar: z.string().optional(),
|
specialWar: z.string().nullable().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -734,10 +734,10 @@ async function handlePatchGeneral(
|
|||||||
...command.patch.stats,
|
...command.patch.stats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (typeof command.patch.specialWar === 'string') {
|
if (command.patch.specialWar !== undefined) {
|
||||||
patch.role = {
|
patch.role = {
|
||||||
...general.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 { describe, expect, it } from 'vitest';
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic';
|
import { LogCategory, LogFormat } from '@sammo-ts/logic';
|
||||||
|
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||||
import {
|
import {
|
||||||
createAddGlobalBetrayHandler,
|
createAddGlobalBetrayHandler,
|
||||||
createAssignGeneralSpecialityHandler,
|
createAssignGeneralSpecialityHandler,
|
||||||
} from '../src/turn/monthlySpecialityBetrayAction.js';
|
} from '../src/turn/monthlySpecialityBetrayAction.js';
|
||||||
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
const event: TurnEvent = {
|
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 () => {
|
it('does nothing before the three-year opening period ends', async () => {
|
||||||
const world = buildWorld();
|
const world = buildWorld();
|
||||||
await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], { ...environment, year: 192 }, event);
|
await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], { ...environment, year: 192 }, event);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
createAddGlobalBetrayHandler,
|
createAddGlobalBetrayHandler,
|
||||||
createAssignGeneralSpecialityHandler,
|
createAssignGeneralSpecialityHandler,
|
||||||
} from '../src/turn/monthlySpecialityBetrayAction.js';
|
} from '../src/turn/monthlySpecialityBetrayAction.js';
|
||||||
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
@@ -105,7 +106,7 @@ integration('monthly speciality and betrayal persistence', () => {
|
|||||||
}),
|
}),
|
||||||
buildGeneral(generalIds[1], {
|
buildGeneral(generalIds[1], {
|
||||||
domestic: 'che_경작',
|
domestic: 'che_경작',
|
||||||
war: null,
|
war: 'che_신산',
|
||||||
meta: {
|
meta: {
|
||||||
specage: 99,
|
specage: 99,
|
||||||
specage2: 30,
|
specage2: 30,
|
||||||
@@ -198,6 +199,22 @@ integration('monthly speciality and betrayal persistence', () => {
|
|||||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||||
|
|
||||||
try {
|
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 world.advanceMonth(new Date('0200-01-01T00:00:00.000Z'));
|
||||||
await hooks.hooks.flushChanges?.({
|
await hooks.hooks.flushChanges?.({
|
||||||
lastTurnTime: state.lastTurnTime.toISOString(),
|
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||||
@@ -214,7 +231,12 @@ integration('monthly speciality and betrayal persistence', () => {
|
|||||||
expect(rows[0]?.specialCode).not.toBe('None');
|
expect(rows[0]?.specialCode).not.toBe('None');
|
||||||
expect(rows[0]?.meta).toMatchObject({ betray: 2 });
|
expect(rows[0]?.meta).toMatchObject({ betray: 2 });
|
||||||
expect(rows[1]).toMatchObject({ special2Code: 'che_의술' });
|
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(rows[1]?.meta).not.toHaveProperty('inheritSpecificSpecialWar');
|
||||||
expect(await db.logEntry.count({ where: { generalId: { in: [...generalIds] } } })).toBe(4);
|
expect(await db.logEntry.count({ where: { generalId: { in: [...generalIds] } } })).toBe(4);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -796,12 +796,12 @@ onMounted(() => {
|
|||||||
width: min(100%, 1000px);
|
width: min(100%, 1000px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border: 1px solid #888;
|
border: 1px solid #888;
|
||||||
overflow: hidden;
|
overflow-x: hidden;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0 7px;
|
padding: 0 7px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
height: 1597px;
|
min-height: 1597px;
|
||||||
font: 14px/21px var(--sammo-font-sans);
|
font: 14px/21px var(--sammo-font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1017,7 +1017,7 @@ a:not(.legacy-button):focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inherit-page {
|
.inherit-page {
|
||||||
height: 3047.5px;
|
min-height: 3047.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-item .buy-button {
|
.shop-item .buy-button {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ storage, route guards, and image loading.
|
|||||||
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
|
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
|
||||||
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
|
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
|
||||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||||
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
|
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error, two 30-row history pages that expand the document and keep the last row/load-more button reachable by scrolling |
|
||||||
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
||||||
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
||||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export type TurnDaemonCommand =
|
|||||||
strength?: number;
|
strength?: number;
|
||||||
intelligence?: number;
|
intelligence?: number;
|
||||||
};
|
};
|
||||||
specialWar?: string;
|
specialWar?: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { dirname, extname, resolve } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
const imageRoot = resolve(repositoryRoot, '../../image');
|
const imageRoot = process.env.FRONTEND_PARITY_IMAGE_ROOT ?? resolve(repositoryRoot, '../../image');
|
||||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||||
const gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`;
|
const gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`;
|
||||||
|
|
||||||
@@ -117,9 +117,21 @@ const statusFixture = {
|
|||||||
currentStat: { leadership: 70, strength: 45, intel: 85 },
|
currentStat: { leadership: 70, strength: 45, intel: 85 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
|
interface InheritanceLogFixture {
|
||||||
|
id: number;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
text: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installFixture = async (
|
||||||
|
page: Page,
|
||||||
|
options: { failBuff?: boolean; logPages?: InheritanceLogFixture[][] } = {}
|
||||||
|
) => {
|
||||||
let buffMutationCount = 0;
|
let buffMutationCount = 0;
|
||||||
let resetTurnMutationCount = 0;
|
let resetTurnMutationCount = 0;
|
||||||
|
let logRequestCount = 0;
|
||||||
const uniqueAuctionRequests: unknown[] = [];
|
const uniqueAuctionRequests: unknown[] = [];
|
||||||
await installImages(page);
|
await installImages(page);
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -148,7 +160,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (name === 'inherit.getLogs') {
|
if (name === 'inherit.getLogs') {
|
||||||
return response([
|
const defaultPage = [
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
year: 200,
|
year: 200,
|
||||||
@@ -156,7 +168,11 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
|
|||||||
text: '1000 포인트로 장수 소유자 확인',
|
text: '1000 포인트로 장수 소유자 확인',
|
||||||
createdAt: '2026-07-26T00:00:00.000Z',
|
createdAt: '2026-07-26T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
const pages = options.logPages ?? [defaultPage];
|
||||||
|
const pageIndex = Math.min(logRequestCount, pages.length - 1);
|
||||||
|
logRequestCount += 1;
|
||||||
|
return response(pages[pageIndex] ?? []);
|
||||||
}
|
}
|
||||||
if (name === 'join.getConfig') {
|
if (name === 'join.getConfig') {
|
||||||
return response({ rules: { stat: { total: 200, min: 10, max: 100 } } });
|
return response({ rules: { stat: { total: 200, min: 10, max: 100 } } });
|
||||||
@@ -184,6 +200,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
|
|||||||
return {
|
return {
|
||||||
buffMutationCount: () => buffMutationCount,
|
buffMutationCount: () => buffMutationCount,
|
||||||
resetTurnMutationCount: () => resetTurnMutationCount,
|
resetTurnMutationCount: () => resetTurnMutationCount,
|
||||||
|
logRequestCount: () => logRequestCount,
|
||||||
uniqueAuctionRequests,
|
uniqueAuctionRequests,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -333,6 +350,52 @@ test.describe('inheritance management legacy parity', () => {
|
|||||||
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
|
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps every paged inheritance log reachable by document scrolling', async ({ page }) => {
|
||||||
|
const buildPage = (firstId: number, count: number): InheritanceLogFixture[] =>
|
||||||
|
Array.from({ length: count }, (_, index) => {
|
||||||
|
const id = firstId - index;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
year: 200,
|
||||||
|
month: 4,
|
||||||
|
text: `유산 포인트 변경 내역 ${id}`,
|
||||||
|
createdAt: `2026-07-${String((id % 27) + 1).padStart(2, '0')}T00:00:00.000Z`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const fixture = await installFixture(page, {
|
||||||
|
logPages: [buildPage(60, 30), buildPage(30, 30), []],
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await page.goto(gameUrl);
|
||||||
|
await expect(page.locator('.log-row')).toHaveCount(30);
|
||||||
|
|
||||||
|
const firstHeight = await page.evaluate(() => document.scrollingElement?.scrollHeight ?? 0);
|
||||||
|
const moreButton = page.getByRole('button', { name: '더 가져오기' });
|
||||||
|
await moreButton.click();
|
||||||
|
await expect(page.locator('.log-row')).toHaveCount(60);
|
||||||
|
await expect(page.locator('.log-row').last()).toContainText('유산 포인트 변경 내역 1');
|
||||||
|
const expandedHeight = await page.evaluate(() => document.scrollingElement?.scrollHeight ?? 0);
|
||||||
|
expect(expandedHeight).toBeGreaterThan(firstHeight);
|
||||||
|
|
||||||
|
await page.evaluate(() => window.scrollTo(0, document.scrollingElement?.scrollHeight ?? 0));
|
||||||
|
await expect(page.locator('.log-row').last()).toBeInViewport();
|
||||||
|
await expect(moreButton).toBeInViewport();
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
Math.abs(
|
||||||
|
window.scrollY + window.innerHeight - (document.scrollingElement?.scrollHeight ?? window.innerHeight)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-mobile-60-logs.png'), fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await moreButton.click();
|
||||||
|
await expect.poll(fixture.logRequestCount).toBe(3);
|
||||||
|
await expect(moreButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
test('selects a Ref default unique and starts its auction from the inheritance page', async ({ page }) => {
|
test('selects a Ref default unique and starts its auction from the inheritance page', async ({ page }) => {
|
||||||
const fixture = await installFixture(page);
|
const fixture = await installFixture(page);
|
||||||
await page.goto(gameUrl);
|
await page.goto(gameUrl);
|
||||||
|
|||||||
Reference in New Issue
Block a user