merge: 최신 main을 전투시드 숨김 작업에 통합

# Conflicts:
#	app/game-frontend/src/assets/main.css
#	docs/frontend-legacy-parity.md
This commit is contained in:
2026-08-19 14:06:58 +00:00
9 changed files with 269 additions and 25 deletions
+95 -7
View File
@@ -684,7 +684,7 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명
await expect(page.locator('.main-page')).not.toContainText('che_'); await expect(page.locator('.main-page')).not.toContainText('che_');
}); });
test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을 유지한다', async ({ page }) => { test('메인 장수 동향과 개인 전투 기록은 Ref 행 간격·색상·글자 크기를 유지한다', async ({ page }) => {
const state: FixtureState = { const state: FixtureState = {
permission: 'head', permission: 'head',
myset: 3, myset: 3,
@@ -712,13 +712,15 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
id: 18608, id: 18608,
text: text:
'<S>◆</>186년 9월:<div class="small_war_log">' + '<S>◆</>186년 9월:<div class="small_war_log">' +
'<span class="me"><span class="crew_type">귀병</span> ' + '<span class="me"><span class="name_plate"><span class="crew_type">귀병</span> ' +
'<span class="name_plate_cover">【<span class="name">Administrator</span>】</span> ' + '<span class="name_plate_cover">【<span class="name">Administrator</span>】</span></span> ' +
'<span class="remain_crew">0</span>(<span class="killed_crew">-2209</span>)</span> ' + '<span class="crew_plate"><span class="remain_crew">0</span>' +
'<span class="killed_plate">(<span class="killed_crew">-2209</span>)</span></span></span> ' +
'<span class="war_type war_type_defense">←</span> ' + '<span class="war_type war_type_defense">←</span> ' +
'<span class="you"><span class="remain_crew">1361</span>' + '<span class="you"><span class="crew_plate"><span class="remain_crew">1361</span>' +
'(<span class="killed_crew">-5539</span>) <span class="crew_type">기병</span> ' + '<span class="killed_plate">(<span class="killed_crew">-5539</span>)</span></span> ' +
'<span class="name_plate_cover"><span class="name">ⓝ뇌동</span>】</span></span></div>', '<span class="name_plate"><span class="crew_type">기병</span> ' +
'<span class="name_plate_cover">【<span class="name">ⓝ뇌동</span>】</span></span></span></div>',
createdAt: '2026-01-01T03:54:00.000Z', createdAt: '2026-01-01T03:54:00.000Z',
}, },
], ],
@@ -762,6 +764,16 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
const battle = element.querySelector<HTMLElement>('.small_war_log'); const battle = element.querySelector<HTMLElement>('.small_war_log');
if (!battle) throw new Error('전투 요약 markup을 찾지 못했습니다.'); if (!battle) throw new Error('전투 요약 markup을 찾지 못했습니다.');
const battleRect = battle.getBoundingClientRect(); const battleRect = battle.getBoundingClientRect();
const requireElement = (selector: string): HTMLElement => {
const target = element.querySelector<HTMLElement>(selector);
if (!target) throw new Error(`전투 요약 요소를 찾지 못했습니다: ${selector}`);
return target;
};
const diamond = requireElement('span[style*="skyblue"]');
const namePlate = requireElement('.me .name_plate');
const nameCover = requireElement('.me .name_plate_cover');
const crewPlate = requireElement('.me .crew_plate');
const arrow = requireElement('.war_type_defense');
return { return {
line: { top: lineRect.top, height: lineRect.height }, line: { top: lineRect.top, height: lineRect.height },
battle: { battle: {
@@ -770,6 +782,14 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
display: getComputedStyle(battle).display, display: getComputedStyle(battle).display,
}, },
lineHeight: getComputedStyle(element).lineHeight, lineHeight: getComputedStyle(element).lineHeight,
styles: {
diamondColor: getComputedStyle(diamond).color,
namePlateFontSize: getComputedStyle(namePlate).fontSize,
nameCoverColor: getComputedStyle(nameCover).color,
crewPlateColor: getComputedStyle(crewPlate).color,
crewPlateFontSize: getComputedStyle(crewPlate).fontSize,
defenseArrowColor: getComputedStyle(arrow).color,
},
}; };
}); });
}; };
@@ -778,6 +798,14 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
expect(geometry.line.height).toBe(21); expect(geometry.line.height).toBe(21);
expect(geometry.battle.height).toBe(21); expect(geometry.battle.height).toBe(21);
expect(geometry.battle.top).toBeCloseTo(geometry.line.top, 0); expect(geometry.battle.top).toBeCloseTo(geometry.line.top, 0);
expect(geometry.styles).toEqual({
diamondColor: 'rgb(135, 206, 235)',
namePlateFontSize: '10.5px',
nameCoverColor: 'rgb(255, 255, 0)',
crewPlateColor: 'rgb(255, 69, 0)',
crewPlateFontSize: '12.6px',
defenseArrowColor: 'rgb(255, 0, 255)',
});
}; };
const desktopGeometry = await inspect( const desktopGeometry = await inspect(
@@ -1181,6 +1209,66 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await persistParityArtifact(page, 'core-my-page-mobile', mobile); await persistParityArtifact(page, 'core-my-page-mobile', mobile);
}); });
test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오른쪽에 정렬된다', async ({ page }) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state);
for (const viewport of [
{ name: 'desktop', width: 1000, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
] as const) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('my-page');
const pastPlaysLink = page.getByRole('link', { name: '지난 플레이' });
await expect(pastPlaysLink).toHaveAttribute('href', `${gameBasePath}/past-plays`);
await pastPlaysLink.hover();
await pastPlaysLink.focus();
await expect(pastPlaysLink).toBeFocused();
const geometry = await page.locator('.title-row').evaluate((element) => {
const title = element.getBoundingClientRect();
const actions = element.querySelector<HTMLElement>('.title-actions')!.getBoundingClientRect();
const navigation = element.querySelector<HTMLElement>('.navigation-actions')!.getBoundingClientRect();
const back = element.querySelector<HTMLAnchorElement>('.navigation-actions a')!.getBoundingClientRect();
const refresh = element.querySelector<HTMLButtonElement>('.navigation-actions button')!.getBoundingClientRect();
const past = element.querySelector<HTMLAnchorElement>('.past-plays-link')!.getBoundingClientRect();
const pastStyle = getComputedStyle(element.querySelector<HTMLAnchorElement>('.past-plays-link')!);
return {
title: { left: title.left, right: title.right },
actions: { left: actions.left, right: actions.right },
navigation: { left: navigation.left, right: navigation.right },
back: { top: back.top, right: back.right },
refresh: { top: refresh.top, right: refresh.right },
past: { top: past.top, left: past.left, right: past.right },
pastStyle: {
cursor: pastStyle.cursor,
minHeight: pastStyle.minHeight,
backgroundColor: pastStyle.backgroundColor,
},
scrollWidth: document.documentElement.scrollWidth,
};
});
expect(geometry.actions.left).toBeCloseTo(geometry.title.left + 1, 0);
expect(geometry.actions.right).toBeCloseTo(geometry.title.right - 1, 0);
expect(geometry.navigation.left).toBeCloseTo(geometry.actions.left, 0);
expect(geometry.past.right).toBeCloseTo(geometry.actions.right, 0);
expect(geometry.past.left).toBeGreaterThan(geometry.navigation.right);
expect(geometry.back.top).toBeCloseTo(geometry.past.top, 0);
expect(geometry.refresh.top).toBeCloseTo(geometry.past.top, 0);
expect(geometry.pastStyle).toEqual({
cursor: 'pointer',
minHeight: '34px',
backgroundColor: 'rgb(49, 95, 134)',
});
expect(geometry.scrollWidth).toBe(viewport.width);
await persistParityArtifact(page, `core-my-page-past-plays-${viewport.name}`, geometry);
await pastPlaysLink.click();
await expect(page).toHaveURL(new RegExp(`${gameBasePath}/past-plays$`, 'u'));
}
});
for (const [label, failure] of [ for (const [label, failure] of [
['daemon timeout', 'TIMEOUT'], ['daemon timeout', 'TIMEOUT'],
['engine transaction 오류', 'INTERNAL_SERVER_ERROR'], ['engine transaction 오류', 'INTERNAL_SERVER_ERROR'],
+25
View File
@@ -98,3 +98,28 @@ textarea {
color: rgba(0, 0, 0, 0) !important; color: rgba(0, 0, 0, 0) !important;
font-size: 0; font-size: 0;
} }
.small_war_log .war_type_attack {
color: cyan;
}
.small_war_log .war_type_defense {
color: magenta;
}
.small_war_log .war_type_siege {
color: white;
}
.small_war_log .name_plate {
font-size: 0.75em;
}
.small_war_log .name_plate_cover {
color: yellow;
}
.small_war_log .crew_plate {
color: orangered;
font-size: 90%;
}
+15 -1
View File
@@ -365,10 +365,14 @@ onMounted(() => {
<main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`"> <main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
<div class="title-row"> <div class="title-row">
<span> </span> <span> </span>
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink> <div class="title-actions">
<div class="navigation-actions">
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink> <RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="() => loadPage()">새로고침</button> <button class="legacy-button" type="button" @click="() => loadPage()">새로고침</button>
</div> </div>
<RouterLink class="legacy-button past-plays-link" to="/past-plays">지난 플레이</RouterLink>
</div>
</div>
<div v-if="error" class="error-row">{{ error }}</div> <div v-if="error" class="error-row">{{ error }}</div>
<div class="status-row">{{ statusLine }}</div> <div class="status-row">{{ statusLine }}</div>
@@ -675,6 +679,16 @@ onMounted(() => {
height: 18px; height: 18px;
letter-spacing: 0; letter-spacing: 0;
} }
.title-actions {
display: flex;
width: 100%;
align-items: flex-start;
justify-content: space-between;
}
.navigation-actions {
display: flex;
gap: 4px;
}
.legacy-button, .legacy-button,
button, button,
select, select,
+14
View File
@@ -144,6 +144,20 @@ desktop/mobile의 투명색, 0px 글자 크기, 0×0 rect와 selection text 보
`MENU_PARITY_ARTIFACT_DIR`를 함께 지정하면 세 화면의 1000×900·500×900 `MENU_PARITY_ARTIFACT_DIR`를 함께 지정하면 세 화면의 1000×900·500×900
screenshot과 computed JSON을 남깁니다. screenshot과 computed JSON을 남깁니다.
개인 전투 결과 요약의 Ref 색상·글자 크기는 실제 `small_war_log` class 구조와
빌드 CSS를 사용하는 별도 정적 Chromium fixture로 재현합니다. 방어 화살표,
장수명 괄호, 병력 수치와 병종·장수/병력 글자 크기를 desktop/mobile에서
수집하며 live session·DB writer 검증과는 구분합니다.
```sh
REF_SAM_ROOT=/path/to/ref/sam \
REF_PERSONAL_BATTLE_LOG_ARTIFACT_DIR=/path/to/ignored/artifacts \
node tools/frontend-legacy-parity/reference-personal-battle-log-colors.mjs
```
대응하는 Core 검증은 `inGameMenus.spec.ts`의 “개인 전투 기록” test이며 같은
viewport에서 한 줄 geometry와 computed 색상·글자 크기를 함께 검사합니다.
To refresh the PHP ranking evidence after building the ignored reference To refresh the PHP ranking evidence after building the ignored reference
webpack assets, run: webpack assets, run:
@@ -12,6 +12,7 @@ import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import { z } from 'zod'; import { z } from 'zod';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js'; import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
@@ -68,11 +69,8 @@ export class ActionResolver<
const destCityId = args.destCityId; const destCityId = args.destCityId;
const storedDestCityId = args.storedDestCityId ?? destCityId; const storedDestCityId = args.storedDestCityId ?? destCityId;
let destCityName = `도시(${destCityId})`; const destCityName =
if (context.map) { context.map?.cities.find((city) => city.id === destCityId)?.name ?? '알 수 없는 도시';
const c = context.map.cities.find((ct) => ct.id === destCityId);
if (c) destCityName = c.name;
}
const josaRo = JosaUtil.pick(destCityName, '로'); const josaRo = JosaUtil.pick(destCityName, '로');
@@ -132,6 +130,11 @@ export class ActionDefinition<
} }
} }
export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
...base,
map: options.map,
});
export const commandSpec: GeneralTurnCommandSpec = { export const commandSpec: GeneralTurnCommandSpec = {
key: 'che_NPC능동', key: 'che_NPC능동',
category: '특수', // Valid category? Legacy didn't specify category in static prop usually, handled by mapping. Defaulting to '특수'. category: '특수', // Valid category? Legacy didn't specify category in static prop usually, handled by mapping. Defaulting to '특수'.
@@ -68,11 +68,8 @@ export class ActionResolver<
const goldCost = develCost * 5; const goldCost = develCost * 5;
// Log destination // Log destination
let destCityName = `도시(${destCityId})`; const destCityName =
if (context.map) { context.map?.cities.find((city) => city.id === destCityId)?.name ?? '알 수 없는 도시';
const c = context.map.cities.find((ct) => ct.id === destCityId);
if (c) destCityName = c.name;
}
const josaRo = JosaUtil.pick(destCityName, '로'); const josaRo = JosaUtil.pick(destCityName, '로');
@@ -68,11 +68,8 @@ export class ActionResolver<
const cost = context.develCost ?? 0; const cost = context.develCost ?? 0;
let destCityName = `도시(${destCityId})`; const destCityName =
if (context.map) { context.map?.cities.find((city) => city.id === destCityId)?.name ?? '알 수 없는 도시';
const c = context.map.cities.find((ct) => ct.id === destCityId);
if (c) destCityName = c.name;
}
const josaRo = JosaUtil.pick(destCityName, '로'); const josaRo = JosaUtil.pick(destCityName, '로');
@@ -0,0 +1,85 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { chromium } from '@playwright/test';
const refRoot = resolve(process.env.REF_SAM_ROOT ?? '/home/letrhee/sam_rebuild/ref/sam');
const artifactDir = process.env.REF_PERSONAL_BATTLE_LOG_ARTIFACT_DIR;
const formatterUrl = pathToFileURL(resolve(refRoot, 'hwe/ts/utilGame/formatLog.ts')).href;
const { formatLog } = await import(formatterUrl);
const css = await readFile(resolve(refRoot, 'dist_js/hwe_dynamic/vue/v_main.css'), 'utf8');
const record =
'<S>◆</>188년 2월:<div class="small_war_log">' +
'<span class="me"><span class="name_plate"><span class="crew_type">남귀</span> ' +
'<span class="name_plate_cover">【<span class="name">운영자</span>】</span></span> ' +
'<span class="crew_plate"><span class="remain_crew">0</span>' +
'<span class="killed_plate">(<span class="killed_crew">-4404</span>)</span></span></span> ' +
'<span class="war_type war_type_defense">←</span> ' +
'<span class="you"><span class="crew_plate"><span class="remain_crew">555</span>' +
'<span class="killed_plate">(<span class="killed_crew">-6845</span>)</span></span> ' +
'<span class="name_plate"><span class="crew_type">기병</span> ' +
'<span class="name_plate_cover">【<span class="name">ⓝ독야청정</span>】</span></span></span></div>';
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
colorScheme: 'dark',
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'UTC',
});
const page = await context.newPage();
await page.setContent(
`<!doctype html><html><head><style>${css}</style></head><body>` +
`<div id="container"><div class="RecordZone row gx-0"><div class="GeneralLog col col-12 col-lg-6">` +
`<div class="bg1 center s-border-tb title">개인 기록</div><div class="fixture-line">${formatLog(record)}</div>` +
`</div></div></div></body></html>`,
{ waitUntil: 'networkidle' }
);
await page.evaluate(() => document.fonts.ready);
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
const measurement = await page.locator('.fixture-line').evaluate((element) => {
const requireElement = (selector) => {
const target = element.querySelector(selector);
if (!(target instanceof HTMLElement)) throw new Error(`Ref 전투기록 요소를 찾지 못했습니다: ${selector}`);
return target;
};
const rect = element.getBoundingClientRect();
const battle = requireElement('.small_war_log');
const battleRect = battle.getBoundingClientRect();
return {
text: element.textContent,
line: { height: rect.height, fontSize: getComputedStyle(element).fontSize },
battle: { height: battleRect.height, display: getComputedStyle(battle).display },
styles: {
diamondColor: getComputedStyle(requireElement('span[style*="skyblue"]')).color,
namePlateFontSize: getComputedStyle(requireElement('.me .name_plate')).fontSize,
nameCoverColor: getComputedStyle(requireElement('.me .name_plate_cover')).color,
crewPlateColor: getComputedStyle(requireElement('.me .crew_plate')).color,
crewPlateFontSize: getComputedStyle(requireElement('.me .crew_plate')).fontSize,
defenseArrowColor: getComputedStyle(requireElement('.war_type_defense')).color,
},
};
});
const output = { viewport, measurement };
console.log(JSON.stringify(output));
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await Promise.all([
page.screenshot({ path: resolve(artifactDir, `ref-personal-battle-log-colors-${viewport.name}.png`) }),
writeFile(
resolve(artifactDir, `ref-personal-battle-log-colors-${viewport.name}.json`),
`${JSON.stringify(output, null, 2)}\n`
),
]);
}
}
} finally {
await browser.close();
}
@@ -421,6 +421,21 @@ integration('general command success matrix', () => {
ignoredPathPatterns: ignoredLifecyclePaths, ignoredPathPatterns: ignoredLifecyclePaths,
}) })
).toEqual([]); ).toEqual([]);
if (action === 'che_이동' || action === 'che_강행') {
const actionLogSuffix = action === 'che_이동' ? '이동했습니다.' : '강행했습니다.';
expect(
semanticLogSignatures(
core.after.logs.filter((entry) => String(entry.text).includes(actionLogSuffix))
)
).toEqual(
semanticLogSignatures(
addedReferenceLogs(reference.before, reference.after.logs).filter((entry) =>
String(entry.text).includes(actionLogSuffix)
)
)
);
expect(core.after.logs.some((entry) => String(entry.text).includes('도시('))).toBe(false);
}
}, },
120_000 120_000
); );
@@ -564,6 +579,12 @@ integration('NPC active command boundary parity', () => {
ignoredPathPatterns: ignoredLifecyclePaths, ignoredPathPatterns: ignoredLifecyclePaths,
}) })
).toEqual([]); ).toEqual([]);
if (completed) {
expect(semanticLogSignatures(core.after.logs)).toEqual(
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
);
expect(core.after.logs.some((entry) => String(entry.text).includes('도시('))).toBe(false);
}
}, },
120_000 120_000
); );