merge: 최신 main을 메시지 tombstone 변경에 통합한다
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(', ');
|
||||
|
||||
Reference in New Issue
Block a user