feat: restore Ref recruitment command details

This commit is contained in:
2026-08-13 16:20:43 +00:00
parent 94b5b7813c
commit 317a055434
12 changed files with 1375 additions and 58 deletions
@@ -0,0 +1,255 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { chromium } from '@playwright/test';
const baseUrl = process.env.REF_PARITY_BASE_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_PARITY_USER ?? 'refuser1';
const passwordFile =
process.env.REF_PARITY_PASSWORD_FILE ??
'/home/letrhee/sam_rebuild/docker_compose_files/reference/secrets/user1_password';
const artifactDir = process.env.REF_PARITY_ARTIFACT_DIR;
const allowGeneralCreate = process.env.REF_CREATE_GENERAL === '1';
const useStaticFixture = process.env.REF_STATIC_FIXTURE === '1';
const password = useStaticFixture ? null : (await readFile(passwordFile, 'utf8')).trim();
const browser = await chromium.launch({ headless: true });
const staticCrewTypes = [
{
id: 1100,
armType: 1,
name: '보병',
reqTech: 0,
reqYear: 0,
notAvailable: false,
attack: 125,
defence: 175,
speed: 7,
avoid: 10,
baseCost: 10.35,
baseRice: 10.35,
img: 'https://sam-image.hided.net/game/crewtype1100.png',
info: ['표준적인 보병입니다.', '보병은 방어특화입니다.'],
},
{
id: 1101,
armType: 1,
name: '정예병',
reqTech: 1000,
reqYear: 0,
notAvailable: true,
attack: 175,
defence: 225,
speed: 8,
avoid: 20,
baseCost: 13.8,
baseRice: 11.5,
img: 'https://sam-image.hided.net/game/crewtype1101.png',
info: ['강력하지만 기술이 필요합니다.'],
},
];
const loadStaticReference = async (page, command) => {
const commandName = command === 'che_모병' ? '모병' : '징병';
const procRes = {
relYear: 20,
year: 200,
tech: 1000,
techLevel: 1,
startYear: 180,
goldCoeff: command === 'che_모병' ? 2 : 1,
leadership: 68,
fullLeadership: 70,
armCrewTypes: [{ armType: 1, armName: '보병', values: staticCrewTypes }],
currentCrewType: 1100,
crew: 500,
gold: 12_345,
};
const assetBase = new URL('dist_js/hwe_dynamic/vue/', baseUrl).toString();
const rootAssetBase = new URL('', baseUrl).toString();
await page.setContent(
`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=500">` +
`<link rel="stylesheet" href="${rootAssetBase}d_shared/common.css">` +
`<link rel="stylesheet" href="${assetBase}vendors.css">` +
`<link rel="stylesheet" href="${assetBase}common_ts.css">` +
`<link rel="stylesheet" href="${assetBase}bootstrap.css">` +
`<link rel="stylesheet" href="${assetBase}v_processing.css">` +
`<script>var staticValues=${JSON.stringify({
serverNick: 'hwe',
serverID: 'hwe',
commandName,
turnList: [0],
currentCity: 1,
currentNation: 1,
entryInfo: ['General', command],
mapName: 'che',
unitSet: 'che',
})};var procRes=${JSON.stringify(procRes)};var entryInfo=['General',${JSON.stringify(command)}];</script>` +
`</head><body><div id="app"></div></body></html>`,
{ waitUntil: 'networkidle' }
);
for (const url of [
`${rootAssetBase}d_shared/common_path.js`,
`${rootAssetBase}hwe/d_shared/base_map.js`,
`${assetBase}vendors.js`,
`${assetBase}common_ts.js`,
`${assetBase}bootstrap.js`,
`${assetBase}v_processing.js`,
]) {
await page.addScriptTag({ url });
}
};
if (artifactDir) await mkdir(artifactDir, { recursive: true });
try {
const context = await browser.newContext({
colorScheme: 'dark',
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'UTC',
});
const page = await context.newPage();
const diagnostics = [];
page.on('pageerror', (error) => diagnostics.push(`pageerror: ${error.stack ?? error.message}`));
page.on('console', (message) => {
if (message.type() === 'error') diagnostics.push(`console: ${message.text()}`);
});
if (!useStaticFixture) {
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const globalSalt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
});
const loginResult = await login.json();
if (!login.ok() || loginResult.result !== true) {
throw new Error(`reference login failed: HTTP ${login.status()}`);
}
await page.goto(new URL('hwe/index.php', baseUrl).toString(), { waitUntil: 'networkidle' });
if (!(await page.locator('.reservedCommandZone').isVisible()) && allowGeneralCreate) {
await page.goto(new URL('hwe/v_join.php', baseUrl).toString(), { waitUntil: 'networkidle' });
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('.reservedCommandZone').waitFor({ state: 'visible', timeout: 60_000 });
}
} else {
await page.goto(new URL('d_shared/common.css', baseUrl).toString(), { waitUntil: 'networkidle' });
}
for (const command of ['che_징병', 'che_모병']) {
for (const viewport of [
{ name: 'desktop', width: 1000, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
if (useStaticFixture) {
await loadStaticReference(page, command);
} else {
const url = new URL('hwe/v_processing.php', baseUrl);
url.searchParams.set('command', command);
url.searchParams.set('turnList', '0');
await page.goto(url.toString(), { waitUntil: 'networkidle' });
}
try {
await page.locator('.crewTypeList').waitFor({ state: 'visible', timeout: 5_000 });
} catch {
throw new Error(
`reference recruitment page unavailable: ${page.url()} (${await page.title()}) | ${diagnostics
.slice(-5)
.join(' | ')}`
);
}
const toggle = page.getByRole('button', { name: '선택 할 수 없는 병종도 보기' }).first();
const unavailableBefore = await page
.locator('.crewTypeItem')
.evaluateAll(
(rows) =>
rows.filter(
(row) =>
getComputedStyle(row.querySelector('.crewTypeName')).backgroundColor ===
'rgb(255, 0, 0)'
).length
);
await toggle.hover();
const toggleHover = await toggle.evaluate((element) => {
const style = getComputedStyle(element);
return { backgroundColor: style.backgroundColor, borderColor: style.borderColor, color: style.color };
});
await toggle.focus();
const toggleFocus = await toggle.evaluate((element) => {
const style = getComputedStyle(element);
return { outline: style.outline, boxShadow: style.boxShadow };
});
await toggle.click();
const measurement = await page.evaluate(async () => {
const rect = (element) => element.getBoundingClientRect().toJSON();
const list = document.querySelector('.crewTypeList');
const status = document.querySelector('.listFront .bg2');
const header = document.querySelector('.listHeader');
const firstRow = document.querySelector('.crewTypeItem');
const image = firstRow?.querySelector('.crewTypeImg');
const info = firstRow?.querySelector('.crewTypeInfo');
const selectedPanel = document.querySelector('.miniCrewPanel');
if (
!(list instanceof HTMLElement) ||
!(status instanceof HTMLElement) ||
!(header instanceof HTMLElement)
) {
throw new Error('missing recruitment layout');
}
const backgroundImage = image instanceof HTMLElement ? getComputedStyle(image).backgroundImage : '';
const imageUrl = backgroundImage.match(/^url\(["']?(.*?)["']?\)$/)?.[1];
const naturalSize = imageUrl
? await new Promise((resolve) => {
const probe = new Image();
probe.onload = () => resolve({ width: probe.naturalWidth, height: probe.naturalHeight });
probe.onerror = () => resolve(null);
probe.src = imageUrl;
})
: null;
return {
document: {
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
},
list: rect(list),
status: rect(status),
statusCells: Array.from(status.children, rect),
header: rect(header),
gridTemplateColumns: getComputedStyle(header).gridTemplateColumns,
firstRow: firstRow instanceof HTMLElement ? rect(firstRow) : null,
image: image instanceof HTMLElement ? rect(image) : null,
imageNaturalSize: naturalSize,
info: info instanceof HTMLElement ? rect(info) : null,
selectedPanel:
selectedPanel instanceof HTMLElement
? { rect: rect(selectedPanel), display: getComputedStyle(selectedPanel).display }
: null,
statusText: status.textContent?.replace(/\s+/g, ' ').trim(),
firstRowText: firstRow?.textContent?.replace(/\s+/g, ' ').trim(),
unavailableAfter: Array.from(document.querySelectorAll('.crewTypeItem')).filter(
(row) =>
getComputedStyle(row.querySelector('.crewTypeName')).backgroundColor === 'rgb(255, 0, 0)'
).length,
};
});
if (artifactDir) {
await page.screenshot({ path: join(artifactDir, `${command}-${viewport.name}.png`), fullPage: true });
}
console.log(
JSON.stringify({ command, viewport, unavailableBefore, toggleHover, toggleFocus, measurement })
);
}
}
} finally {
await browser.close();
}