Merge branch 'main' into feature/auction-menu-parity

This commit is contained in:
2026-07-26 05:39:29 +00:00
27 changed files with 2955 additions and 384 deletions
@@ -0,0 +1,384 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
error: {
message,
code: -32000,
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
},
});
const operationNames = (route: Route): string[] => {
const pathname = new URL(route.request().url()).pathname;
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const general = {
id: 1,
name: '테스트장수',
npcState: 0,
nationId: 1,
cityId: 1,
troopId: 0,
picture: 'default.jpg',
imageServer: 0,
officerLevel: 1,
stats: { leadership: 80, strength: 70, intelligence: 90 },
gold: 1000,
rice: 1000,
crew: 500,
train: 100,
atmos: 100,
injury: 0,
experience: 1200,
dedication: 900,
items: { horse: null, weapon: null, book: null, item: null },
};
const generalContext = {
general,
city: {
id: 1,
name: '낙양',
level: 7,
nationId: 1,
population: 50000,
agriculture: 5000,
commerce: 5000,
security: 5000,
defence: 5000,
wall: 5000,
supplyState: 1,
frontState: 2,
},
nation: {
id: 1,
name: '테스트국',
color: '#d32f2f',
level: 5,
gold: 10000,
rice: 10000,
tech: 1200,
typeCode: 'che_군벌',
capitalCityId: 1,
},
settings: {},
penalties: {},
};
const target = (generalId: number, generalName: string, nationId: number, nationName: string, color: string) => ({
generalId,
generalName,
nationId,
nationName,
color,
icon: '/image/icons/default.jpg',
});
const ownTarget = target(1, '테스트장수', 1, '테스트국', '#d32f2f');
const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6');
const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19);
const buildMessages = (permission: number) => ({
result: true,
public: [
{
id: 101,
msgType: 'public',
src: ownTarget,
dest: null,
text: '전체 메시지 본문',
option: {},
time: messageTime,
},
],
national: [
{
id: 102,
msgType: 'national',
src: ownTarget,
dest: target(0, '', 1, '테스트국', '#d32f2f'),
text: '국가 메시지 본문',
option: {},
time: messageTime,
},
],
private: [
{
id: 103,
msgType: 'private',
src: foreignTarget,
dest: ownTarget,
text: '개인 메시지 본문',
option: {},
time: messageTime,
},
],
diplomacy: [
{
id: 104,
msgType: 'diplomacy',
src: foreignTarget,
dest: target(0, '', 1, '테스트국', '#d32f2f'),
text: permission >= 3 ? '외교 메시지 본문' : '(외교 메시지입니다)',
option:
permission >= 3
? { action: 'noAggression', deletable: false }
: { action: 'noAggression', deletable: false, invalid: true },
time: messageTime,
},
],
sequence: 104,
nationId: 1,
generalName: general.name,
permission,
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
latestRead: { private: 0, diplomacy: 0 },
});
const contacts = {
nation: [
{
nationId: 0,
mailbox: 9000,
name: '재야',
color: '#000000',
general: [],
},
{
nationId: 1,
mailbox: 9001,
name: '테스트국',
color: '#d32f2f',
general: [
[1, '테스트장수', 4],
[2, '아군군주', 1],
],
},
{
nationId: 2,
mailbox: 9002,
name: '상대국',
color: '#2457a6',
general: [
[8, '상대외교관', 4],
[9, '상대일반', 0],
],
},
],
};
const installFixture = async (
page: Page,
options: { permission: number; sendError?: string }
): Promise<Array<{ operation: string; body: unknown }>> => {
const mutations: Array<{ operation: string; body: unknown }> = [];
await page.addInitScript(
({ gameToken, profile }) => {
window.localStorage.setItem('sammo-game-token', gameToken);
window.localStorage.setItem('sammo-game-profile', profile);
},
{
gameToken: fixture.game.session.gameToken,
profile: fixture.game.session.profile,
}
);
await page.route('**/image/**', (route) => route.fulfill({ status: 204, body: '' }));
await page.route('**/che/api/events**', (route) => route.abort());
await page.route('**/che/api/trpc/**', async (route) => {
const body = route.request().postDataJSON();
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') {
return response({ ...fixture.game.lobby, myGeneral: general });
}
if (operation === 'general.me') return response(generalContext);
if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout);
if (operation === 'world.getMap') {
return response({ ...fixture.game.map, myCity: 1, myNation: 1 });
}
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
return response([]);
}
if (operation === 'messages.getRecent') return response(buildMessages(options.permission));
if (operation === 'messages.getContacts') return response(contacts);
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
if (operation === 'tournament.getState') return response({ stage: 0 });
if (
operation === 'messages.send' ||
operation === 'messages.readLatest' ||
operation === 'messages.delete' ||
operation === 'messages.respond'
) {
mutations.push({ operation, body });
if (operation === 'messages.send' && options.sendError) {
return errorResponse(operation, options.sendError);
}
return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true });
}
return errorResponse(operation, `Unhandled message fixture operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
return mutations;
};
const openMessages = async (page: Page, viewport: { width: number; height: number }) => {
await page.setViewportSize(viewport);
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
if (viewport.width <= 1024) {
await page.getByRole('button', { name: '메시지', exact: true }).click();
}
await expect(page.locator('.MessagePanel')).toBeVisible();
};
for (const viewport of [
{ width: 1000, height: 900 },
{ width: 500, height: 900 },
]) {
test(`matches the reference message computed DOM at ${viewport.width}px Chromium viewport`, async ({ page }) => {
await installFixture(page, { permission: 4 });
await openMessages(page, viewport);
const geometry = await page.locator('.MessagePanel').evaluate((panel) => {
const required = (selector: string) => panel.querySelector<HTMLElement>(selector)!;
const rect = (element: Element) => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
};
const panelStyle = getComputedStyle(panel);
const header = required('.BoardHeader');
const plate = required('.msg-plate');
const icon = required('.general-icon');
return {
panel: rect(panel),
inputForm: rect(required('.MessageInputForm')),
select: rect(required('.message-select')),
input: rect(required('.message-text')),
submit: rect(required('.message-send')),
publicSection: rect(required('.PublicTalk')),
nationalSection: rect(required('.NationalTalk')),
firstHeader: rect(header),
firstPlate: rect(plate),
firstIcon: rect(icon),
computed: {
panelDisplay: panelStyle.display,
panelColumns: panelStyle.gridTemplateColumns,
panelFontSize: panelStyle.fontSize,
headerColor: getComputedStyle(header).color,
headerOutlineWidth: getComputedStyle(header).outlineWidth,
plateBackgroundColor: getComputedStyle(plate).backgroundColor,
plateFontSize: getComputedStyle(plate).fontSize,
plateMinHeight: getComputedStyle(plate).minHeight,
iconObjectFit: getComputedStyle(icon).objectFit,
},
};
});
expect(geometry.panel.x).toBeCloseTo(0, 0);
expect(geometry.panel.width).toBeCloseTo(viewport.width, 0);
expect(geometry.inputForm.width).toBeCloseTo(viewport.width, 0);
expect(geometry.select.height).toBeCloseTo(35.5, 0);
expect(geometry.submit.height).toBeCloseTo(35.5, 0);
expect(geometry.firstHeader.height).toBeCloseTo(25, 0);
expect(geometry.firstPlate.height).toBeGreaterThanOrEqual(64);
expect(geometry.firstIcon).toMatchObject({ width: 64, height: 64 });
expect(geometry.computed).toMatchObject({
panelFontSize: '14px',
headerColor: 'rgb(255, 255, 255)',
headerOutlineWidth: '1px',
plateBackgroundColor: 'rgb(20, 28, 101)',
plateFontSize: '12.5px',
plateMinHeight: '64px',
iconObjectFit: 'fill',
});
if (viewport.width === 1000) {
expect(geometry.computed.panelDisplay).toBe('grid');
expect(geometry.computed.panelColumns).toBe('500px 500px');
expect(geometry.select.width).toBeCloseTo(166.66, 0);
expect(geometry.input.width).toBeCloseTo(666.66, 0);
expect(geometry.submit.width).toBeCloseTo(166.66, 0);
expect(geometry.publicSection.width).toBeCloseTo(500, 0);
expect(geometry.nationalSection.x).toBeCloseTo(500, 0);
} else {
expect(geometry.computed.panelDisplay).toBe('block');
expect(geometry.select.width).toBeCloseTo(250, 0);
expect(geometry.input.width).toBeCloseTo(500, 0);
expect(geometry.input.height).toBeCloseTo(33.5, 0);
expect(geometry.submit.width).toBeCloseTo(250, 0);
}
const submit = page.locator('.message-send');
await submit.hover();
expect(
await submit.evaluate((element) => ({
cursor: getComputedStyle(element).cursor,
backgroundColor: getComputedStyle(element).backgroundColor,
}))
).toEqual({ cursor: 'pointer', backgroundColor: 'rgb(55, 90, 127)' });
await submit.focus();
expect(
await submit.evaluate((element) => ({
outlineWidth: getComputedStyle(element).outlineWidth,
boxShadow: getComputedStyle(element).boxShadow,
}))
).toEqual({ outlineWidth: '0px', boxShadow: 'none' });
});
}
test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => {
const mutations = await installFixture(page, { permission: 4 });
await openMessages(page, { width: 500, height: 900 });
const select = page.getByLabel('메시지 수신 대상');
await expect(select.locator('option[value="9002"]')).toHaveCount(1);
await expect(select.locator('option[value="8"]')).toBeDisabled();
await expect(select.locator('option[value="9"]')).toBeEnabled();
await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대장수' }).click();
await expect(select).toHaveValue('8');
await page.locator('.PrivateTalk').getByRole('button', { name: '모두 읽음' }).click();
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.readLatest').length).toBe(1);
const deleteButton = page.locator('.PublicTalk .delete-message');
page.once('dialog', (dialog) => dialog.accept());
await deleteButton.click();
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1);
await select.selectOption('9999');
await page.getByLabel('메시지 입력').fill('전송 성공');
await page.getByRole('button', { name: '서신전달&갱신' }).click();
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
});
test('redacts diplomacy for a low-permission general and preserves the failed-send error flow', async ({ page }) => {
const mutations = await installFixture(page, {
permission: 2,
sendError: '공개 메세지를 보낼 수 없습니다.',
});
await openMessages(page, { width: 500, height: 900 });
const select = page.getByLabel('메시지 수신 대상');
await expect(select.locator('option[value="9002"]')).toHaveCount(0);
await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다');
await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문');
await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled();
await select.selectOption('9999');
await page.getByLabel('메시지 입력').fill('차단될 메시지');
await page.getByRole('button', { name: '서신전달&갱신' }).click();
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
await expect(page.locator('.error')).toHaveText('공개 메세지를 보낼 수 없습니다.');
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
});
@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route): string[] => {
@@ -90,6 +91,7 @@ const messageBundle = (visible: boolean, canRespondDiplomacy = true) => ({
sequence: visible ? diplomacyMessage.id : -1,
nationId: 1,
generalName: general.name,
permission: canRespondDiplomacy ? 4 : 2,
canRespondDiplomacy,
latestRead: { diplomacy: 0, private: 0 },
});
@@ -131,6 +133,9 @@ const installFixture = async (
if (operation === 'messages.getRecent') {
return response(messageBundle(visible, options.canRespondDiplomacy));
}
if (operation === 'messages.getContacts') return response({ nation: [] });
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
if (operation === 'tournament.getState') return response({ stage: 0 });
if (operation === 'messages.respond') {
mutations.push({ operation, body: requestBody });
if (options.acceptResponse) {
@@ -151,9 +156,9 @@ const installFixture = async (
};
const openDiplomacyTab = async (page: Page) => {
await page.goto('http://127.0.0.1:15102/che/');
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
await page.getByRole('button', { name: '외교', exact: true }).last().click();
await expect(page.locator('.DiplomacyTalk')).toBeVisible();
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
};
@@ -186,16 +191,16 @@ test.describe('instant diplomacy response UI', () => {
});
expect(geometry.buttons).toHaveLength(2);
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(4, 0);
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(0, 0);
expect(geometry.buttons[0]).toMatchObject({
color: 'rgb(143, 209, 143)',
fontSize: '11.2px',
color: 'rgb(255, 255, 255)',
fontSize: '12.5px',
borderWidth: '1px',
cursor: 'pointer',
});
expect(geometry.buttons[1]).toMatchObject({
color: 'rgb(224, 154, 154)',
fontSize: '11.2px',
color: 'rgb(255, 255, 255)',
fontSize: '12.5px',
borderWidth: '1px',
cursor: 'pointer',
});
@@ -234,18 +239,17 @@ test.describe('instant diplomacy response UI', () => {
test('keeps the message and exposes a rejected response on mobile Chromium', async ({ page }) => {
const mutations = await installFixture(page, { acceptResponse: false });
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('http://127.0.0.1:15102/che/');
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
await page.getByRole('button', { name: '메시지', exact: true }).click();
await page.getByRole('button', { name: '외교', exact: true }).click();
const responseRow = page.locator('.message-response');
await expect(responseRow).toBeVisible();
const itemWidth = await page
.locator('.message-item')
.locator('.DiplomacyTalk .msg-plate')
.evaluate((element) => element.getBoundingClientRect().width);
expect(itemWidth).toBeGreaterThan(320);
expect(itemWidth).toBeLessThanOrEqual(342);
expect(itemWidth).toBeGreaterThanOrEqual(389);
expect(itemWidth).toBeLessThanOrEqual(390);
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('거절하시겠습니까?');
@@ -272,10 +276,9 @@ test.describe('instant diplomacy response UI', () => {
canRespondDiplomacy: false,
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('http://127.0.0.1:15102/che/');
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
await page.getByRole('button', { name: '메시지', exact: true }).click();
await page.getByRole('button', { name: '외교', exact: true }).click();
const accept = page.locator('.message-response').getByRole('button', { name: '수락' });
await expect(accept).toBeDisabled();
@@ -284,7 +287,7 @@ test.describe('instant diplomacy response UI', () => {
const style = getComputedStyle(element);
return { cursor: style.cursor, opacity: style.opacity };
})
).toEqual({ cursor: 'not-allowed', opacity: '0.5' });
).toEqual({ cursor: 'not-allowed', opacity: '0.65' });
await accept.click({ force: true });
expect(mutations).toHaveLength(0);
});
@@ -0,0 +1,237 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { chromium } from '@playwright/test';
const baseUrl = process.env.REF_NATION_BETTING_URL ?? 'https://dev-sam-ref.hided.net/sam/';
const staticBaseUrl = process.env.REF_NATION_BETTING_STATIC_BASE_URL;
const username = process.env.REF_NATION_BETTING_USER ?? 'refuser1';
const passwordFile = process.env.REF_NATION_BETTING_PASSWORD_FILE;
const artifactRoot = resolve(process.env.REF_NATION_BETTING_ARTIFACT_DIR ?? 'test-results/reference-nation-betting');
if (!staticBaseUrl && !passwordFile) {
throw new Error('REF_NATION_BETTING_PASSWORD_FILE is required.');
}
const password = passwordFile ? (await readFile(passwordFile, 'utf8')).trim() : '';
const bettingList = {
result: true,
bettingList: {
7: {
id: 7,
type: 'bettingNation',
name: '천통국 예상',
finished: false,
selectCnt: 2,
isExclusive: false,
reqInheritancePoint: true,
openYearMonth: 2316,
closeYearMonth: 2340,
winner: null,
totalAmount: 800,
},
},
year: 193,
month: 1,
};
const bettingDetail = {
result: true,
bettingInfo: {
id: 7,
type: 'bettingNation',
name: '천통국 예상',
finished: false,
selectCnt: 2,
isExclusive: false,
reqInheritancePoint: true,
openYearMonth: 2316,
closeYearMonth: 2340,
candidates: [
{ title: '촉', info: '국력: 1200<br>장수 수: 8<br>도시 수: 5', isHtml: true },
{ title: '위', info: '국력: 1100<br>장수 수: 7<br>도시 수: 4', isHtml: true },
{ title: '오', info: '국력: 900<br>장수 수: 6<br>도시 수: 3', isHtml: true },
{ title: '연', info: '국력: 700<br>장수 수: 5<br>도시 수: 2', isHtml: true },
{ title: '양', info: '국력: 650<br>장수 수: 4<br>도시 수: 2', isHtml: true },
{ title: '형', info: '국력: 600<br>장수 수: 3<br>도시 수: 1', isHtml: true },
],
winner: null,
},
bettingDetail: [
['[-1]', 500],
['[0,1]', 200],
['[1,2]', 100],
],
myBetting: [['[0,1]', 50]],
remainPoint: 1200,
year: 193,
month: 1,
};
const login = async (context, page) => {
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
const globalSalt = await page.locator('#global_salt').inputValue();
// The reference entrance polls install status and can keep the PHP session
// occupied. Leave it before the login request so the session lock is free.
await page.goto('about:blank');
await context.clearCookies();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
timeout: 60_000,
});
const result = await response.json();
if (!response.ok() || result.result !== true) {
throw new Error('Reference login failed.');
}
};
const installBettingFixture = async (page) => {
await page.route('**/api.php*', async (route) => {
const path = new URL(route.request().url()).searchParams.get('path');
if (path === 'Betting/GetBettingList') {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bettingList) });
return;
}
if (path === 'Betting/GetBettingDetail') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(bettingDetail),
});
return;
}
await route.continue();
});
};
const mountStaticReference = async (page) => {
const hweUrl = new URL('hwe/', staticBaseUrl);
const assetUrl = new URL('dist_js/hwe_dynamic/vue/', staticBaseUrl);
await page.setContent(
`<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=500">
<base href="${hweUrl}">
<link rel="stylesheet" href="${new URL('d_shared/common.css', hweUrl)}">
<link rel="stylesheet" href="${new URL('vendors.css', assetUrl)}">
<link rel="stylesheet" href="${new URL('common_ts.css', assetUrl)}">
<link rel="stylesheet" href="${new URL('bootstrap.css', assetUrl)}">
<link rel="stylesheet" href="${new URL('v_nationBetting.css', assetUrl)}">
</head>
<body>
<div id="app"></div>
<script src="${new URL('d_shared/common_path.js', hweUrl)}"></script>
<script src="${new URL('vendors.js', assetUrl)}"></script>
<script src="${new URL('common_ts.js', assetUrl)}"></script>
<script src="${new URL('bootstrap.js', assetUrl)}"></script>
<script src="${new URL('v_nationBetting.js', assetUrl)}"></script>
</body>
</html>`,
{ waitUntil: 'networkidle' }
);
};
const measure = async (browser, viewport) => {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
colorScheme: 'dark',
locale: 'ko-KR',
timezoneId: 'UTC',
ignoreHTTPSErrors: true,
});
try {
const page = await context.newPage();
await installBettingFixture(page);
if (staticBaseUrl) {
await mountStaticReference(page);
} else {
await login(context, page);
await page.goto(new URL('hwe/v_nationBetting.php', baseUrl).toString(), {
waitUntil: 'networkidle',
timeout: 60_000,
});
}
await page.locator('.bettingItem').click();
await page.locator('.bettingCandidate').first().waitFor({ state: 'visible' });
const geometry = await page.locator('#container').evaluate((container) => {
const rect = (element) => {
const value = element.getBoundingClientRect();
return { x: value.x, y: value.y, width: value.width, height: value.height };
};
const cards = Array.from(container.querySelectorAll('.bettingCandidate'));
const firstCard = cards[0];
const cardStyle = getComputedStyle(firstCard);
const titleStyle = getComputedStyle(firstCard.querySelector('.title'));
const optionalRect = (selector) => {
const element = container.querySelector(selector);
return element ? rect(element) : null;
};
return {
container: rect(container),
topBar: rect(container.querySelector('.back_bar')),
candidateCells: Array.from(container.querySelectorAll('.bettingCandidates > div')).map(rect),
candidates: cards.map(rect),
bettingForm: optionalRect('.bettingCandidates + .row'),
payoutTable: optionalRect('.bettingCandidates + .row + div'),
bettingList: optionalRect('.bettingList'),
bottomBar: optionalRect('.bottom_bar, .bg0[style]'),
cardStyle: {
borderWidth: cardStyle.borderWidth,
borderRadius: cardStyle.borderRadius,
cursor: cardStyle.cursor,
fontSize: cardStyle.fontSize,
lineHeight: cardStyle.lineHeight,
},
titleStyle: {
fontWeight: titleStyle.fontWeight,
textAlign: titleStyle.textAlign,
},
};
});
await page.locator('.bettingCandidate').first().click();
const pickedStyle = await page
.locator('.bettingCandidate')
.first()
.evaluate((candidate) => {
const style = getComputedStyle(candidate);
return {
borderColor: style.borderColor,
outlineWidth: style.outlineWidth,
titleWeight: getComputedStyle(candidate.querySelector('.title')).fontWeight,
};
});
const screenshotPath = resolve(artifactRoot, `nation-betting-ref-${viewport.name}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true, animations: 'disabled' });
return { geometry, pickedStyle, screenshotPath };
} finally {
await context.close();
}
};
await mkdir(artifactRoot, { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const result = {};
for (const viewport of [
{ name: 'desktop', width: 1280, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
result[viewport.name] = await measure(browser, viewport);
}
const outputPath = resolve(artifactRoot, 'computed-dom.json');
await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, outputPath })}\n`);
} finally {
await browser.close();
}
@@ -12,6 +12,7 @@ export default defineConfig({
'visual-parity.spec.ts',
'public-gaps.spec.ts',
'instant-diplomacy-message.spec.ts',
'ingame-message-parity.spec.ts',
'tournament-betting.spec.ts',
'inheritance-management.spec.ts',
],
@@ -4,10 +4,7 @@ import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const imageRoots = [
resolve(repositoryRoot, '../image/game'),
resolve(repositoryRoot, '../../image/game'),
];
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
const response = (data: unknown) => ({ result: { data } });
@@ -214,12 +211,39 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
const geometry = await page.locator('#nation-betting-container').evaluate((container) => {
const containerRect = container.getBoundingClientRect();
const bar = container.querySelector<HTMLElement>('.legacy-top-bar')!.getBoundingClientRect();
const detail = container.querySelector<HTMLElement>('.betting-detail')!;
const detailRect = detail.getBoundingClientRect();
const candidateRowElement = container.querySelector<HTMLElement>('.betting-candidates')!;
const candidateRow = candidateRowElement.getBoundingClientRect();
const candidateCells = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate-cell'));
const cards = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate'));
const cardStyle = getComputedStyle(cards[0]!);
const optionalRect = (selector: string) => {
const element = container.querySelector<HTMLElement>(selector);
if (!element) return null;
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
};
return {
container: { x: containerRect.x, width: containerRect.width },
container: { x: containerRect.x, width: containerRect.width, height: containerRect.height },
bar: { width: bar.width, height: bar.height },
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
detail: { x: detailRect.x, width: detailRect.width },
candidateRow: {
x: candidateRow.x,
width: candidateRow.width,
},
candidateCells: candidateCells.map((cell) => {
const rect = cell.getBoundingClientRect();
return { x: rect.x, width: rect.width };
}),
cards: cards.map((card) => {
const rect = card.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}),
bettingForm: optionalRect('.betting-form'),
payoutTable: optionalRect('.payout-table'),
bettingList: optionalRect('.betting-list'),
bottomBar: optionalRect('.betting-footer'),
cardStyle: {
borderWidth: cardStyle.borderWidth,
borderRadius: cardStyle.borderRadius,
@@ -229,24 +253,43 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
},
};
});
expect(geometry.container).toEqual({ x: 140, width: 1000 });
expect(geometry.container).toEqual({ x: 140, width: 1000, height: 435 });
expect(geometry.bar).toEqual({ width: 1000, height: 32 });
expect(geometry.cardWidths.every((width) => Math.abs(width - 162) < 1)).toBe(true);
expect(geometry.detail).toEqual({ x: 140, width: 1000 });
expect(geometry.candidateRow).toEqual({ x: 138.25, width: 1003.5 });
expect(geometry.candidateCells.map(({ width }) => width)).toEqual(Array(6).fill(167.25));
expect(geometry.cards.map(({ width }) => width)).toEqual(Array(6).fill(163.75));
expect(geometry.cards.map(({ height }) => height)).toEqual(Array(6).fill(143));
expect(geometry.cards.map(({ y }) => y)).toEqual(Array(6).fill(53));
expect(geometry.bettingForm).toEqual({ x: 140, y: 196, width: 1000, height: 35.5 });
expect(geometry.payoutTable).toEqual({ x: 140, y: 231.5, width: 1000, height: 85 });
expect(geometry.bettingList).toEqual({ x: 140, y: 330.5, width: 1000, height: 45.5 });
expect(geometry.bottomBar).toEqual({ x: 140, y: 379.5, width: 1000, height: 55.5 });
expect(geometry.cardStyle).toEqual({
borderWidth: '1px',
borderRadius: '7px',
cursor: 'pointer',
fontSize: '14px',
lineHeight: '18.2px',
lineHeight: '21px',
});
await expect(page.locator('.legacy-top-bar .legacy-nav-button')).toHaveCount(1);
await expect(page.locator('.payout-row:not(.payout-head)').first().locator('div').nth(2)).toHaveText(
'(50 -> 100.0)'
);
await page.locator('.betting-candidate').nth(0).click();
await page.locator('.betting-candidate').nth(1).click();
const pickedStyle = await page.locator('.betting-candidate').first().evaluate((candidate) => {
const style = getComputedStyle(candidate);
return { borderColor: style.borderColor, outlineWidth: style.outlineWidth, titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight };
});
const pickedStyle = await page
.locator('.betting-candidate')
.first()
.evaluate((candidate) => {
const style = getComputedStyle(candidate);
return {
borderColor: style.borderColor,
outlineWidth: style.outlineWidth,
titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight,
};
});
expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)');
// Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1.
expect(pickedStyle.outlineWidth).toBe('1px');
@@ -281,6 +324,7 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
return {
x: rect.x,
width: rect.width,
firstX: cards[0]!.getBoundingClientRect().x,
firstWidth: cards[0]!.getBoundingClientRect().width,
fourthY: cards[3]!.getBoundingClientRect().y,
firstY: cards[0]!.getBoundingClientRect().y,
@@ -288,7 +332,8 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
});
expect(geometry.x).toBe(0);
expect(geometry.width).toBe(500);
expect(geometry.firstWidth).toBeCloseTo(161.328125, 3);
expect(geometry.firstX).toBe(0);
expect(geometry.firstWidth).toBe(164.328125);
expect(geometry.fourthY).toBeGreaterThan(geometry.firstY);
if (artifactRoot) {
@@ -325,17 +370,7 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
expect(geometry.tableWidth).toBe(1000);
// The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table.
expect(geometry.headerWidths).toEqual([
104.609375,
104.609375,
69.734375,
121.015625,
69.734375,
90.25,
69.734375,
69.734375,
69.734375,
69.734375,
80,
104.609375, 104.609375, 69.734375, 121.015625, 69.734375, 90.25, 69.734375, 69.734375, 69.734375, 69.734375, 80,
80.109375,
]);
expect(geometry.headerStyle).toEqual({
@@ -346,7 +381,10 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
lineHeight: '18.2px',
});
await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우');
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS('color', 'rgb(135, 206, 235)');
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS(
'color',
'rgb(135, 206, 235)'
);
const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first();
await personality.hover();
await expect(personality.getByRole('tooltip')).toBeVisible();
@@ -0,0 +1,178 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { chromium } from '@playwright/test';
const baseUrl = process.env.REF_MESSAGE_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_MESSAGE_USER ?? 'refuser1';
const passwordFile = process.env.REF_MESSAGE_PASSWORD_FILE;
const artifactRoot = process.env.REF_MESSAGE_ARTIFACT_DIR;
if (!passwordFile) {
throw new Error('REF_MESSAGE_PASSWORD_FILE is required.');
}
const password = (await readFile(passwordFile, 'utf8')).trim();
const login = async (context, page) => {
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
const globalSalt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
});
const result = await response.json();
if (!response.ok() || result.result !== true) {
throw new Error('Reference login failed.');
}
};
const ensureGeneral = async (page) => {
await page.goto(new URL('hwe/index.php', baseUrl).toString(), {
waitUntil: 'networkidle',
timeout: 60_000,
});
if (await page.locator('.MessagePanel').isVisible()) {
return;
}
await page.goto(new URL('hwe/v_join.php', baseUrl).toString(), {
waitUntil: 'networkidle',
timeout: 60_000,
});
const create = page.getByRole('button', { name: '장수 생성', exact: true });
await create.waitFor({ state: 'visible', timeout: 30_000 });
page.once('dialog', (dialog) => dialog.accept());
await create.click();
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 60_000 });
};
const measure = async (browser, name, viewport) => {
const context = await browser.newContext({
viewport,
deviceScaleFactor: 1,
colorScheme: 'dark',
locale: 'ko-KR',
timezoneId: 'UTC',
ignoreHTTPSErrors: true,
});
try {
const page = await context.newPage();
await login(context, page);
await ensureGeneral(page);
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.BoardHeader').first().waitFor({ state: 'visible' });
const marker = `computed-dom-${name}-${Date.now()}`;
await page.locator('.MessageInputForm select').selectOption('9999');
await page.locator('.MessageInputForm input').fill(marker);
await page.getByRole('button', { name: '서신전달&갱신' }).click();
await page.getByText(marker, { exact: true }).waitFor({ state: 'visible', timeout: 30_000 });
if (artifactRoot) {
const path = resolve(artifactRoot, `message-ref-${name}.png`);
await mkdir(dirname(path), { recursive: true });
await page.locator('.MessagePanel').screenshot({
path,
animations: 'disabled',
});
}
const result = await page.evaluate(() => {
const rect = (element) => {
const box = element.getBoundingClientRect();
return {
x: box.x,
y: box.y,
width: box.width,
height: box.height,
};
};
const required = (selector) => {
const element = document.querySelector(selector);
if (!element) throw new Error(`Missing reference selector: ${selector}`);
return element;
};
const optionalRect = (selector) => {
const element = document.querySelector(selector);
return element ? rect(element) : null;
};
const style = (selector) => getComputedStyle(required(selector));
const input = required('.MessageInputForm input');
const select = required('.MessageInputForm select');
const submit = required('#msg_submit-col button');
const firstPlate = document.querySelector('.msg_plate');
const firstIcon = document.querySelector('.msg_plate .generalIcon');
const panelStyle = style('.MessagePanel');
const headerStyle = style('.BoardHeader');
const plateStyle = firstPlate ? getComputedStyle(firstPlate) : null;
const iconStyle = firstIcon ? getComputedStyle(firstIcon) : null;
return {
panel: rect(required('.MessagePanel')),
inputForm: rect(required('.MessageInputForm')),
select: rect(select),
input: rect(input),
submit: rect(submit),
publicSection: rect(required('.PublicTalk')),
nationalSection: rect(required('.NationalTalk')),
privateSection: rect(required('.PrivateTalk')),
diplomacySection: rect(required('.DiplomacyTalk')),
firstHeader: rect(required('.BoardHeader')),
firstPlate: optionalRect('.msg_plate'),
firstIcon: optionalRect('.msg_plate .generalIcon'),
computed: {
panelDisplay: panelStyle.display,
panelColumns: panelStyle.gridTemplateColumns,
panelFontSize: panelStyle.fontSize,
headerColor: headerStyle.color,
headerOutlineWidth: headerStyle.outlineWidth,
headerBackgroundImage: headerStyle.backgroundImage,
plateBackgroundColor: plateStyle?.backgroundColor ?? null,
plateFontSize: plateStyle?.fontSize ?? null,
plateMinHeight: plateStyle?.minHeight ?? null,
iconObjectFit: iconStyle?.objectFit ?? null,
},
};
});
const submit = page.locator('#msg_submit-col button');
await submit.hover();
const hover = await submit.evaluate((element) => {
const style = getComputedStyle(element);
return {
cursor: style.cursor,
backgroundColor: style.backgroundColor,
};
});
await submit.focus();
const focus = await submit.evaluate((element) => {
const style = getComputedStyle(element);
return {
outline: style.outline,
boxShadow: style.boxShadow,
};
});
const markerPlate = page.locator('.msg_plate').filter({ hasText: marker });
const deleteButton = markerPlate.locator('.btn-delete-msg');
if (await deleteButton.isVisible()) {
page.once('dialog', (dialog) => dialog.accept());
await deleteButton.click();
}
return { ...result, interaction: { hover, focus } };
} finally {
await context.close();
}
};
const browser = await chromium.launch({ headless: true });
try {
const measurements = {
desktop: await measure(browser, 'desktop', { width: 1000, height: 900 }),
mobile: await measure(browser, 'mobile', { width: 500, height: 900 }),
};
process.stdout.write(`${JSON.stringify(measurements, null, 2)}\n`);
} finally {
await browser.close();
}
@@ -348,6 +348,8 @@ const buildWorldInput = (
maxGeneral: 500,
baseGold: 0,
baseRice: 2_000,
generalMinimumGold: 0,
generalMinimumRice: 500,
maxResourceActionAmount: 10_000,
maxTechLevel: 12,
maxLevel: 255,
@@ -1109,6 +1109,194 @@ integration('general command missing-target fallback matrix', () => {
);
});
const resourceAmountCases: Array<{
name: string;
action: string;
args: Record<string, unknown>;
expectedAmount: number;
}> = [
{
name: 'gift rounds a half unit up',
action: 'che_증여',
args: { isGold: true, amount: 150, destGeneralID: 3 },
expectedAmount: 200,
},
{
name: 'gift clamps below the minimum',
action: 'che_증여',
args: { isGold: true, amount: 1, destGeneralID: 3 },
expectedAmount: 100,
},
{
name: 'gift clamps above the maximum',
action: 'che_증여',
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
expectedAmount: 10_000,
},
{
name: 'donation rounds a half unit up',
action: 'che_헌납',
args: { isGold: true, amount: 150 },
expectedAmount: 200,
},
{
name: 'donation clamps below the minimum',
action: 'che_헌납',
args: { isGold: true, amount: 1 },
expectedAmount: 100,
},
{
name: 'donation clamps above the maximum',
action: 'che_헌납',
args: { isGold: true, amount: 10_050 },
expectedAmount: 10_000,
},
{
name: 'trade rounds a half unit up',
action: 'che_군량매매',
args: { buyRice: true, amount: 150 },
expectedAmount: 200,
},
{
name: 'trade clamps below the minimum',
action: 'che_군량매매',
args: { buyRice: true, amount: 1 },
expectedAmount: 100,
},
{
name: 'trade clamps above the maximum',
action: 'che_군량매매',
args: { buyRice: true, amount: 10_050 },
expectedAmount: 10_000,
},
];
integration('general command resource amount normalization matrix', () => {
it.each(resourceAmountCases)(
'$name matches legacy rounding and clamp semantics',
async ({ action, args, expectedAmount }) => {
const request = buildRequest(action, args);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
const referenceActor = reference.after.generals.find((entry) => entry.id === 1);
const coreActor = core.after.generals.find((entry) => entry.id === 1);
const referenceLastTurn = referenceActor?.lastTurn as { arg?: Record<string, unknown> } | null | undefined;
const coreLastTurn = coreActor?.lastTurn as { arg?: Record<string, unknown> } | null | undefined;
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: action,
usedFallback: false,
});
expect(referenceLastTurn?.arg).toMatchObject({ amount: expectedAmount });
expect(coreLastTurn?.arg).toMatchObject({ amount: expectedAmount });
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
integration('general command donation resource boundaries', () => {
it('donates the available resource when the normalized request exceeds the current amount', async () => {
const request = buildRequest('che_헌납', { isGold: true, amount: 10_000 }, { gold: 5_000 });
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_헌납',
actionKey: 'che_헌납',
usedFallback: false,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
it('falls back when current rice is below the legacy minimum even for a small request', async () => {
const request = buildRequest('che_헌납', { isGold: false, amount: 100 }, { rice: 499 });
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: false });
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_헌납',
actionKey: '휴식',
usedFallback: true,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
});
integration('general command gift resource and target boundaries', () => {
it('keeps the legacy minimum rice reserve while gifting the available amount', async () => {
const request = buildRequest('che_증여', { isGold: false, amount: 10_000, destGeneralID: 3 }, { rice: 600 });
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_증여',
actionKey: 'che_증여',
usedFallback: false,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
it('rejects gifting to the actor and falls back without command RNG', async () => {
const request = buildRequest('che_증여', { isGold: true, amount: 100, destGeneralID: 1 });
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: false });
expect(core.execution.outcome).toMatchObject({
requestedAction: 'che_증여',
actionKey: '휴식',
usedFallback: true,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
});
type GeneralConstraintCase = {
name: string;
action: string;