merge: 최신 main을 메시지 tombstone 변경에 통합한다

This commit is contained in:
2026-08-24 08:39:39 +00:00
20 changed files with 997 additions and 40 deletions
@@ -1997,7 +1997,7 @@ async function handleKick(
}
const target = world.getGeneralById(command.destGeneralId);
if (!target || target.id === general.id || target.nationId !== general.nationId) {
if (!target || target.nationId !== general.nationId) {
return {
type: 'kick',
ok: false,
@@ -2005,7 +2005,18 @@ async function handleKick(
reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.',
};
}
if (resolveMaxSecretPermission(target) === 4 && resolvePermissionKind(target) === 'ambassador') {
if (target.id === general.id) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '본인은 추방할 수 없습니다.' };
}
// Ref 화면은 군주와 본인을 후보에서 제외하지만 서버는 조작 요청을 막지 못했다.
// 국가 소유권을 깨뜨리는 대상은 UI와 무관하게 durable command 경계에서 거부한다.
if (target.id === nation.chiefGeneralId || target.officerLevel === 12) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '군주는 추방할 수 없습니다.' };
}
if (target.officerLevel >= 5) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '수뇌는 추방할 수 없습니다.' };
}
if (resolvePermissionKind(target) === 'ambassador') {
return {
type: 'kick',
ok: false,
@@ -312,6 +312,39 @@ describe('nation personnel world commands', () => {
expect(fixture.world.peekDirtyState().logs).toHaveLength(2);
});
it('rejects self, ruler, head officer, and ambassador targets without partial mutation', async () => {
const cases = [
{ label: 'self', targetId: 2, reason: '본인은 추방할 수 없습니다.' },
{ label: 'ruler', targetId: 1, reason: '군주는 추방할 수 없습니다.' },
{ label: 'head officer', targetId: 3, reason: '수뇌는 추방할 수 없습니다.' },
{ label: 'ambassador', targetId: 4, reason: '외교권자는 추방할 수 없습니다.' },
] as const;
for (const testCase of cases) {
const fixture = buildWorld({
generals: [
buildGeneral(1, { officerLevel: 12 }),
buildGeneral(2, { officerLevel: 5 }),
buildGeneral(3, { officerLevel: 7 }),
buildGeneral(4, {
meta: { killturn: 12, belong: 5, permission: 'ambassador' },
penalty: { noAmbassador: true },
}),
buildGeneral(5),
],
});
const originalTarget = fixture.world.getGeneralById(testCase.targetId);
await expect(
fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: testCase.targetId })
).resolves.toMatchObject({ ok: false, reason: testCase.reason });
expect(fixture.world.getGeneralById(testCase.targetId), testCase.label).toEqual(originalTarget);
expect(fixture.world.getGeneralById(2)?.meta.killturn, testCase.label).toBe(12);
expect(fixture.world.peekDirtyState().logs, testCase.label).toEqual([]);
expect(fixture.world.peekDirtyState().nations, testCase.label).toEqual([]);
}
});
it('preserves the legacy kick year boundaries and deterministic NPC public message', async () => {
const early = buildWorld({
currentYear: 181,
+40 -6
View File
@@ -61,10 +61,10 @@ const castleFixtures = [
{ id: 2, level: 1, layoutLevel: 8, x: 200, y: 100, width: 16, height: 15 },
{ id: 3, level: 2, layoutLevel: 8, x: 300, y: 100, width: 20, height: 14 },
{ id: 4, level: 3, layoutLevel: 8, x: 400, y: 100, width: 14, height: 14 },
{ id: 5, level: 4, layoutLevel: 8, x: 100, y: 220, width: 20, height: 15 },
{ id: 6, level: 5, layoutLevel: 8, x: 200, y: 220, width: 24, height: 16 },
{ id: 7, level: 6, layoutLevel: 8, x: 300, y: 220, width: 26, height: 18 },
{ id: 8, level: 7, layoutLevel: 8, x: 400, y: 220, width: 28, height: 20 },
{ id: 5, name: '남만', level: 4, layoutLevel: 8, x: 80, y: 455, width: 20, height: 15 },
{ id: 6, name: '교지', level: 5, layoutLevel: 8, x: 130, y: 480, width: 24, height: 16 },
{ id: 7, name: '남해', level: 6, layoutLevel: 8, x: 245, y: 480, width: 26, height: 18 },
{ id: 8, name: '대', level: 7, layoutLevel: 8, x: 450, y: 480, width: 28, height: 20 },
] as const;
const map = {
result: true,
@@ -82,9 +82,9 @@ const map = {
};
const layout = {
mapName: 'che',
cityList: castleFixtures.map(({ id, layoutLevel: level, x, y }) => ({
cityList: castleFixtures.map(({ id, layoutLevel: level, x, y, ...fixture }) => ({
id,
name: id === 1 ? '업' : `${id}`,
name: id === 1 ? '업' : 'name' in fixture ? fixture.name : `${id}`,
level,
region: 1,
x,
@@ -550,6 +550,40 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
await desktopCity.hover();
await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
for (const cityName of ['남만', '교지', '남해', '대']) {
await page.getByRole('link', { name: cityName, exact: true }).hover();
await expect(page.locator('.map-tooltip')).toBeVisible();
const geometry = await page.locator('.map-area').evaluate((mapArea, expectedCityName) => {
const cityElement = Array.from(mapArea.querySelectorAll<HTMLElement>('.city-base')).find(
(element) => element.getAttribute('aria-label') === expectedCityName
);
const tooltip = mapArea.querySelector<HTMLElement>('.map-tooltip');
if (!cityElement || !tooltip) throw new Error(`Missing bottom-city hover geometry for ${expectedCityName}`);
const mapRect = mapArea.getBoundingClientRect();
const cityRect = cityElement.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const controls = mapArea.querySelector<HTMLElement>('.map-controls');
const tooltipStyle = getComputedStyle(tooltip);
return {
map: { top: mapRect.top, bottom: mapRect.bottom },
city: { top: cityRect.top, bottom: cityRect.bottom },
tooltip: { top: tooltipRect.top, bottom: tooltipRect.bottom, height: tooltipRect.height },
tooltipZIndex: Number(tooltipStyle.zIndex),
controlsZIndex: controls ? Number(getComputedStyle(controls).zIndex) : null,
pointerEvents: tooltipStyle.pointerEvents,
};
}, cityName);
expect(geometry.tooltip.top).toBeGreaterThanOrEqual(geometry.map.top);
expect(geometry.tooltip.bottom).toBeLessThanOrEqual(geometry.map.bottom);
expect(geometry.tooltip.bottom).toBeLessThan(geometry.city.top);
expect(geometry.tooltip.height).toBeGreaterThanOrEqual(32);
expect(geometry.tooltipZIndex).toBeGreaterThan(geometry.controlsZIndex ?? 0);
expect(geometry.pointerEvents).toBe('none');
}
await page.screenshot({ path: testInfo.outputPath('desktop-map-bottom-tooltip.png'), fullPage: true });
await desktopCity.hover();
await desktopCity.click();
await expect(page).toHaveURL(/\/current-city\?cityId=1$/u);
await page.goBack();
@@ -412,6 +412,13 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie
expect(rowGeometry.gradientCount).toBe(0);
await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0);
await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible();
await expect(page.getByRole('combobox', { name: '추방 대상 장수' }).locator('option')).toHaveText([
'장수 선택',
'하후돈 (70/70/70)',
'곽가 (70/70/70)',
'정욱 (70/70/70)',
'장료 (70/70/70)',
]);
await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click();
const picker = page.getByTestId('personnel-selection-dialog');
@@ -92,6 +92,8 @@ const BASE_MAP_WIDTH = 700;
const BASE_MAP_HEIGHT = 500;
const SMALL_MAP_SCALE = 5 / 7;
const MAP_BACKGROUND_TRANSITION_MS = 480;
const TOOLTIP_FALLBACK_HEIGHT = 32;
const TOOLTIP_VERTICAL_OFFSET = 30;
const decodedImageCache = new Map<string, Promise<void>>();
const decodedImageElements = new Map<string, HTMLImageElement>();
@@ -146,6 +148,7 @@ const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null);
const mapControls = ref<HTMLElement | null>(null);
const tooltipElement = ref<HTMLElement | null>(null);
const mapOptionsOpen = ref(false);
const mapOptionsMenuId = `map-options-${useId()}`;
const { width: mapBodyWidth } = useElementSize(mapBody);
@@ -568,10 +571,15 @@ const tooltipPosition = computed(() => {
const width = 120;
const offset = 10;
const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value;
const mapPixelHeight = BASE_MAP_HEIGHT * mapScale.value;
const tooltipHeight = tooltipElement.value?.offsetHeight ?? TOOLTIP_FALLBACK_HEIGHT;
const left = elementX.value + width + offset > mapPixelWidth ? elementX.value - width - 5 : elementX.value + offset;
const belowTop = elementY.value + TOOLTIP_VERTICAL_OFFSET;
const top =
belowTop + tooltipHeight > mapPixelHeight ? elementY.value - tooltipHeight - TOOLTIP_VERTICAL_OFFSET : belowTop;
return {
left: `${Math.max(0, left)}px`,
top: `${elementY.value + 30}px`,
top: `${Math.max(0, top)}px`,
};
});
@@ -698,7 +706,7 @@ const selectCity = (cityId: number) => {
>
현재
</div>
<div v-if="hoveredCity" class="map-tooltip" :style="tooltipPosition">
<div v-if="hoveredCity" ref="tooltipElement" class="map-tooltip" :style="tooltipPosition">
<div class="tooltip-title">{{ hoveredCityTitle }}</div>
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
</div>
@@ -99,7 +99,9 @@ const cityCandidates = (level: OfficerLevel): GeneralEntry[] => {
return candidates;
};
const kickCandidates = computed(() =>
(data.value?.generals ?? []).filter((general) => general.id !== data.value?.me.id)
(data.value?.generals ?? []).filter(
(general) => general.id !== data.value?.me.id && general.officerLevel < 5 && general.permission !== 'ambassador'
)
);
const awardText = (entries: PersonnelResponse['awards']['tigers']): string =>
entries.map((entry) => `${entry.name}${entry.value.toLocaleString('ko-KR')}`).join(', ');