-
-
-
-
- {{ entry.attackerName }}
- vs
- {{ entry.defenderName }}
-
-
-
-
-
-
-
8강
-
진행 대기
-
-
- {{ entry.attackerName }}
- vs
- {{ entry.defenderName }}
-
-
-
-
-
4강
-
진행 대기
-
-
- {{ entry.attackerName }}
- vs
- {{ entry.defenderName }}
-
-
-
-
-
결승
-
진행 대기
-
-
- {{ entry.attackerName }}
- vs
- {{ entry.defenderName }}
-
-
-
-
-
-
-
-
-
-
전체 베팅: {{ totalBetAmount.toLocaleString('ko-KR') }}
-
내 베팅: {{ myBetAmount.toLocaleString('ko-KR') }}
-
-
-
- {{ entry.name }}
- 전체 {{ (bettingTotals[entry.id] ?? 0).toLocaleString('ko-KR') }}
- 내 {{ (bettingMine[entry.id] ?? 0).toLocaleString('ko-KR') }}
-
-
-
-
-
-
- 베팅 기간이 아닙니다.
-
-
-
-
-
-
+
┻
+
+ {{ name }}
+
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+
+ {{ name }}
+
+
┏━━━━━━━━━━┻━━━━━━━━━━┓ ┏━━━━━━━━━━┻━━━━━━━━━━┓
+
+ {{ name }}
+
+
┏━━━━┻━━━━┓ ┏━━━━┻━━━━┓ ┏━━━━┻━━━━┓ ┏━━━━┻━━━━┓
+
+ {{ name }}
+
+
+
+ {{ odds(matchesAt(7).flatMap((match) => [match.attackerId, match.defenderId])[index]) }}
+
+
+
배당률이 낮을수록 베팅된 금액이 많고 유저들이 우승후보로 많이 선택한 장수입니다.
+
+
+
+ {{ nameOf(currentMatch.attackerId) }} vs {{ nameOf(currentMatch.defenderId) }}
+ {{ line }}
+
+
+
+
+
+
+ {{
+ ['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex]
+ }}조
+
+
+
+ | 순 |
+ 장수 |
+ 경 |
+ 승 |
+ 무 |
+ 패 |
+ 점 |
+ 득 |
+
+
+
+
+ | {{ rowIndex }} |
+ {{ group[rowIndex - 1]?.name ?? '' }} |
+
+ {{
+ group[rowIndex - 1]
+ ? (group[rowIndex - 1]!.win ?? 0) +
+ (group[rowIndex - 1]!.draw ?? 0) +
+ (group[rowIndex - 1]!.lose ?? 0)
+ : ''
+ }}
+ |
+ {{ group[rowIndex - 1]?.win ?? '' }} |
+ {{ group[rowIndex - 1]?.draw ?? '' }} |
+ {{ group[rowIndex - 1]?.lose ?? '' }} |
+
+ {{
+ group[rowIndex - 1]
+ ? (group[rowIndex - 1]!.win ?? 0) * 3 + (group[rowIndex - 1]!.draw ?? 0)
+ : ''
+ }}
+ |
+ {{ group[rowIndex - 1]?.gl ?? '' }} |
+
+
+
+
+
+
\ No newline at end of file
+
diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md
index 2865916..dd9544f 100644
--- a/docs/frontend-legacy-parity.md
+++ b/docs/frontend-legacy-parity.md
@@ -17,6 +17,8 @@ fails on unknown operations. It also serves the checked-out reference image
tree instead of replacing images with layout-neutral placeholders.
`public-gaps.spec.ts` adds bounded fixtures for nation betting and the public
NPC list, including mutations and recoverable API failures.
+`tournament-betting.spec.ts` covers the separate tournament and tournament
+betting routes, including a recoverable failed bet.
Run the suite from the core2026 repository root:
@@ -24,6 +26,11 @@ Run the suite from the core2026 repository root:
pnpm test:e2e:frontend-legacy
```
+When another worktree occupies the default ports, set
+`FRONTEND_PARITY_GATEWAY_PORT`, `FRONTEND_PARITY_GAME_PORT`, and
+`FRONTEND_PARITY_GAME_URL`. `FRONTEND_PARITY_ARTIFACT_DIR` retains the
+tournament and betting screenshots.
+
The suite starts both applications at their public prefixes:
- gateway: `http://127.0.0.1:15100/gateway/`
@@ -47,6 +54,8 @@ storage, route guards, and image loading.
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| 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 |
+| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
+| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
The global game baseline is black, white, Pretendard 14px. Legacy texture
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
diff --git a/tools/frontend-legacy-parity/playwright.config.mjs b/tools/frontend-legacy-parity/playwright.config.mjs
index f510f51..eece2a1 100644
--- a/tools/frontend-legacy-parity/playwright.config.mjs
+++ b/tools/frontend-legacy-parity/playwright.config.mjs
@@ -3,10 +3,17 @@ import { fileURLToPath } from 'node:url';
import { defineConfig, devices } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
+const gatewayPort = process.env.FRONTEND_PARITY_GATEWAY_PORT ?? '15100';
+const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
export default defineConfig({
testDir: '.',
- testMatch: ['visual-parity.spec.ts', 'public-gaps.spec.ts', 'instant-diplomacy-message.spec.ts'],
+ testMatch: [
+ 'visual-parity.spec.ts',
+ 'public-gaps.spec.ts',
+ 'instant-diplomacy-message.spec.ts',
+ 'tournament-betting.spec.ts',
+ ],
fullyParallel: false,
workers: 1,
timeout: 30_000,
@@ -29,18 +36,16 @@ export default defineConfig({
},
webServer: [
{
- command:
- 'VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_API_URL_TEMPLATE=/{profile}/api/trpc VITE_GAME_ASSET_URL=/image pnpm --filter @sammo-ts/gateway-frontend dev --host 127.0.0.1 --port 15100',
+ command: `VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_API_URL_TEMPLATE=/{profile}/api/trpc VITE_GAME_ASSET_URL=/image pnpm --filter @sammo-ts/gateway-frontend dev --host 127.0.0.1 --port ${gatewayPort}`,
cwd: repositoryRoot,
- url: 'http://127.0.0.1:15100/gateway/',
+ url: `http://127.0.0.1:${gatewayPort}/gateway/`,
reuseExistingServer: false,
timeout: 120_000,
},
{
- command:
- 'VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc VITE_GAME_ASSET_URL=/image VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port 15102',
+ command: `VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc VITE_GAME_ASSET_URL=/image VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${gamePort}`,
cwd: repositoryRoot,
- url: 'http://127.0.0.1:15102/che/',
+ url: `http://127.0.0.1:${gamePort}/che/`,
reuseExistingServer: false,
timeout: 120_000,
},
diff --git a/tools/frontend-legacy-parity/tournament-betting.spec.ts b/tools/frontend-legacy-parity/tournament-betting.spec.ts
new file mode 100644
index 0000000..002609c
--- /dev/null
+++ b/tools/frontend-legacy-parity/tournament-betting.spec.ts
@@ -0,0 +1,257 @@
+import { expect, test, type Page, type Route } from '@playwright/test';
+
+const response = (data: unknown) => ({ result: { data } });
+const gameUrl = process.env.FRONTEND_PARITY_GAME_URL ?? 'http://127.0.0.1:15102';
+const artifactDir = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
+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 participants = Array.from({ length: 64 }, (_, index) => ({
+ id: index + 1,
+ name: `장수${String(index + 1).padStart(2, '0')}`,
+ leadership: 80 - (index % 20),
+ strength: 70 + (index % 20),
+ intel: 65 + (index % 15),
+ level: 5,
+ groupId: 10 + Math.floor(index / 8),
+ groupNo: index % 8,
+ win: index % 4,
+ draw: index % 2,
+ lose: (index + 1) % 3,
+ gl: 20 - index,
+ finalRank: (index % 8) + 1,
+}));
+
+const matches = Array.from({ length: 8 }, (_, index) => ({
+ id: index + 1,
+ stage: 7,
+ roundIndex: index,
+ attackerId: index * 2 + 1,
+ defenderId: index * 2 + 2,
+}));
+
+const snapshot = {
+ state: {
+ stage: 6,
+ phase: 0,
+ type: 0,
+ auto: true,
+ openYear: 193,
+ openMonth: 1,
+ termSeconds: 60,
+ nextAt: '2099-01-01T00:00:00.000Z',
+ bettingId: 7,
+ bettingCloseAt: '2099-01-01T00:00:00.000Z',
+ },
+ participants,
+ matches,
+ betCount: 3,
+};
+
+const bettingSummary = {
+ state: snapshot.state,
+ totals: { 1: 300, 2: 200, 3: 100 },
+ myTotals: { 1: 50 },
+ totalAmount: 600,
+ myAmount: 50,
+};
+
+const installFixture = async (page: Page) => {
+ await page.addInitScript(() => {
+ window.localStorage.setItem('sammo-game-token', 'ga_tournament_visual');
+ window.localStorage.setItem('sammo-game-profile', 'che:default');
+ });
+ let betCalls = 0;
+ let joinCalls = 0;
+ await page.route('**/image/game/**', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'image/jpeg',
+ body: Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2Q==', 'base64'),
+ })
+ );
+ await page.route('**/che/api/trpc/**', async (route) => {
+ const results = operationNames(route).map((operation) => {
+ if (operation === 'lobby.info') {
+ return response({ myGeneral: { id: 64, name: '내장수' } });
+ }
+ if (operation === 'join.getConfig') return response({});
+ if (operation === 'general.me') {
+ return response({ general: { id: 64, name: '내장수' }, nation: null, city: null });
+ }
+ if (operation === 'tournament.getSnapshot') return response(snapshot);
+ if (operation === 'tournament.getBettingSummary') return response(bettingSummary);
+ if (operation === 'tournament.getRankings') {
+ return response(
+ [
+ ['tt', '전 력 전', '종합'],
+ ['tl', '통 솔 전', '통솔'],
+ ['ts', '일 기 토', '무력'],
+ ['ti', '설 전', '지력'],
+ ].map(([prefix, title, statLabel]) => ({
+ prefix,
+ title,
+ statLabel,
+ entries: [
+ {
+ rank: 1,
+ generalId: 1,
+ name: '장수01',
+ npcState: 0,
+ stat: 210,
+ games: 12,
+ win: 8,
+ draw: 2,
+ lose: 2,
+ score: 42,
+ prizes: 1,
+ },
+ ],
+ }))
+ );
+ }
+ if (operation === 'tournament.getAdminStatus')
+ return errorResponse(operation, 'Admin permission is required.');
+ if (operation === 'tournament.join') {
+ joinCalls += 1;
+ return joinCalls === 1
+ ? errorResponse(operation, '금이 부족합니다.')
+ : response({ ok: true, count: 64 });
+ }
+ if (operation === 'tournament.placeBet') {
+ betCalls += 1;
+ return betCalls === 1
+ ? errorResponse(operation, '500금까지만 베팅 가능합니다.')
+ : response({ ok: true });
+ }
+ return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
+ });
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(results),
+ });
+ });
+};
+
+test.beforeEach(async ({ page }) => {
+ await installFixture(page);
+});
+
+test('tournament keeps the legacy 2000px bracket and 250px group geometry', async ({ page }) => {
+ await page.setViewportSize({ width: 2200, height: 1000 });
+ await page.goto(`${gameUrl}/che/tournament`);
+ await expect(page.getByText('삼모전 토너먼트')).toBeVisible();
+ await expect(page.locator('.top16 span')).toHaveCount(16);
+
+ const geometry = await page.locator('#tournament-container').evaluate((container) => {
+ const rect = container.getBoundingClientRect();
+ const candidate = container.querySelector
('.top16 span')!;
+ const groupTable = container.querySelector('.group-grid table')!;
+ const title = container.querySelector('.legacy-title')!;
+ const refresh = container.querySelector('.toolbar button')!;
+ const style = getComputedStyle(container);
+ return {
+ x: rect.x,
+ width: rect.width,
+ candidateWidth: candidate.getBoundingClientRect().width,
+ groupWidth: groupTable.getBoundingClientRect().width,
+ titleHeight: title.getBoundingClientRect().height,
+ refreshHeight: refresh.getBoundingClientRect().height,
+ refreshRadius: getComputedStyle(refresh).borderRadius,
+ fontFamily: style.fontFamily,
+ fontSize: style.fontSize,
+ backgroundImage: getComputedStyle(container.querySelector('.bracket')!).backgroundImage,
+ };
+ });
+ expect(geometry).toMatchObject({
+ x: 100,
+ width: 2000,
+ candidateWidth: 125,
+ groupWidth: 250,
+ titleHeight: 55.6875,
+ refreshHeight: 35.5,
+ refreshRadius: '5.25px',
+ fontSize: '14px',
+ });
+ expect(geometry.fontFamily).toContain('Pretendard');
+ expect(geometry.backgroundImage).toContain('back_walnut.jpg');
+ if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-tournament.png`, fullPage: true });
+
+ const refresh = page.getByRole('button', { name: '갱신' });
+ const before = await refresh.evaluate((element) => getComputedStyle(element).filter);
+ await refresh.hover();
+ const hover = await refresh.evaluate((element) => getComputedStyle(element).filter);
+ await refresh.focus();
+ await expect(refresh).toBeFocused();
+ expect(hover).not.toBe(before);
+});
+
+test('tournament keeps the fixed legacy canvas at a 1024px viewport', async ({ page }) => {
+ await page.setViewportSize({ width: 1024, height: 768 });
+ await page.goto(`${gameUrl}/che/tournament`);
+ await expect(page.locator('#tournament-container')).toHaveCSS('width', '2000px');
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(2000);
+});
+
+test('betting keeps the 1120px and 16 by 70px layout and retains a failed selection', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.goto(`${gameUrl}/che/betting`);
+ await expect(page.getByText('베 팅 장')).toBeVisible();
+ await expect(page.locator('.names span')).toHaveCount(16);
+
+ const geometry = await page.locator('#tournament-betting-container').evaluate((container) => {
+ const rect = container.getBoundingClientRect();
+ const first = container.querySelector('.names span')!;
+ const title = container.querySelector('.title')!;
+ const refresh = container.querySelector('.toolbar button')!;
+ const stateStyle = getComputedStyle(container.querySelector('.state')!);
+ const tableStyle = getComputedStyle(container.querySelector('.candidate-table')!);
+ return {
+ x: rect.x,
+ width: rect.width,
+ candidateWidth: first.getBoundingClientRect().width,
+ titleHeight: title.getBoundingClientRect().height,
+ refreshHeight: refresh.getBoundingClientRect().height,
+ refreshRadius: getComputedStyle(refresh).borderRadius,
+ stateFontSize: stateStyle.fontSize,
+ tableFontSize: tableStyle.fontSize,
+ tableBorder: tableStyle.borderTopWidth,
+ };
+ });
+ expect(geometry).toEqual({
+ x: 80,
+ width: 1120,
+ candidateWidth: 70,
+ titleHeight: 55.6875,
+ refreshHeight: 35.5,
+ refreshRadius: '5.25px',
+ stateFontSize: '24px',
+ tableFontSize: '10px',
+ tableBorder: '1px',
+ });
+ if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-betting.png`, fullPage: true });
+
+ const select = page.getByLabel('장수01 베팅 금액');
+ await select.selectOption('500');
+ const bet = page.getByRole('button', { name: '베팅!' }).first();
+ await bet.hover();
+ await bet.focus();
+ await expect(bet).toBeFocused();
+ await bet.click();
+ await expect(page.getByRole('status')).toHaveText('500금까지만 베팅 가능합니다.');
+ await expect(select).toHaveValue('500');
+
+ await bet.click();
+ await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
+});