feat: 암행부와 현재 도시의 정보 보존형 500px 배치 구현

This commit is contained in:
2026-09-12 15:28:46 +00:00
parent 047d86cace
commit 0cd2b7770c
6 changed files with 821 additions and 53 deletions
+1
View File
@@ -1124,6 +1124,7 @@ test('current-city exposes own general details to a member and admin fixture', a
await go(page, 'current-city');
await expect(page.locator('.generals')).toContainText('장수');
await expect(page.locator('.generals')).toContainText('90');
await expect(page.locator('.general-icon')).toHaveJSProperty('naturalWidth', 64);
const legacyGeometry = await page.evaluate(() => {
const rect = (selector: string) => {
const box = document.querySelector(selector)?.getBoundingClientRect();
@@ -0,0 +1,421 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page } from '@playwright/test';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const artifactRoot = process.env.INFO_LAYOUT_ARTIFACT_DIR;
const baseline = process.env.INFO_LAYOUT_BASELINE === '1';
const commands = [
{ action: 'che_징병', args: { crewType: 1, amount: 12345 } },
{ action: 'che_화계', args: { destCityId: 1 } },
{ action: '휴식', args: {} },
{ action: 'che_징병', args: { crewType: 1, amount: 9000 } },
{ action: 'che_화계', args: { destCityId: 2 } },
];
const generals = Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
name: index === 0 ? '긴이름을가진검증장수' : `검증장수${index + 1}`,
npcState: index % 5 === 4 ? 2 : 0,
picture: null,
imageServer: 0,
nationId: 1,
nationName: '위',
injury: index === 0 ? 30 : 0,
stats: { leadership: 70, strength: 56, intelligence: 42 },
baseStats: { leadership: 100, strength: 80, intelligence: 60 },
leadership: 100,
strength: 80,
intelligence: 60,
leadershipBonus: index === 0 ? 12 : 0,
experienceLevel: 12,
officerLevel: 4,
cityId: 1,
cityName: '업',
troopId: 1,
troopName: index === 0 ? '이름이긴백호부대' : '백호부대',
gold: index === 0 ? 123456789 : 12345 + index,
rice: 234567 + index,
defenceTrain: 90,
defenceTrainText: '☆',
crewTypeId: 1,
crewTypeName: '보병',
crew: 12345,
train: 90,
atmos: 95,
killTurn: 8,
turnTime: `2026-01-01T01:${String(index % 60).padStart(2, '0')}:00.000Z`,
reservedCommands: index % 5 === 4 ? [] : commands,
turns: index % 5 === 4 ? [] : commands,
}));
const commandTable = {
general: [
{
category: '군사',
values: [
{ key: 'che_징병', name: '징병', reqArg: true, inputFields: [] },
{ key: 'che_화계', name: '화계', reqArg: true, inputFields: [] },
{ key: '휴식', name: '휴식', reqArg: false, inputFields: [] },
],
},
],
nation: [],
inputOptions: {
cities: [
{ value: 1, label: '업 (위)' },
{ value: 2, label: '이름이아주긴목적지도시 (위)' },
],
generals: [],
nations: [],
crewTypes: [{ value: 1, label: '보병' }],
armTypes: [],
nationTypes: [],
colors: [],
items: {},
recruitment: null,
},
};
const summary = {
gold: 123456789,
rice: 234567890,
averageGold: 1234567.89,
averageRice: 2345678.9,
crew: 1234500,
generalCount: 100,
readiness: {
90: { crew: 1234500, generals: 100 },
80: { crew: 1234500, generals: 100 },
60: { crew: 1234500, generals: 100 },
},
};
const forceSummary = {
enemyCrew: 0,
enemyArmedGenerals: 0,
enemyGenerals: 0,
ownCrew: 1234500,
ownArmedGenerals: 100,
ownGenerals: 100,
ready90Crew: 1234500,
ready90Generals: 100,
ready60Crew: 1234500,
ready60Generals: 100,
defenceReadyCrew: 1234500,
defenceReadyGenerals: 100,
};
const install = async (page: Page, variant: 'mixed' | 'idle' | 'empty' = 'mixed') => {
const fixtureGenerals =
variant === 'empty'
? []
: variant === 'idle'
? generals.map((general) => ({ ...general, npcState: 2, injury: 0, reservedCommands: [], turns: [] }))
: generals;
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_info_layout_fixture');
localStorage.setItem('sammo-game-profile', profile);
}, gameProfile);
await page.route('**/image/**', async (route) => {
const relativePath = new URL(route.request().url()).pathname.split('/image/')[1]!;
if (relativePath.includes('..')) throw new Error('Invalid image fixture path');
const root = process.env.FRONTEND_PARITY_IMAGE_ROOT;
if (!root) throw new Error('FRONTEND_PARITY_IMAGE_ROOT is required');
const body = await readFile(resolve(root, relativePath));
await route.fulfill({
body,
contentType: relativePath.endsWith('.png')
? 'image/png'
: relativePath.endsWith('.gif')
? 'image/gif'
: 'image/jpeg',
});
});
await page.route(gameTrpcRoute, async (route) => {
const operations = decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(
','
);
const responses = operations.map((operation) => {
let data: unknown;
if (operation === 'auth.status') data = { ok: true };
else if (operation === 'lobby.info') data = { myGeneral: { id: 1, name: '검증장수' } };
else if (operation === 'join.getConfig') data = {};
else if (operation === 'public.recordAccess') data = { recorded: true };
else if (operation === 'general.me') data = { general: generals[0], iconChoices: [] };
else if (operation === 'turns.getCommandTable') data = commandTable;
else if (operation === 'nation.getSecretGeneralList')
data = {
nation: { id: 1, name: '위', color: '#008000', level: 3 },
viewer: { generalId: 1, permission: 1 },
summary,
generals: fixtureGenerals,
};
else if (operation === 'world.getCurrentCity')
data = {
me: { id: 1, nationId: 1, officerLevel: 4, admin: false },
options: [
{ id: 1, name: '업', nationId: 1 },
{ id: 2, name: '낙양', nationId: 1 },
],
visibility: { full: true, detailed: true },
city: {
id: 1,
name: '업',
nationId: 1,
nationColor: '#008000',
level: 8,
region: 1,
population: 150000,
populationMax: 620500,
agriculture: 1000,
agricultureMax: 12500,
commerce: 1000,
commerceMax: 11300,
security: 1000,
securityMax: 10000,
trust: 80,
trade: 100,
defence: 5000,
defenceMax: 11700,
wall: 5000,
wallMax: 12200,
officers: { 2: '이름이긴종사장수', 3: '군사', 4: '태수' },
},
generals: fixtureGenerals,
forceSummary,
lastExecute: '2026-09-12',
};
else throw new Error(`Unhandled fixture operation: ${operation}`);
return { result: { data } };
});
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(responses) });
});
};
// CSS textures are not part of document.images; wait for them before collecting visuals.
const waitForVisualAssets = async (page: Page) => {
await expect(page.locator('.legacy-bg0').first()).toHaveCSS('background-image', /url\(/);
await page.evaluate(async () => {
await document.fonts.ready;
const backgrounds = new Set<string>();
for (const element of document.querySelectorAll('body, main, table, td, th')) {
for (const match of getComputedStyle(element).backgroundImage.matchAll(/url\(["']?(.*?)["']?\)/g)) {
if (match[1]) backgrounds.add(match[1]);
}
}
await Promise.all([
...Array.from(document.images, (image) => image.decode()),
...Array.from(backgrounds, async (url) => {
const image = new Image();
image.src = url;
await image.decode();
}),
]);
});
};
const measure = async (page: Page, root: string, table: string, name: string) => {
await expect(page.locator(`${table} tbody tr`)).toHaveCount(100);
await waitForVisualAssets(page);
const result = await page.evaluate(
({ root, table }) => {
const rect = (element: Element) => {
const b = element.getBoundingClientRect();
return { x: b.x, y: b.y, width: b.width, height: b.height };
};
const container = document.querySelector(root)!;
const rows = Array.from(document.querySelectorAll(`${table} tbody tr`));
return {
fonts: Array.from(document.fonts, (font) => ({ family: font.family, status: font.status })),
page: rect(container),
table: rect(document.querySelector(table)!),
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
clientWidth: document.documentElement.clientWidth,
},
rows: rows.map((row) => ({
rect: rect(row),
cells: Array.from(row.querySelectorAll('td'), (cell) => ({
rect: rect(cell),
text: cell.innerText.replace(/\s+/g, ''),
scrollWidth: cell.scrollWidth,
clientWidth: cell.clientWidth,
fontSize: getComputedStyle(cell).fontSize,
lineHeight: getComputedStyle(cell).lineHeight,
})),
})),
images: Array.from(container.querySelectorAll('img'), (image) => ({
rect: rect(image),
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
objectFit: getComputedStyle(image).objectFit,
})),
};
},
{ root, table }
);
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await writeFile(resolve(artifactRoot, `${name}.json`), JSON.stringify(result, null, 2));
await writeFile(
resolve(artifactRoot, `${name}.html`),
await page.locator(root).evaluate((element) => element.outerHTML)
);
await page.screenshot({ path: resolve(artifactRoot, `${name}.png`), fullPage: true });
await page.screenshot({ path: resolve(artifactRoot, `${name}-viewport.png`) });
}
return result;
};
for (const [route, root, table] of [
['nation/secret', '.secret-page', '#secret-general-list'],
['current-city', '.city-page', '.generals'],
] as const) {
test(`${route} 500px retains 100 generals without doubling list height`, async ({ page }) => {
test.setTimeout(60_000);
await install(page);
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto(route);
const desktop = await measure(page, root, table, `${route.replace('/', '-')}-1000`);
for (const width of [500, 501, 800]) {
await page.setViewportSize({ width, height: 900 });
const mobile = await measure(page, root, table, `${route.replace('/', '-')}-${width}`);
if (baseline) continue;
expect(mobile.page.width).toBe(500);
expect(mobile.table.width).toBe(500);
expect(mobile.page.x).toBeCloseTo((width - 500) / 2, 0);
expect(mobile.document.width).toBe(width);
expect(mobile.table.height / desktop.table.height).toBeLessThan(1.5);
expect(mobile.page.height / desktop.page.height).toBeLessThan(1.55);
expect(mobile.rows.map((row) => row.cells.map((cell) => cell.text))).toEqual(
desktop.rows.map((row) => row.cells.map((cell) => cell.text))
);
for (const row of mobile.rows)
for (const cell of row.cells) {
expect(cell.rect.width).toBeGreaterThan(0);
expect(cell.rect.x).toBeGreaterThanOrEqual(mobile.page.x - 1);
expect(cell.rect.x + cell.rect.width).toBeLessThanOrEqual(mobile.page.x + 501);
expect(cell.scrollWidth - cell.clientWidth).toBeLessThanOrEqual(1);
}
for (const image of mobile.images)
expect(image).toMatchObject({ rect: { width: 64, height: 64 }, naturalWidth: 64, naturalHeight: 64 });
}
if (baseline) return;
await page.setViewportSize({ width: 500, height: 900 });
if (route === 'nation/secret') {
const sort = page.locator('#secret-list-sort');
await sort.selectOption('1');
await page.locator('.title').getByRole('button', { name: '정렬' }).click();
await expect(page.locator(`${table} tbody tr`).first()).toHaveAttribute('data-general-id', '1');
const citySort = page.getByRole('button', { name: '도시 기준 정렬' });
await citySort.hover();
await citySort.focus();
await page.mouse.down();
await page.mouse.up();
await expect(page.locator('#secret-general-list th[aria-sort="ascending"]')).toContainText('도시');
const injury = page.locator('[data-directory-tooltip="secret-injury-name-1"]');
await injury.focus();
await expect(injury.getByRole('tooltip')).toBeVisible();
await injury.hover();
await expect(injury.getByRole('tooltip')).toContainText('부상 30%');
} else {
await page.locator('#citySelector').selectOption('2');
await expect(page).toHaveURL(/cityId=2/);
}
});
}
test('physical mobile switches both information pages through the settings 500/1000 radios', async ({
browser,
baseURL,
}) => {
test.skip(baseline);
const context = await browser.newContext({
baseURL,
viewport: { width: 390, height: 844 },
screen: { width: 390, height: 844 },
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
});
const page = await context.newPage();
await install(page);
for (const mode of ['1000px', '500px'] as const) {
await page.goto('my-settings');
await page.locator(`input[value="${mode}"]`).check();
for (const [route, root] of [
['nation/secret', '.secret-page .title'],
['current-city', '.city-page'],
] as const) {
await page.goto(route);
await expect(page.locator(root)).toBeVisible();
await expect
.poll(() => page.locator(root).evaluate((element) => element.getBoundingClientRect().width))
.toBe(mode === '500px' ? 500 : 1000);
expect(
await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
).toBeLessThanOrEqual(1);
await expect(
page.locator(route === 'nation/secret' ? '#secret-general-list tbody tr' : '.generals tbody tr')
).toHaveCount(100);
await waitForVisualAssets(page);
if (artifactRoot)
await page.screenshot({
path: resolve(artifactRoot, `physical-${route.replace('/', '-')}-${mode}.png`),
});
}
}
await context.close();
});
test('idle and empty information lists stay compact; doubled text wraps without losing fields', async ({ page }) => {
test.skip(baseline);
test.setTimeout(60_000);
await install(page, 'idle');
for (const [route, root, table] of [
['nation/secret', '.secret-page', '#secret-general-list'],
['current-city', '.city-page', '.generals'],
] as const) {
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto(route);
const desktop = await measure(page, root, table, `idle-${route.replace('/', '-')}-1000`);
await page.setViewportSize({ width: 500, height: 900 });
const mobile = await measure(page, root, table, `idle-${route.replace('/', '-')}-500`);
expect(mobile.table.height / desktop.table.height).toBeLessThan(1.8);
expect(mobile.document.width).toBe(500);
await page.unroute(gameTrpcRoute);
await install(page);
await page.goto(route);
const unscaled = await measure(page, root, table, `full-text-${route.replace('/', '-')}-500`);
await page.locator(`${root} td, ${root} th`).evaluateAll((elements) => {
const sizes = elements.map((element) => Number.parseFloat(getComputedStyle(element).fontSize));
elements.forEach((element, index) => {
const cell = element as HTMLElement;
cell.style.setProperty('font-size', `${sizes[index]! * 2}px`, 'important');
cell.style.setProperty('line-height', '1.3', 'important');
});
});
await page.addStyleTag({ content: `${root} td::before { font-size:22px !important; }` });
const large = await measure(page, root, table, `large-text-${route.replace('/', '-')}-500`);
expect(large.document.width).toBe(500);
expect(large.rows.map((row) => row.cells.map((cell) => cell.text))).toEqual(
unscaled.rows.map((row) => row.cells.map((cell) => cell.text))
);
for (const row of large.rows)
for (const cell of row.cells) expect(cell.scrollWidth - cell.clientWidth).toBeLessThanOrEqual(1);
await page.unroute(gameTrpcRoute);
await install(page, 'idle');
}
await page.unroute(gameTrpcRoute);
await install(page, 'empty');
for (const [route, table] of [
['nation/secret', '#secret-general-list'],
['current-city', '.generals'],
] as const) {
await page.goto(route);
// The mobile current-city table has no heading-only height when there are no rows.
await expect(page.locator(table)).toBeAttached();
await expect(page.locator(`${table} tbody tr`)).toHaveCount(0);
await expect(
page.locator(route === 'nation/secret' ? '.secret-page .footer' : '.city-page .footer')
).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(500);
}
});
@@ -22,6 +22,7 @@ export default defineConfig({
'troop.spec.ts',
'board.spec.ts',
'inGameInfo.spec.ts',
'infoMobileLayout.spec.ts',
'nationCityOfficeIntegration.spec.ts',
'inGameMenus.spec.ts',
'nationOffices.spec.ts',
+138 -16
View File
@@ -257,7 +257,7 @@ const commandBrief = (command: ReservedCommand): string =>
:data-is-our-general="general.train !== null"
:data-general-wounded="general.injury"
>
<td class="icon-cell">
<td data-field="icon" class="icon-cell">
<img
class="general-icon"
width="64"
@@ -266,26 +266,28 @@ const commandBrief = (command: ReservedCommand): string =>
@error="useDefaultGeneralIcon"
/>
</td>
<td :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
<td :class="{ wounded: general.injury !== 0 }">
<td data-field="name" :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
<td data-field="lead" data-label="" :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.leadership, general.injury)
}}<span v-if="general.leadershipBonus" class="leadership-bonus"
>+{{ general.leadershipBonus }}</span
>
</td>
<td :class="{ wounded: general.injury !== 0 }">
<td data-field="str" data-label="" :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.strength, general.injury) }}
</td>
<td :class="{ wounded: general.injury !== 0 }">
<td data-field="intel" data-label="" :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.intelligence, general.injury) }}
</td>
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
<td>{{ defenceTrainText(general.defenceTrain) }}</td>
<td>{{ general.crewTypeName ?? '?' }}</td>
<td>{{ general.crew ?? '?' }}</td>
<td>{{ general.train ?? '?' }}</td>
<td>{{ general.atmos ?? '?' }}</td>
<td class="turns" :class="{ 'turns--reserved': general.turns.length > 0 }">
<td data-field="office" data-label="관직">
{{ formatOfficerLevelText(general.officerLevel) }}
</td>
<td data-field="defence" data-label="">{{ defenceTrainText(general.defenceTrain) }}</td>
<td data-field="type" data-label="병종">{{ general.crewTypeName ?? '?' }}</td>
<td data-field="crew" data-label="병사">{{ general.crew ?? '?' }}</td>
<td data-field="train" data-label="">{{ general.train ?? '?' }}</td>
<td data-field="atmos" data-label="">{{ general.atmos ?? '?' }}</td>
<td data-field="turns" class="turns" :class="{ 'turns--reserved': general.turns.length > 0 }">
<template v-if="general.turns.length">
<span
v-for="(turn, index) in general.turns"
@@ -470,11 +472,131 @@ const commandBrief = (command: ReservedCommand): string =>
text-align: center;
color: #ff7373;
}
@media (max-width: 700px) {
/* 넓은 표의 모든 셀을 유지하고 초상/명령의 높이에 통계를 나란히 배치한다. */
@media (max-width: 939.98px) {
.city-page {
width: 1000px;
margin-top: 8px;
transform-origin: top left;
width: 500px;
}
.stats,
.stats tbody,
.generals,
.generals tbody {
display: block;
}
.stats colgroup,
.generals colgroup {
display: none;
}
.stats tr {
display: grid;
grid-template-columns: repeat(3, 40px minmax(0, 1fr));
}
.stats tr:first-child {
grid-template-columns: 1fr auto;
}
.stats tr:last-child {
grid-template-columns: 40px minmax(0, 1fr);
}
.stats td,
.stats th {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
align-content: center;
}
.generals {
width: 500px;
margin: 8px 0 0;
transform: none;
}
.generals thead {
display: none;
}
.generals tbody tr {
display: grid;
grid-template-columns: 64px 62px 68px 40px 40px minmax(0, 1fr);
grid-template-areas:
'icon name name name name turns'
'icon lead str intel intel turns'
'icon office office defence defence turns'
'icon type crew train atmos turns';
height: auto;
border: 1px solid gray;
border-bottom: 0;
}
.generals tbody tr:last-child {
border-bottom: 1px solid gray;
}
.generals tbody td {
min-width: 0;
padding: 0 2px;
border: 0;
line-height: 18.2px;
overflow-wrap: anywhere;
align-content: center;
}
.generals tbody td[data-label]::before {
content: attr(data-label);
margin-right: 3px;
font-size: 11px;
color: #bbb;
}
.generals [data-field='train'],
.generals [data-field='atmos'],
.generals [data-field='crew'] {
padding-inline: 1px;
}
.generals [data-field='train']::before,
.generals [data-field='atmos']::before,
.generals [data-field='crew']::before {
margin-right: 2px;
}
.generals td.turns--reserved {
line-height: 1.3;
}
.generals .icon-cell {
height: auto;
}
.generals td.turns {
border-left: 1px solid gray;
padding-inline: 4px;
text-align: left;
}
.generals [data-field='icon'] {
grid-area: icon;
}
.generals [data-field='name'] {
grid-area: name;
}
.generals [data-field='lead'] {
grid-area: lead;
}
.generals [data-field='str'] {
grid-area: str;
}
.generals [data-field='intel'] {
grid-area: intel;
}
.generals [data-field='office'] {
grid-area: office;
}
.generals [data-field='defence'] {
grid-area: defence;
}
.generals [data-field='type'] {
grid-area: type;
}
.generals [data-field='crew'] {
grid-area: crew;
}
.generals [data-field='train'] {
grid-area: train;
}
.generals [data-field='atmos'] {
grid-area: atmos;
}
.generals [data-field='turns'] {
grid-area: turns;
}
}
</style>
+217 -15
View File
@@ -241,9 +241,10 @@ onMounted(load);
v-for="general in generals"
:key="general.id"
:data-general-id="general.id"
:class="{ 'has-commands': general.npcState < 2 && general.reservedCommands.length > 0 }"
:data-npc-state="general.npcState"
>
<td>
<td data-field="name">
<DirectoryTooltip
:title="`부상 · ${injuryInfo(general.injury).text}`"
:description="injuryDescription(general)"
@@ -259,9 +260,9 @@ onMounted(load);
}"
>{{ displayName(general) }}</span
> </DirectoryTooltip
><br />Lv {{ general.experienceLevel }}
><br /><span class="general-level">Lv {{ general.experienceLevel }}</span>
</td>
<td>
<td data-field="stats" data-label="//">
<DirectoryTooltip
title="통솔 부상"
:description="
@@ -311,16 +312,16 @@ onMounted(load);
}}</span></DirectoryTooltip
>
</td>
<td>{{ general.troopName ?? '-' }}</td>
<td>{{ general.gold }}</td>
<td>{{ general.rice }}</td>
<td>{{ general.cityName ?? '-' }}</td>
<td>{{ general.defenceTrainText }}</td>
<td>{{ general.crewTypeName }}</td>
<td>{{ general.crew }}</td>
<td>{{ general.train }}</td>
<td>{{ general.atmos }}</td>
<td class="turns">
<td data-field="troop" data-label="부대">{{ general.troopName ?? '-' }}</td>
<td data-field="gold" data-label="">{{ general.gold }}</td>
<td data-field="rice" data-label="">{{ general.rice }}</td>
<td data-field="city" data-label="도시">{{ general.cityName ?? '-' }}</td>
<td data-field="defence" data-label="">{{ general.defenceTrainText }}</td>
<td data-field="type" data-label="병종">{{ general.crewTypeName }}</td>
<td data-field="crew" data-label="병사">{{ general.crew }}</td>
<td data-field="train" data-label="">{{ general.train }}</td>
<td data-field="atmos" data-label="">{{ general.atmos }}</td>
<td data-field="turns" class="turns">
<template v-if="general.npcState >= 2">NPC 장수</template
><template v-else
><div
@@ -332,8 +333,10 @@ onMounted(load);
</div></template
>
</td>
<td>{{ general.killTurn }}</td>
<td>{{ formatGameTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
<td data-field="kill" data-label="">{{ general.killTurn }}</td>
<td data-field="time" data-label="">
{{ formatGameTime(general.turnTime, { format: 'minuteSecond' }) }}
</td>
</tr>
</tbody>
</table>
@@ -521,4 +524,203 @@ th,
width: 22px;
}
}
/* 500px에서는 명령을 숨기지 않고 그 옆 높이를 통계에 사용한다.
* 예약이 없는 장수는 전체 폭 2행으로 배치해 빈 명령 칸 때문에 길어지지 않는다. */
@media (max-width: 939.98px) {
.secret-page {
width: 500px;
margin: 0 auto;
}
.layout,
.list {
width: 500px;
margin-inline: 0;
}
.summary,
.summary tbody {
display: block;
}
.summary tr {
display: grid;
grid-template-columns: 106px 144px 106px 144px;
}
.summary th,
.summary td {
box-sizing: border-box;
width: auto;
min-width: 0;
overflow-wrap: anywhere;
}
.list,
.list thead,
.list tbody {
display: block;
}
.list thead tr {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.list thead th:not(:has(button)) {
display: none;
}
.list thead button {
width: 100%;
min-height: 28px;
}
.list :is(th, td):nth-child(n) {
width: auto;
}
.list tbody tr {
display: grid;
grid-template-columns: repeat(100, minmax(0, 1fr));
height: auto;
border: 1px solid gray;
border-top: 0;
}
.list tbody tr.has-commands {
grid-template-columns: 64px 70px 70px 70px minmax(0, 1fr);
grid-template-areas:
'name name stats stats turns'
'troop troop city city turns'
'gold gold rice rice turns'
'type crew train atmos turns'
'defence kill time time turns';
}
.list tbody td {
min-width: 0;
padding: 0 2px;
border: 0;
font-size: 14px;
line-height: 18.2px;
overflow-wrap: anywhere;
align-content: center;
}
.list tbody td[data-label]::before {
content: attr(data-label);
margin-right: 3px;
font-size: 11px;
color: #bbb;
}
.list tbody td[data-field='name'] br {
display: none;
}
.general-level {
margin-left: 4px;
}
.list tbody .turns {
font-size: 11px;
line-height: 1.3;
border-left: 1px solid gray;
padding-inline: 4px;
text-align: left;
}
.list [data-field='name'] {
grid-area: name;
}
.list [data-field='stats'] {
grid-area: stats;
}
.list [data-field='troop'] {
grid-area: troop;
}
.list [data-field='gold'] {
grid-area: gold;
}
.list [data-field='rice'] {
grid-area: rice;
}
.list [data-field='city'] {
grid-area: city;
}
.list [data-field='defence'] {
grid-area: defence;
}
.list [data-field='type'] {
grid-area: type;
}
.list [data-field='crew'] {
grid-area: crew;
}
.list [data-field='train'] {
grid-area: train;
}
.list [data-field='atmos'] {
grid-area: atmos;
}
.list [data-field='turns'] {
grid-area: turns;
}
.list [data-field='kill'] {
grid-area: kill;
}
.list [data-field='time'] {
grid-area: time;
}
/* 예약이 없으면 서로 다른 열폭을 가진 2행을 100등분 grid 위에 놓는다.
* 첫 행: 이름/능력/부대/도시/NPC, 둘째 행: 자원/병력/훈사/턴. */
.list tbody tr:not(.has-commands) td {
grid-area: auto;
padding-inline: 1px;
}
.list tbody tr:not(.has-commands) td::before {
margin-right: 2px;
}
.list tbody tr:not(.has-commands) [data-field='name'] {
grid-row: 1;
grid-column: 1 / span 27;
}
.list tbody tr:not(.has-commands) [data-field='stats'] {
grid-row: 1;
grid-column: 28 / span 28;
}
.list tbody tr:not(.has-commands) [data-field='troop'] {
grid-row: 1;
grid-column: 56 / span 18;
}
.list tbody tr:not(.has-commands) [data-field='city'] {
grid-row: 1;
grid-column: 74 / span 12;
}
.list tbody tr:not(.has-commands) [data-field='turns'] {
grid-row: 1;
grid-column: 86 / span 15;
}
.list tbody tr:not(.has-commands) [data-field='gold'] {
grid-row: 2;
grid-column: 1 / span 19;
}
.list tbody tr:not(.has-commands) [data-field='rice'] {
grid-row: 2;
grid-column: 20 / span 16;
}
.list tbody tr:not(.has-commands) [data-field='defence'] {
grid-row: 2;
grid-column: 36 / span 7;
}
.list tbody tr:not(.has-commands) [data-field='type'] {
grid-row: 2;
grid-column: 43 / span 11;
}
.list tbody tr:not(.has-commands) [data-field='crew'] {
grid-row: 2;
grid-column: 54 / span 14;
}
.list tbody tr:not(.has-commands) [data-field='train'] {
grid-row: 2;
grid-column: 68 / span 8;
}
.list tbody tr:not(.has-commands) [data-field='atmos'] {
grid-row: 2;
grid-column: 76 / span 8;
}
.list tbody tr:not(.has-commands) [data-field='kill'] {
grid-row: 2;
grid-column: 84 / span 6;
}
.list tbody tr:not(.has-commands) [data-field='time'] {
grid-row: 2;
grid-column: 90 / span 11;
}
}
</style>
+43 -22
View File
@@ -85,28 +85,28 @@ storage, route guards, and image loading.
## Enforced contracts
| Screen | Ref entry point | Current automated contract |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| gateway Kakao OTP | `index.php#modalOTP` | 동일 문구·500px modal, desktop/mobile geometry와 색상·typography, password/OAuth 진입, autofocus·focus-visible·active·disabled·오류 재시도·session 저장 |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| current city | `hwe/b_currentCity.php` | main-page Pretendard 14px, wrapping general-name summary, small reserved-turn lines but normal-size NPC labels, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
| 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 |
| 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, 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 |
| 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 |
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, browser Web Worker calculation including 1000 repeats, fixed-seed result/logs, retained input after API error |
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
| tournament | `hwe/b_tournament.php` | fixed 2000px Ref canvas and eight 250px group tables; Core responsive bracket, four-column/one-tab group cards with equal-height 64px empty slots and grouped stat/record/score summaries, long-name ellipsis/title, all eight latest preliminary/final-group fight logs, latest knockout/final log, safe Ref marker colors, desktop/mobile containment |
| tournament betting | `hwe/b_betting.php` | Core intentional UX: inline per-candidate number/preset input and one-click bet, visible investment/odds/return, 340px desktop first-round cards and mobile paired cards; `e2e/tournamentBracket.spec.ts` owns responsive interaction/geometry and retained per-candidate drafts |
| Screen | Ref entry point | Current automated contract |
| -------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| gateway Kakao OTP | `index.php#modalOTP` | 동일 문구·500px modal, desktop/mobile geometry와 색상·typography, password/OAuth 진입, autofocus·focus-visible·active·disabled·오류 재시도·session 저장 |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| current city | `hwe/b_currentCity.php` | main-page Pretendard 14px, wrapping general-name summary, small reserved-turn lines but normal-size NPC labels, 1000px desktop summary/general tables, 500px mobile summary reflow and compact general grid with all commands visible, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
| 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 |
| 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, 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 |
| 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 |
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, browser Web Worker calculation including 1000 repeats, fixed-seed result/logs, retained input after API error |
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
| tournament | `hwe/b_tournament.php` | fixed 2000px Ref canvas and eight 250px group tables; Core responsive bracket, four-column/one-tab group cards with equal-height 64px empty slots and grouped stat/record/score summaries, long-name ellipsis/title, all eight latest preliminary/final-group fight logs, latest knockout/final log, safe Ref marker colors, desktop/mobile containment |
| tournament betting | `hwe/b_betting.php` | Core intentional UX: inline per-candidate number/preset input and one-click bet, visible investment/odds/return, 340px desktop first-round cards and mobile paired cards; `e2e/tournamentBracket.spec.ts` owns responsive interaction/geometry and retained per-candidate drafts |
The global game baseline is black, white, Pretendard 14px. Legacy texture
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
@@ -120,6 +120,27 @@ legacy-compatible `after_vote` mode. The mutation fixture covers voting and
comment submission, while the error fixture confirms that a failed vote keeps
the selected radio option so the user can retry.
## Compact information layouts
`app/game-frontend/e2e/infoMobileLayout.spec.ts` verifies the intentional Core
500px layouts for `/nation/secret` and `/current-city`. Both keep every visible
cell, command and image from the desktop dataset. Statistics share vertical space
with reserved commands; current-city portraits remain 64px. Empty reservation
cells in the secret list use a shorter full-width two-row layout.
The fixture measures 100 generals with five-command and NPC rows at CSS viewport
widths 1000, 500, 501 and 800. The mixed-list height must stay below 1.5 times the
same desktop list, and page height below 1.55 times; idle lists stay below 1.8 times.
These limits apply to the deterministic fixture, not arbitrary-length user text.
It also verifies exact cell text preservation, no horizontal overflow, sorting,
injury tooltip interaction, city selection, empty lists, doubled text, and actual
500/1000 settings radios in a 390px physical mobile context with DPR 2.
Set `INFO_LAYOUT_ARTIFACT_DIR` to an ignored absolute directory to save full-page
and viewport screenshots plus DOM/geometry JSON. Set `FRONTEND_PARITY_IMAGE_ROOT`
to the workspace image directory when testing in a nested worktree. This is
production-bundle Chromium with mocked read APIs, not live DB or deployment proof.
## Route coverage rule
Adding or changing a frontend route requires: