feat(game-ui): redesign settings and tournament layouts
This commit is contained in:
@@ -137,7 +137,24 @@ export const tournamentRouter = router({
|
|||||||
store.getMatches(),
|
store.getMatches(),
|
||||||
store.getBettingEntries(),
|
store.getBettingEntries(),
|
||||||
]);
|
]);
|
||||||
return { state, participants, matches, betCount: bets.length };
|
const participantIds = [...new Set(participants.map((participant) => participant.id))];
|
||||||
|
const iconRows =
|
||||||
|
participantIds.length === 0
|
||||||
|
? []
|
||||||
|
: await ctx.db.general.findMany({
|
||||||
|
where: { id: { in: participantIds } },
|
||||||
|
select: { id: true, picture: true, imageServer: true },
|
||||||
|
});
|
||||||
|
const iconsByGeneralId = new Map(iconRows.map((general) => [general.id, general]));
|
||||||
|
const publicParticipants = participants.map((participant) => {
|
||||||
|
const icon = iconsByGeneralId.get(participant.id);
|
||||||
|
return {
|
||||||
|
...participant,
|
||||||
|
picture: icon?.picture ?? null,
|
||||||
|
imageServer: icon?.imageServer ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { state, participants: publicParticipants, matches, betCount: bets.length };
|
||||||
}),
|
}),
|
||||||
getRankings: authedProcedure.query(async ({ ctx }) => {
|
getRankings: authedProcedure.query(async ({ ctx }) => {
|
||||||
await getMyGeneral(ctx);
|
await getMyGeneral(ctx);
|
||||||
@@ -177,7 +194,16 @@ export const tournamentRouter = router({
|
|||||||
}
|
}
|
||||||
const generals = await ctx.db.general.findMany({
|
const generals = await ctx.db.general.findMany({
|
||||||
where: { id: { in: [...rankMap.keys()] } },
|
where: { id: { in: [...rankMap.keys()] } },
|
||||||
select: { id: true, name: true, npcState: true, leadership: true, strength: true, intel: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
npcState: true,
|
||||||
|
picture: true,
|
||||||
|
imageServer: true,
|
||||||
|
leadership: true,
|
||||||
|
strength: true,
|
||||||
|
intel: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return tournamentRankTypes.map((prefix) => {
|
return tournamentRankTypes.map((prefix) => {
|
||||||
@@ -201,6 +227,8 @@ export const tournamentRouter = router({
|
|||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
name: general.name,
|
name: general.name,
|
||||||
npcState: general.npcState,
|
npcState: general.npcState,
|
||||||
|
picture: general.picture,
|
||||||
|
imageServer: general.imageServer,
|
||||||
stat,
|
stat,
|
||||||
games: win + draw + lose,
|
games: win + draw + lose,
|
||||||
win,
|
win,
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow =>
|
|||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
name: `장수${id}`,
|
name: `장수${id}`,
|
||||||
|
picture: `${id}.jpg`,
|
||||||
|
imageServer: id % 2,
|
||||||
leadership: 70 + id,
|
leadership: 70 + id,
|
||||||
strength: 60 + id,
|
strength: 60 + id,
|
||||||
intel: 50 + id,
|
intel: 50 + id,
|
||||||
@@ -296,6 +298,10 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
const sections = await ownerCaller.tournament.getRankings();
|
const sections = await ownerCaller.tournament.getRankings();
|
||||||
expect(sections).toHaveLength(4);
|
expect(sections).toHaveLength(4);
|
||||||
expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]);
|
expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]);
|
||||||
|
expect(sections[0]?.entries[0]).toMatchObject({
|
||||||
|
picture: '2.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const generalLessCaller = appRouter.createCaller(
|
const generalLessCaller = appRouter.createCaller(
|
||||||
buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows })
|
buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows })
|
||||||
@@ -303,6 +309,33 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('joins current dedicated icon metadata to the public tournament snapshot', async () => {
|
||||||
|
const redis = new MemoryRedis();
|
||||||
|
const transport = new TournamentTransport();
|
||||||
|
const owner = buildGeneral(11, 'user-1');
|
||||||
|
const rival = buildGeneral(12, 'user-2');
|
||||||
|
await setTournamentFixture(redis, {
|
||||||
|
stage: 7,
|
||||||
|
phase: 0,
|
||||||
|
type: 0,
|
||||||
|
auto: true,
|
||||||
|
openYear: 193,
|
||||||
|
openMonth: 1,
|
||||||
|
termSeconds: 60,
|
||||||
|
nextAt: '2026-07-26T01:00:00.000Z',
|
||||||
|
});
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
buildContext({ redis, transport, generals: [owner, rival], userId: 'user-1' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await caller.tournament.getSnapshot();
|
||||||
|
|
||||||
|
expect(snapshot.participants).toEqual([
|
||||||
|
expect.objectContaining({ id: 11, picture: '11.jpg', imageServer: 1 }),
|
||||||
|
expect.objectContaining({ id: 12, picture: '12.jpg', imageServer: 0 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('refunds gold when the tournament bet rank update fails', async () => {
|
it('refunds gold when the tournament bet rank update fails', async () => {
|
||||||
const redis = new MemoryRedis();
|
const redis = new MemoryRedis();
|
||||||
const transport = new TournamentTransport();
|
const transport = new TournamentTransport();
|
||||||
|
|||||||
@@ -646,7 +646,7 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
|
|||||||
expect(mobileWidth).toBe(1016);
|
expect(mobileWidth).toBe(1016);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
|
test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-identity layout', async ({ page }) => {
|
||||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
await install(page, state);
|
await install(page, state);
|
||||||
await page.setViewportSize({ width: 1000, height: 900 });
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
@@ -696,7 +696,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(desktop.width).toBe(1000);
|
expect(desktop.width).toBe(1000);
|
||||||
expect(desktop.minWidth).toBe('500px');
|
expect(desktop.minWidth).toBe('0px');
|
||||||
expect(desktop.fontSize).toBe('14px');
|
expect(desktop.fontSize).toBe('14px');
|
||||||
expect(desktop.columns.split(' ')).toHaveLength(2);
|
expect(desktop.columns.split(' ')).toHaveLength(2);
|
||||||
expect(desktop.titleHeight).toBeCloseTo(54, 0);
|
expect(desktop.titleHeight).toBeCloseTo(54, 0);
|
||||||
@@ -766,26 +766,36 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
|||||||
expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId');
|
expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId');
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 390, height: 900 });
|
||||||
await page.reload();
|
await page.reload();
|
||||||
const mobile = await page.locator('#container').evaluate((element) => {
|
const mobile = await page.locator('#container').evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
||||||
|
const icon = element.querySelector<HTMLElement>('.portrait-image')!.getBoundingClientRect();
|
||||||
|
const name = element.querySelector<HTMLElement>('.portrait-cell strong')!.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
scrollWidth: document.documentElement.scrollWidth,
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
|
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
|
||||||
settingsOffset: settings.x - rect.x,
|
settingsOffset: settings.x - rect.x,
|
||||||
settingsWidth: settings.width,
|
settingsWidth: settings.width,
|
||||||
|
identity: {
|
||||||
|
iconRight: icon.right,
|
||||||
|
nameLeft: name.left,
|
||||||
|
iconCenterY: icon.y + icon.height / 2,
|
||||||
|
nameCenterY: name.y + name.height / 2,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(mobile).toMatchObject({
|
expect(mobile).toMatchObject({
|
||||||
width: 500,
|
width: 390,
|
||||||
scrollWidth: 500,
|
scrollWidth: 390,
|
||||||
columns: '500px',
|
columns: '390px',
|
||||||
settingsOffset: 0,
|
settingsOffset: 0,
|
||||||
settingsWidth: 500,
|
settingsWidth: 390,
|
||||||
});
|
});
|
||||||
|
expect(mobile.identity.nameLeft).toBeGreaterThanOrEqual(mobile.identity.iconRight);
|
||||||
|
expect(Math.abs(mobile.identity.iconCenterY - mobile.identity.nameCenterY)).toBeLessThan(1);
|
||||||
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { mkdir, readFile } from 'node:fs/promises';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
|
const responsiveArtifactDir = process.env.TOURNAMENT_RESPONSIVE_ARTIFACT_DIR;
|
||||||
const imageRoots = [
|
const imageRoots = [
|
||||||
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []),
|
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []),
|
||||||
resolve(repositoryRoot, '../image/game'),
|
resolve(repositoryRoot, '../image/game'),
|
||||||
resolve(repositoryRoot, '../../image/game'),
|
resolve(repositoryRoot, '../../image/game'),
|
||||||
];
|
];
|
||||||
|
const iconRoots = [
|
||||||
|
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'icons')] : []),
|
||||||
|
resolve(repositoryRoot, '../image/icons'),
|
||||||
|
resolve(repositoryRoot, '../../image/icons'),
|
||||||
|
resolve(repositoryRoot, '../../sam_rebuild/image/icons'),
|
||||||
|
];
|
||||||
const names = [
|
const names = [
|
||||||
'관우',
|
'관우',
|
||||||
'장료',
|
'장료',
|
||||||
@@ -35,6 +42,8 @@ const participants = names.map((name, index) => ({
|
|||||||
strength: 80,
|
strength: 80,
|
||||||
intel: 80,
|
intel: 80,
|
||||||
level: 10,
|
level: 10,
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
groupId: 10 + (index % 8),
|
groupId: 10 + (index % 8),
|
||||||
groupNo: Math.floor(index / 8),
|
groupNo: Math.floor(index / 8),
|
||||||
win: 3 - (index % 2),
|
win: 3 - (index % 2),
|
||||||
@@ -88,6 +97,26 @@ const readReferenceImage = async (filename: string): Promise<Buffer> => {
|
|||||||
throw new Error(`Reference image not found: ${filename}`);
|
throw new Error(`Reference image not found: ${filename}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readReferenceIcon = async (filename: string): Promise<Buffer> => {
|
||||||
|
for (const iconRoot of iconRoots) {
|
||||||
|
try {
|
||||||
|
return await readFile(resolve(iconRoot, filename));
|
||||||
|
} catch {
|
||||||
|
// Worktrees can be nested at different depths.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Reference icon not found: ${filename}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistScreenshot = async (page: Page, name: string, fallbackPath: string) => {
|
||||||
|
if (!responsiveArtifactDir) {
|
||||||
|
await page.screenshot({ path: fallbackPath, fullPage: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mkdir(responsiveArtifactDir, { recursive: true });
|
||||||
|
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
|
||||||
|
};
|
||||||
|
|
||||||
const installFixture = async (page: Page) => {
|
const installFixture = async (page: Page) => {
|
||||||
await page.addInitScript((profile) => {
|
await page.addInitScript((profile) => {
|
||||||
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
|
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
|
||||||
@@ -98,6 +127,9 @@ const installFixture = async (page: Page) => {
|
|||||||
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) });
|
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await page.route('**/icons/default.jpg', async (route) => {
|
||||||
|
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceIcon('default.jpg') });
|
||||||
|
});
|
||||||
await page.route(gameTrpcRoute, async (route) => {
|
await page.route(gameTrpcRoute, async (route) => {
|
||||||
const results = operationNames(route).map((operation) => {
|
const results = operationNames(route).map((operation) => {
|
||||||
if (operation === 'auth.status') return response({ ok: true });
|
if (operation === 'auth.status') return response({ ok: true });
|
||||||
@@ -125,12 +157,43 @@ const installFixture = async (page: Page) => {
|
|||||||
}
|
}
|
||||||
if (operation === 'tournament.getBettingSummary') {
|
if (operation === 'tournament.getBettingSummary') {
|
||||||
return response({
|
return response({
|
||||||
totals: Object.fromEntries(participants.map((participant, index) => [participant.id, 100 + index * 10])),
|
totals: Object.fromEntries(
|
||||||
|
participants.map((participant, index) => [participant.id, 100 + index * 10])
|
||||||
|
),
|
||||||
myTotals: {},
|
myTotals: {},
|
||||||
totalAmount: 2800,
|
totalAmount: 2800,
|
||||||
myAmount: 0,
|
myAmount: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (operation === 'tournament.getRankings') {
|
||||||
|
return response(
|
||||||
|
[
|
||||||
|
['tt', '전 력 전', '종합'],
|
||||||
|
['tl', '통 솔 전', '통솔'],
|
||||||
|
['ts', '일 기 토', '무력'],
|
||||||
|
['ti', '설 전', '지력'],
|
||||||
|
].map(([prefix, title, statLabel]) => ({
|
||||||
|
prefix,
|
||||||
|
title,
|
||||||
|
statLabel,
|
||||||
|
entries: participants.slice(0, 6).map((participant, index) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
generalId: participant.id,
|
||||||
|
name: participant.name,
|
||||||
|
picture: participant.picture,
|
||||||
|
imageServer: participant.imageServer,
|
||||||
|
npcState: 0,
|
||||||
|
stat: 240 - index,
|
||||||
|
games: 10,
|
||||||
|
win: 7,
|
||||||
|
draw: 1,
|
||||||
|
lose: 2,
|
||||||
|
score: 22 - index,
|
||||||
|
prizes: 3,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
return response(null);
|
return response(null);
|
||||||
});
|
});
|
||||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||||
@@ -162,16 +225,20 @@ test('desktop bracket connects every real general slot to the next round', async
|
|||||||
connectorCenter: firstConnector.x + firstConnector.width / 2,
|
connectorCenter: firstConnector.x + firstConnector.width / 2,
|
||||||
championCenter: champion.x + champion.width / 2,
|
championCenter: champion.x + champion.width / 2,
|
||||||
finalistCenters: finalists.map((rect) => rect.x + rect.width / 2),
|
finalistCenters: finalists.map((rect) => rect.x + rect.width / 2),
|
||||||
connectorQuarters: [firstConnector.x + firstConnector.width / 4, firstConnector.x + (firstConnector.width * 3) / 4],
|
connectorQuarters: [
|
||||||
|
firstConnector.x + firstConnector.width / 4,
|
||||||
|
firstConnector.x + (firstConnector.width * 3) / 4,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(geometry.canvasWidth).toBe(2000);
|
expect(geometry.canvasWidth).toBeGreaterThanOrEqual(1000);
|
||||||
|
expect(geometry.canvasWidth).toBeLessThanOrEqual(1200);
|
||||||
expect(Math.abs(geometry.connectorCenter - geometry.championCenter)).toBeLessThan(1);
|
expect(Math.abs(geometry.connectorCenter - geometry.championCenter)).toBeLessThan(1);
|
||||||
expect(geometry.finalistCenters).toHaveLength(2);
|
expect(geometry.finalistCenters).toHaveLength(2);
|
||||||
expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1);
|
expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1);
|
||||||
expect(Math.abs(geometry.finalistCenters[1]! - geometry.connectorQuarters[1]!)).toBeLessThan(1);
|
expect(Math.abs(geometry.finalistCenters[1]! - geometry.connectorQuarters[1]!)).toBeLessThan(1);
|
||||||
|
|
||||||
await page.screenshot({ path: testInfo.outputPath('tournament-bracket-desktop.webp'), fullPage: true });
|
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => {
|
test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => {
|
||||||
@@ -197,5 +264,62 @@ test('mobile bracket shows every round and general within the handheld width', a
|
|||||||
expect(bounds.width).toBe(390);
|
expect(bounds.width).toBe(390);
|
||||||
expect(bounds.minX).toBeGreaterThanOrEqual(0);
|
expect(bounds.minX).toBeGreaterThanOrEqual(0);
|
||||||
expect(bounds.maxX).toBeLessThanOrEqual(390);
|
expect(bounds.maxX).toBeLessThanOrEqual(390);
|
||||||
await page.screenshot({ path: testInfo.outputPath('tournament-bracket-mobile.webp'), fullPage: true });
|
const identity = await bracket
|
||||||
|
.locator('.mobile-bracket-name')
|
||||||
|
.first()
|
||||||
|
.evaluate((element) => {
|
||||||
|
const icon = element.querySelector('img')!.getBoundingClientRect();
|
||||||
|
const name = element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect();
|
||||||
|
return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y };
|
||||||
|
});
|
||||||
|
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight);
|
||||||
|
expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8);
|
||||||
|
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
|
||||||
|
await page.getByRole('tab', { name: '二조' }).first().click();
|
||||||
|
await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true');
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||||
|
await persistScreenshot(page, 'tournament-mobile', testInfo.outputPath('tournament-bracket-mobile.webp'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await installFixture(page);
|
||||||
|
await page.goto('betting');
|
||||||
|
|
||||||
|
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
||||||
|
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
|
||||||
|
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
|
||||||
|
await page.getByRole('tab', { name: '통솔전' }).click();
|
||||||
|
await expect(page.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.ranking-table:visible thead')).toContainText('통 솔 전');
|
||||||
|
|
||||||
|
const identity = await page
|
||||||
|
.locator('.ranking-table:visible .general-identity')
|
||||||
|
.first()
|
||||||
|
.evaluate((element) => {
|
||||||
|
const icon = element.querySelector('img')!.getBoundingClientRect();
|
||||||
|
const name = element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect();
|
||||||
|
return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y };
|
||||||
|
});
|
||||||
|
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight);
|
||||||
|
expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||||
|
await persistScreenshot(page, 'tournament-ranking-mobile', testInfo.outputPath('tournament-ranking-mobile.webp'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop betting presents icon-and-name cards and all four rankings without document overflow', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 1365, height: 900 });
|
||||||
|
await installFixture(page);
|
||||||
|
await page.goto('betting');
|
||||||
|
|
||||||
|
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
||||||
|
await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
|
||||||
|
const columns = await page
|
||||||
|
.locator('.candidate-grid')
|
||||||
|
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length);
|
||||||
|
expect(columns).toBe(4);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365);
|
||||||
|
await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ body {
|
|||||||
min-width: 500px;
|
min-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* These redesigned identity/tournament screens own a true handheld layout. */
|
||||||
|
#app:has(.responsive-settings-page),
|
||||||
|
#app:has(#tournament-container),
|
||||||
|
#app:has(#tournament-betting-container) {
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
body:has(.battle-page),
|
body:has(.battle-page),
|
||||||
body:has(.chief-page),
|
body:has(.chief-page),
|
||||||
body:has(.global-page),
|
body:has(.global-page),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import GeneralIdentity from '../ui/GeneralIdentity.vue';
|
||||||
import {
|
import {
|
||||||
buildTournamentBracket,
|
buildTournamentBracket,
|
||||||
type TournamentBracketMatch,
|
type TournamentBracketMatch,
|
||||||
@@ -89,7 +90,12 @@ const odds = (id: number | null) => {
|
|||||||
:class="{ advanced: bracket.champion.advanced }"
|
:class="{ advanced: bracket.champion.advanced }"
|
||||||
:data-general-id="bracket.champion.id ?? undefined"
|
:data-general-id="bracket.champion.id ?? undefined"
|
||||||
>
|
>
|
||||||
{{ bracket.champion.name }}
|
<GeneralIdentity
|
||||||
|
:name="bracket.champion.name"
|
||||||
|
:picture="bracket.champion.picture"
|
||||||
|
:image-server="bracket.champion.imageServer"
|
||||||
|
:icon-size="24"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,7 +116,12 @@ const odds = (id: number | null) => {
|
|||||||
:class="{ advanced: slot.advanced }"
|
:class="{ advanced: slot.advanced }"
|
||||||
:data-general-id="slot.id ?? undefined"
|
:data-general-id="slot.id ?? undefined"
|
||||||
>
|
>
|
||||||
{{ slot.name }}
|
<GeneralIdentity
|
||||||
|
:name="slot.name"
|
||||||
|
:picture="slot.picture"
|
||||||
|
:image-server="slot.imageServer"
|
||||||
|
:icon-size="22"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="connector-row" :style="{ '--connector-count': round.slots.length }">
|
<div class="connector-row" :style="{ '--connector-count': round.slots.length }">
|
||||||
@@ -140,7 +151,12 @@ const odds = (id: number | null) => {
|
|||||||
:class="{ advanced: slot.advanced }"
|
:class="{ advanced: slot.advanced }"
|
||||||
:data-general-id="slot.id ?? undefined"
|
:data-general-id="slot.id ?? undefined"
|
||||||
>
|
>
|
||||||
{{ slot.name }}
|
<GeneralIdentity
|
||||||
|
:name="slot.name"
|
||||||
|
:picture="slot.picture"
|
||||||
|
:image-server="slot.imageServer"
|
||||||
|
:icon-size="20"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bracket-round bracket-odds" :style="roundStyle(bracket.top16)">
|
<div class="bracket-round bracket-odds" :style="roundStyle(bracket.top16)">
|
||||||
@@ -183,9 +199,17 @@ const odds = (id: number | null) => {
|
|||||||
:key="`mobile-${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
|
:key="`mobile-${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
|
||||||
class="mobile-bracket-name"
|
class="mobile-bracket-name"
|
||||||
:class="{ advanced: slot.advanced }"
|
:class="{ advanced: slot.advanced }"
|
||||||
:style="{ left: `${mobileX[columnIndex]}px`, top: `${mobileY(columnIndex, slotIndex)}px` }"
|
:style="{
|
||||||
|
left: `${(mobileX[columnIndex]! / 390) * 100}%`,
|
||||||
|
top: `${mobileY(columnIndex, slotIndex)}px`,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
{{ slot.name }}
|
<GeneralIdentity
|
||||||
|
:name="slot.name"
|
||||||
|
:picture="slot.picture"
|
||||||
|
:image-server="slot.imageServer"
|
||||||
|
:icon-size="18"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,21 +234,23 @@ const odds = (id: number | null) => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.bracket-canvas {
|
.bracket-canvas {
|
||||||
width: 2000px;
|
width: 100%;
|
||||||
min-width: 2000px;
|
min-width: 1000px;
|
||||||
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.mobile-bracket {
|
.mobile-bracket {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: none;
|
display: none;
|
||||||
width: 390px;
|
width: 100%;
|
||||||
|
max-width: 390px;
|
||||||
height: 544px;
|
height: 544px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.mobile-bracket svg {
|
.mobile-bracket svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 390px;
|
width: 100%;
|
||||||
height: 544px;
|
height: 544px;
|
||||||
}
|
}
|
||||||
.mobile-connector {
|
.mobile-connector {
|
||||||
@@ -239,14 +265,16 @@ const odds = (id: number | null) => {
|
|||||||
.mobile-bracket-name {
|
.mobile-bracket-name {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 64px;
|
width: clamp(58px, 18vw, 72px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
background: rgb(58 33 24 / 92%);
|
background: rgb(58 33 24 / 92%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
min-height: 26px;
|
||||||
line-height: 22px;
|
padding: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 20px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -265,7 +293,7 @@ const odds = (id: number | null) => {
|
|||||||
}
|
}
|
||||||
.bracket-name {
|
.bracket-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0 3px;
|
padding: 2px 3px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -321,8 +349,8 @@ const odds = (id: number | null) => {
|
|||||||
}
|
}
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.tournament-bracket {
|
.tournament-bracket {
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
.bracket-canvas {
|
.bracket-canvas {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon, type GeneralIconSource } from '../../utils/generalIcon';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
name: string;
|
||||||
|
picture?: GeneralIconSource['picture'];
|
||||||
|
imageServer?: GeneralIconSource['imageServer'];
|
||||||
|
iconSize?: number;
|
||||||
|
hideIcon?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
iconSize: 28,
|
||||||
|
hideIcon: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconUrl = computed(() =>
|
||||||
|
resolveGeneralIconUrl({
|
||||||
|
picture: props.picture,
|
||||||
|
imageServer: props.imageServer,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const identityStyle = computed(() => ({ '--general-identity-icon-size': `${props.iconSize}px` }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="general-identity" :style="identityStyle">
|
||||||
|
<img
|
||||||
|
v-if="!hideIcon && name !== '-'"
|
||||||
|
class="general-identity-icon"
|
||||||
|
:src="iconUrl"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
|
/>
|
||||||
|
<span class="general-identity-name">{{ name }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.general-identity {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.general-identity-icon {
|
||||||
|
width: var(--general-identity-icon-size);
|
||||||
|
height: var(--general-identity-icon-size);
|
||||||
|
flex: 0 0 var(--general-identity-icon-size);
|
||||||
|
border: 1px solid rgb(255 255 255 / 28%);
|
||||||
|
background: #111;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.general-identity-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export interface TournamentBracketParticipant {
|
export interface TournamentBracketParticipant {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
picture?: string | null;
|
||||||
|
imageServer?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TournamentBracketMatch {
|
export interface TournamentBracketMatch {
|
||||||
@@ -15,6 +17,8 @@ export interface TournamentBracketMatch {
|
|||||||
export interface TournamentBracketSlot {
|
export interface TournamentBracketSlot {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
picture: string | null;
|
||||||
|
imageServer: number;
|
||||||
advanced: boolean;
|
advanced: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +35,13 @@ export interface TournamentBracketModel {
|
|||||||
top16: TournamentBracketRound;
|
top16: TournamentBracketRound;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptySlot = (): TournamentBracketSlot => ({ id: null, name: '-', advanced: false });
|
const emptySlot = (): TournamentBracketSlot => ({
|
||||||
|
id: null,
|
||||||
|
name: '-',
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
advanced: false,
|
||||||
|
});
|
||||||
|
|
||||||
export const buildTournamentBracket = (
|
export const buildTournamentBracket = (
|
||||||
participants: TournamentBracketParticipant[],
|
participants: TournamentBracketParticipant[],
|
||||||
@@ -39,19 +49,24 @@ export const buildTournamentBracket = (
|
|||||||
winnerId?: number
|
winnerId?: number
|
||||||
): TournamentBracketModel => {
|
): TournamentBracketModel => {
|
||||||
const participantsById = new Map(participants.map((participant) => [participant.id, participant]));
|
const participantsById = new Map(participants.map((participant) => [participant.id, participant]));
|
||||||
const nameOf = (id: number | null): string =>
|
const participantOf = (id: number | null): TournamentBracketParticipant | null =>
|
||||||
id === null ? '-' : (participantsById.get(id)?.name ?? `#${id}`);
|
id === null ? null : (participantsById.get(id) ?? { id, name: `#${id}` });
|
||||||
|
|
||||||
const buildRound = (stage: number, slotCount: number): TournamentBracketRound => {
|
const buildRound = (stage: number, slotCount: number): TournamentBracketRound => {
|
||||||
const roundMatches = matches
|
const roundMatches = matches
|
||||||
.filter((match) => match.stage === stage)
|
.filter((match) => match.stage === stage)
|
||||||
.sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id);
|
.sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id);
|
||||||
const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) =>
|
const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) =>
|
||||||
[match.attackerId, match.defenderId].map((id) => ({
|
[match.attackerId, match.defenderId].map((id) => {
|
||||||
id,
|
const participant = participantOf(id);
|
||||||
name: nameOf(id),
|
return {
|
||||||
advanced: match.winnerId === id,
|
id,
|
||||||
}))
|
name: participant?.name ?? '-',
|
||||||
|
picture: participant?.picture ?? null,
|
||||||
|
imageServer: participant?.imageServer ?? 0,
|
||||||
|
advanced: match.winnerId === id,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
while (slots.length < slotCount) {
|
while (slots.length < slotCount) {
|
||||||
slots.push(emptySlot());
|
slots.push(emptySlot());
|
||||||
@@ -65,7 +80,9 @@ export const buildTournamentBracket = (
|
|||||||
return {
|
return {
|
||||||
champion: {
|
champion: {
|
||||||
id: resolvedWinnerId,
|
id: resolvedWinnerId,
|
||||||
name: nameOf(resolvedWinnerId),
|
name: participantOf(resolvedWinnerId)?.name ?? '-',
|
||||||
|
picture: participantOf(resolvedWinnerId)?.picture ?? null,
|
||||||
|
imageServer: participantOf(resolvedWinnerId)?.imageServer ?? 0,
|
||||||
advanced: resolvedWinnerId !== null,
|
advanced: resolvedWinnerId !== null,
|
||||||
},
|
},
|
||||||
final,
|
final,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { formatServerDateTime } from '@sammo-ts/common';
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||||
|
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||||
@@ -13,6 +14,7 @@ const loading = ref(false);
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const message = ref<string | null>(null);
|
const message = ref<string | null>(null);
|
||||||
const amounts = ref<Record<number, number>>({});
|
const amounts = ref<Record<number, number>>({});
|
||||||
|
const activeRankingPrefix = ref('tt');
|
||||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||||
const stageNames = [
|
const stageNames = [
|
||||||
'경기 없음',
|
'경기 없음',
|
||||||
@@ -58,7 +60,13 @@ const final16Ids = computed(() =>
|
|||||||
const candidates = computed(() =>
|
const candidates = computed(() =>
|
||||||
Array.from({ length: 16 }, (_, index) => {
|
Array.from({ length: 16 }, (_, index) => {
|
||||||
const id = final16Ids.value[index] ?? 0;
|
const id = final16Ids.value[index] ?? 0;
|
||||||
return { id, name: id ? (participantMap.value.get(id)?.name ?? `#${id}`) : '-' };
|
const participant = id ? participantMap.value.get(id) : null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id ? (participant?.name ?? `#${id}`) : '-',
|
||||||
|
picture: participant?.picture ?? null,
|
||||||
|
imageServer: participant?.imageServer ?? 0,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
||||||
@@ -132,58 +140,43 @@ const placeBet = async (targetId: number) => {
|
|||||||
:bet-totals="betTotals"
|
:bet-totals="betTotals"
|
||||||
:total-bet="totalAmount"
|
:total-bet="totalAmount"
|
||||||
:show-legend="false"
|
:show-legend="false"
|
||||||
force-desktop
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="candidate-table bg0">
|
<section class="candidate-table bg0">
|
||||||
<div class="candidate-row names">
|
<div class="candidate-grid">
|
||||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{ candidate.name }}</span>
|
<article v-for="candidate in candidates" :key="candidate.id || candidate.name" class="candidate-card">
|
||||||
|
<GeneralIdentity
|
||||||
|
:name="candidate.name"
|
||||||
|
:picture="candidate.picture"
|
||||||
|
:image-server="candidate.imageServer"
|
||||||
|
:icon-size="36"
|
||||||
|
/>
|
||||||
|
<div class="candidate-return">
|
||||||
|
<span class="ratio-color">{{ ratio(candidate.id) }}</span>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
<span class="gold-color">{{ amounts[candidate.id] ?? 10 }}</span>
|
||||||
|
<span aria-hidden="true">=</span>
|
||||||
|
<strong class="return-color">{{ expected(candidate.id) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="bettingOpen" class="candidate-actions">
|
||||||
|
<select
|
||||||
|
v-model.number="amounts[candidate.id]"
|
||||||
|
:aria-label="`${candidate.name} 베팅 금액`"
|
||||||
|
:disabled="!candidate.id"
|
||||||
|
>
|
||||||
|
<option :value="10">금10</option>
|
||||||
|
<option :value="20">금20</option>
|
||||||
|
<option :value="50">금50</option>
|
||||||
|
<option :value="100">금100</option>
|
||||||
|
<option :value="200">금200</option>
|
||||||
|
<option :value="500">금500</option>
|
||||||
|
<option :value="1000">최대</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" :disabled="!candidate.id" @click="placeBet(candidate.id)">베팅</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div class="candidate-row ratios">
|
<p class="candidate-help">
|
||||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
|
|
||||||
ratio(candidate.id)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="candidate-row multiply">
|
|
||||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">×</span>
|
|
||||||
</div>
|
|
||||||
<div class="candidate-row labels">
|
|
||||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">∥</span>
|
|
||||||
</div>
|
|
||||||
<div class="candidate-row expected">
|
|
||||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
|
|
||||||
expected(candidate.id)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="bettingOpen" class="candidate-row selects">
|
|
||||||
<select
|
|
||||||
v-for="candidate in candidates"
|
|
||||||
:key="candidate.id || candidate.name"
|
|
||||||
v-model.number="amounts[candidate.id]"
|
|
||||||
:aria-label="`${candidate.name} 베팅 금액`"
|
|
||||||
:disabled="!candidate.id"
|
|
||||||
>
|
|
||||||
<option :value="10">금10</option>
|
|
||||||
<option :value="20">금20</option>
|
|
||||||
<option :value="50">금50</option>
|
|
||||||
<option :value="100">금100</option>
|
|
||||||
<option :value="200">금200</option>
|
|
||||||
<option :value="500">금500</option>
|
|
||||||
<option :value="1000">최대</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div v-if="bettingOpen" class="candidate-row buttons">
|
|
||||||
<button
|
|
||||||
v-for="candidate in candidates"
|
|
||||||
:key="candidate.id || candidate.name"
|
|
||||||
type="button"
|
|
||||||
:disabled="!candidate.id"
|
|
||||||
@click="placeBet(candidate.id)"
|
|
||||||
>
|
|
||||||
베팅!
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
|
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
|
||||||
<span class="return-color">적중시 환수금</span><br />
|
<span class="return-color">적중시 환수금</span><br />
|
||||||
<span class="ratio-color">( 베팅후 500원 이하일땐 베팅이 불가능합니다. )</span>
|
<span class="ratio-color">( 베팅후 500원 이하일땐 베팅이 불가능합니다. )</span>
|
||||||
@@ -204,8 +197,26 @@ const placeBet = async (targetId: number) => {
|
|||||||
<section class="ranking-placeholder bg0">
|
<section class="ranking-placeholder bg0">
|
||||||
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
|
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
|
||||||
</section>
|
</section>
|
||||||
|
<div class="ranking-tabs bg0" role="tablist" aria-label="토너먼트 랭킹 종목 선택">
|
||||||
|
<button
|
||||||
|
v-for="section in rankings"
|
||||||
|
:key="`ranking-tab-${section.prefix}`"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeRankingPrefix === section.prefix"
|
||||||
|
:class="{ active: activeRankingPrefix === section.prefix }"
|
||||||
|
@click="activeRankingPrefix = section.prefix"
|
||||||
|
>
|
||||||
|
{{ section.title.replaceAll(' ', '') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<section class="ranking-grid bg0">
|
<section class="ranking-grid bg0">
|
||||||
<table v-for="section in rankings" :key="section.prefix" class="ranking-table">
|
<table
|
||||||
|
v-for="section in rankings"
|
||||||
|
:key="section.prefix"
|
||||||
|
class="ranking-table"
|
||||||
|
:class="{ 'mobile-active': activeRankingPrefix === section.prefix }"
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="9">{{ section.title }}</th>
|
<th colspan="9">{{ section.title }}</th>
|
||||||
@@ -225,7 +236,14 @@ const placeBet = async (targetId: number) => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="entry in section.entries" :key="entry.generalId">
|
<tr v-for="entry in section.entries" :key="entry.generalId">
|
||||||
<td>{{ entry.rank }}</td>
|
<td>{{ entry.rank }}</td>
|
||||||
<td>{{ entry.name }}</td>
|
<td class="ranking-general">
|
||||||
|
<GeneralIdentity
|
||||||
|
:name="entry.name"
|
||||||
|
:picture="entry.picture"
|
||||||
|
:image-server="entry.imageServer"
|
||||||
|
:icon-size="24"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>{{ entry.stat }}</td>
|
<td>{{ entry.stat }}</td>
|
||||||
<td>{{ entry.games }}</td>
|
<td>{{ entry.games }}</td>
|
||||||
<td>{{ entry.win }}</td>
|
<td>{{ entry.win }}</td>
|
||||||
@@ -259,9 +277,10 @@ const placeBet = async (targetId: number) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.betting-page {
|
.betting-page {
|
||||||
width: 1125px;
|
width: 100%;
|
||||||
height: 1346px;
|
max-width: 1200px;
|
||||||
overflow: hidden;
|
min-width: 0;
|
||||||
|
min-height: 100vh;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: var(--sammo-font-sans);
|
font-family: var(--sammo-font-sans);
|
||||||
@@ -270,8 +289,8 @@ const placeBet = async (targetId: number) => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.betting-bracket :deep(.bracket-canvas) {
|
.betting-bracket :deep(.bracket-canvas) {
|
||||||
width: 1125px;
|
width: 100%;
|
||||||
min-width: 1125px;
|
min-width: 1000px;
|
||||||
}
|
}
|
||||||
.betting-bracket :deep(.bracket-round),
|
.betting-bracket :deep(.bracket-round),
|
||||||
.betting-bracket :deep(.connector-row) {
|
.betting-bracket :deep(.connector-row) {
|
||||||
@@ -353,20 +372,34 @@ const placeBet = async (targetId: number) => {
|
|||||||
}
|
}
|
||||||
.candidate-table {
|
.candidate-table {
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
padding: 10px 0;
|
padding: 10px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.candidate-row {
|
.candidate-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(16, 70px);
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
align-items: center;
|
gap: 8px;
|
||||||
min-height: 10px;
|
|
||||||
line-height: 10px;
|
|
||||||
}
|
}
|
||||||
.names {
|
.candidate-card {
|
||||||
min-height: 14px;
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #5b504b;
|
||||||
|
background: rgb(0 0 0 / 26%);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.candidate-return {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr auto 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 8px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.candidate-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 64px;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.ratios,
|
|
||||||
.ratio-color {
|
.ratio-color {
|
||||||
color: skyblue;
|
color: skyblue;
|
||||||
}
|
}
|
||||||
@@ -378,7 +411,7 @@ const placeBet = async (targetId: number) => {
|
|||||||
color: orange;
|
color: orange;
|
||||||
}
|
}
|
||||||
select,
|
select,
|
||||||
.buttons button {
|
.candidate-actions button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 27px;
|
min-height: 27px;
|
||||||
padding: 2px 1px;
|
padding: 2px 1px;
|
||||||
@@ -412,7 +445,7 @@ select:disabled {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.candidate-table p {
|
.candidate-help {
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@@ -431,11 +464,13 @@ select:disabled {
|
|||||||
}
|
}
|
||||||
.ranking-grid {
|
.ranking-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 280px);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
}
|
}
|
||||||
.ranking-table {
|
.ranking-table {
|
||||||
width: 280px;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -443,7 +478,7 @@ select:disabled {
|
|||||||
}
|
}
|
||||||
.ranking-table th,
|
.ranking-table th,
|
||||||
.ranking-table td {
|
.ranking-table td {
|
||||||
height: 14px;
|
height: 28px;
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
}
|
}
|
||||||
@@ -457,12 +492,20 @@ select:disabled {
|
|||||||
.ranking-table .bg1 {
|
.ranking-table .bg1 {
|
||||||
background: #213b52;
|
background: #213b52;
|
||||||
}
|
}
|
||||||
|
.ranking-table th:nth-child(2),
|
||||||
.ranking-table td:nth-child(2) {
|
.ranking-table td:nth-child(2) {
|
||||||
max-width: 80px;
|
width: 130px;
|
||||||
|
max-width: 130px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.ranking-general {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.ranking-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.guide {
|
.guide {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -470,4 +513,83 @@ select:disabled {
|
|||||||
.error {
|
.error {
|
||||||
color: #ff8080;
|
color: #ff8080;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.betting-page {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
height: auto;
|
||||||
|
min-height: 55px;
|
||||||
|
}
|
||||||
|
.state {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.section-title,
|
||||||
|
.ranking-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.candidate-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.candidate-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 112px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
.candidate-return {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.candidate-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.candidate-help {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.ranking-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ranking-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.ranking-tabs button {
|
||||||
|
height: 36px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.ranking-tabs button.active {
|
||||||
|
border-color: #f39c12;
|
||||||
|
background: #8a5b13;
|
||||||
|
}
|
||||||
|
.ranking-grid {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.ranking-table {
|
||||||
|
display: none;
|
||||||
|
min-width: 390px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.ranking-table.mobile-active {
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
.ranking-table th:nth-child(2),
|
||||||
|
.ranking-table td:nth-child(2) {
|
||||||
|
width: 112px;
|
||||||
|
max-width: 112px;
|
||||||
|
}
|
||||||
|
.guide,
|
||||||
|
.betting-footer {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.betting-footer small {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
||||||
|
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
|
||||||
|
|
||||||
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
||||||
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
|
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
|
||||||
@@ -360,7 +361,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
|
<main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<span>내 정 보</span>
|
<span>내 정 보</span>
|
||||||
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
|
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
|
||||||
@@ -544,6 +545,16 @@ onMounted(() => {
|
|||||||
<span v-if="data.iconChangeAvailableAt" class="hint">
|
<span v-if="data.iconChangeAvailableAt" class="hint">
|
||||||
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
|
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
|
||||||
</span>
|
</span>
|
||||||
|
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
|
||||||
|
<img
|
||||||
|
:src="resolveGeneralIconUrl(selectedIcon)"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
alt=""
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
|
/>
|
||||||
|
<strong>{{ data.general.name }}</strong>
|
||||||
|
</div>
|
||||||
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
|
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
|
||||||
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
|
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
|
||||||
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
||||||
@@ -678,8 +689,7 @@ onMounted(() => {
|
|||||||
.legacy-page {
|
.legacy-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
min-width: 500px;
|
min-width: 0;
|
||||||
height: 1257.5px;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -798,8 +808,9 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
.portrait-cell {
|
.portrait-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-right: 1px solid #777;
|
border-right: 1px solid #777;
|
||||||
@@ -945,6 +956,21 @@ dt {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
}
|
}
|
||||||
|
.selected-general-icon {
|
||||||
|
display: flex;
|
||||||
|
max-width: 260px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 8px auto;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #666;
|
||||||
|
background: rgb(23 42 82 / 70%);
|
||||||
|
}
|
||||||
|
.selected-general-icon img {
|
||||||
|
flex: 0 0 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
.general-icon-choice {
|
.general-icon-choice {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -952,16 +978,55 @@ dt {
|
|||||||
}
|
}
|
||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
.legacy-page {
|
.legacy-page {
|
||||||
width: 500px;
|
width: 100%;
|
||||||
height: 1798.34px;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.my-page-mobile-scroll-spacer {
|
.my-page-mobile-scroll-spacer {
|
||||||
display: block;
|
display: none;
|
||||||
height: 100px;
|
|
||||||
}
|
}
|
||||||
.top-grid,
|
.top-grid,
|
||||||
.log-grid {
|
.log-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.title-row {
|
||||||
|
height: auto;
|
||||||
|
min-height: 54px;
|
||||||
|
}
|
||||||
|
.general-table {
|
||||||
|
grid-template-columns: minmax(142px, 38%) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.portrait-cell {
|
||||||
|
padding: 8px 6px;
|
||||||
|
}
|
||||||
|
.portrait-image {
|
||||||
|
flex: 0 0 52px;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
dl > div {
|
||||||
|
grid-template-columns: 62px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
dt,
|
||||||
|
dd {
|
||||||
|
padding: 2px 3px;
|
||||||
|
}
|
||||||
|
.settings-column {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.screen-mode-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.button-group {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.item-group {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.custom-css textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { formatServerDateTime } from '@sammo-ts/common';
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||||
|
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { resolveTournamentStageName } from '../utils/tournamentStatus';
|
import { resolveTournamentStageName } from '../utils/tournamentStatus';
|
||||||
|
|
||||||
@@ -14,8 +15,11 @@ const loading = ref(false);
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const actionMessage = ref<string | null>(null);
|
const actionMessage = ref<string | null>(null);
|
||||||
const adminEnabled = ref(false);
|
const adminEnabled = ref(false);
|
||||||
|
const activeFinalGroup = ref(0);
|
||||||
|
const activePreliminaryGroup = ref(0);
|
||||||
|
|
||||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||||
|
const typeStatNames = ['종합', '통솔', '무력', '지력'];
|
||||||
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
|
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -64,6 +68,26 @@ const groups = computed(() =>
|
|||||||
.sort((a, b) => (a.finalRank ?? 99) - (b.finalRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
|
.sort((a, b) => (a.finalRank ?? 99) - (b.finalRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const preliminaryGroups = computed(() =>
|
||||||
|
Array.from({ length: 8 }, (_, index) =>
|
||||||
|
(snapshot.value?.participants ?? [])
|
||||||
|
.filter((participant) => participant.groupId === index)
|
||||||
|
.sort((a, b) => (a.seedRank ?? 99) - (b.seedRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const groupNames = ['一', '二', '三', '四', '五', '六', '七', '八'];
|
||||||
|
const statOf = (participant: Snapshot['participants'][number] | undefined): number | '' => {
|
||||||
|
if (!participant) return '';
|
||||||
|
const type = snapshot.value?.state?.type ?? 0;
|
||||||
|
if (type === 0) return participant.leadership + participant.strength + participant.intel;
|
||||||
|
if (type === 1) return participant.leadership;
|
||||||
|
if (type === 2) return participant.strength;
|
||||||
|
return participant.intel;
|
||||||
|
};
|
||||||
|
const gamesOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
|
||||||
|
participant ? (participant.win ?? 0) + (participant.draw ?? 0) + (participant.lose ?? 0) : '';
|
||||||
|
const pointsOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
|
||||||
|
participant ? (participant.win ?? 0) * 3 + (participant.draw ?? 0) : '';
|
||||||
const currentMatch = computed(() => {
|
const currentMatch = computed(() => {
|
||||||
const state = snapshot.value?.state;
|
const state = snapshot.value?.state;
|
||||||
if (!state || state.stage < 7 || state.stage > 10) return null;
|
if (!state || state.stage < 7 || state.stage > 10) return null;
|
||||||
@@ -154,7 +178,6 @@ const start = async () => {
|
|||||||
:winner-id="snapshot?.state?.winnerId"
|
:winner-id="snapshot?.state?.winnerId"
|
||||||
:bet-totals="betTotals"
|
:bet-totals="betTotals"
|
||||||
:total-bet="totalBet"
|
:total-bet="totalBet"
|
||||||
force-desktop
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section v-if="currentMatch" class="fight bg0">
|
<section v-if="currentMatch" class="fight bg0">
|
||||||
@@ -163,18 +186,35 @@ const start = async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section-title groups-title bg2">조별 본선 순위</section>
|
<section class="section-title groups-title bg2">조별 본선 순위</section>
|
||||||
|
<div class="group-tabs bg0" role="tablist" aria-label="본선 조 선택">
|
||||||
|
<button
|
||||||
|
v-for="(groupName, groupIndex) in groupNames"
|
||||||
|
:key="`final-tab-${groupName}`"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeFinalGroup === groupIndex"
|
||||||
|
:class="{ active: activeFinalGroup === groupIndex }"
|
||||||
|
@click="activeFinalGroup = groupIndex"
|
||||||
|
>
|
||||||
|
{{ groupName }}조
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<section class="group-grid bg0">
|
<section class="group-grid bg0">
|
||||||
<table v-for="(group, groupIndex) in groups" :key="groupIndex">
|
<table
|
||||||
|
v-for="(group, groupIndex) in groups"
|
||||||
|
:key="groupIndex"
|
||||||
|
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
|
||||||
|
>
|
||||||
<caption>
|
<caption>
|
||||||
{{
|
{{
|
||||||
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex]
|
groupNames[groupIndex]
|
||||||
}}조
|
}}조
|
||||||
</caption>
|
</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>순</th>
|
<th>순</th>
|
||||||
<th>장수</th>
|
<th>장수</th>
|
||||||
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
|
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
||||||
<th>경</th>
|
<th>경</th>
|
||||||
<th>승</th>
|
<th>승</th>
|
||||||
<th>무</th>
|
<th>무</th>
|
||||||
@@ -186,26 +226,21 @@ const start = async () => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="rowIndex in 4" :key="rowIndex">
|
<tr v-for="rowIndex in 4" :key="rowIndex">
|
||||||
<td>{{ rowIndex }}</td>
|
<td>{{ rowIndex }}</td>
|
||||||
<td>{{ group[rowIndex - 1]?.name ?? '' }}</td>
|
<td class="general-cell">
|
||||||
<td>
|
<GeneralIdentity
|
||||||
{{
|
v-if="group[rowIndex - 1]"
|
||||||
group[rowIndex - 1]
|
:name="group[rowIndex - 1]!.name"
|
||||||
? (group[rowIndex - 1]!.win ?? 0) +
|
:picture="group[rowIndex - 1]!.picture"
|
||||||
(group[rowIndex - 1]!.draw ?? 0) +
|
:image-server="group[rowIndex - 1]!.imageServer"
|
||||||
(group[rowIndex - 1]!.lose ?? 0)
|
:icon-size="24"
|
||||||
: ''
|
/>
|
||||||
}}
|
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
||||||
|
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
||||||
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
||||||
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
||||||
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
||||||
<td>
|
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
||||||
{{
|
|
||||||
group[rowIndex - 1]
|
|
||||||
? (group[rowIndex - 1]!.win ?? 0) * 3 + (group[rowIndex - 1]!.draw ?? 0)
|
|
||||||
: ''
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -213,18 +248,35 @@ const start = async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section-title groups-title bg2">조별 예선 순위</section>
|
<section class="section-title groups-title bg2">조별 예선 순위</section>
|
||||||
|
<div class="group-tabs bg0" role="tablist" aria-label="예선 조 선택">
|
||||||
|
<button
|
||||||
|
v-for="(groupName, groupIndex) in groupNames"
|
||||||
|
:key="`preliminary-tab-${groupName}`"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activePreliminaryGroup === groupIndex"
|
||||||
|
:class="{ active: activePreliminaryGroup === groupIndex }"
|
||||||
|
@click="activePreliminaryGroup = groupIndex"
|
||||||
|
>
|
||||||
|
{{ groupName }}조
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<section class="group-grid preliminary-grid bg0">
|
<section class="group-grid preliminary-grid bg0">
|
||||||
<table v-for="groupIndex in 8" :key="`preliminary-${groupIndex}`">
|
<table
|
||||||
|
v-for="(group, groupIndex) in preliminaryGroups"
|
||||||
|
:key="`preliminary-${groupIndex}`"
|
||||||
|
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
|
||||||
|
>
|
||||||
<caption>
|
<caption>
|
||||||
{{
|
{{
|
||||||
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex - 1]
|
groupNames[groupIndex]
|
||||||
}}조
|
}}조
|
||||||
</caption>
|
</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>순</th>
|
<th>순</th>
|
||||||
<th>장수</th>
|
<th>장수</th>
|
||||||
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
|
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
||||||
<th>경</th>
|
<th>경</th>
|
||||||
<th>승</th>
|
<th>승</th>
|
||||||
<th>무</th>
|
<th>무</th>
|
||||||
@@ -236,14 +288,22 @@ const start = async () => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="rowIndex in 8" :key="rowIndex">
|
<tr v-for="rowIndex in 8" :key="rowIndex">
|
||||||
<td>{{ rowIndex }}</td>
|
<td>{{ rowIndex }}</td>
|
||||||
<td></td>
|
<td class="general-cell">
|
||||||
<td></td>
|
<GeneralIdentity
|
||||||
<td></td>
|
v-if="group[rowIndex - 1]"
|
||||||
<td></td>
|
:name="group[rowIndex - 1]!.name"
|
||||||
<td></td>
|
:picture="group[rowIndex - 1]!.picture"
|
||||||
<td></td>
|
:image-server="group[rowIndex - 1]!.imageServer"
|
||||||
<td></td>
|
:icon-size="24"
|
||||||
<td></td>
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
||||||
|
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
||||||
|
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -291,9 +351,10 @@ const start = async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.legacy-page {
|
.legacy-page {
|
||||||
width: 2009px;
|
width: 100%;
|
||||||
height: 1059px;
|
max-width: 1200px;
|
||||||
overflow: hidden;
|
min-width: 0;
|
||||||
|
min-height: 100vh;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: var(--sammo-font-sans);
|
font-family: var(--sammo-font-sans);
|
||||||
@@ -420,13 +481,15 @@ button:focus-visible {
|
|||||||
}
|
}
|
||||||
.group-grid {
|
.group-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(8, 250px);
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
}
|
}
|
||||||
table {
|
table {
|
||||||
width: 250px;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: auto;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
caption {
|
caption {
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
@@ -439,14 +502,99 @@ th {
|
|||||||
}
|
}
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
height: 17px;
|
height: 30px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
padding: 1px 3px;
|
padding: 1px 3px;
|
||||||
}
|
}
|
||||||
|
.group-grid th:first-child,
|
||||||
|
.group-grid td:first-child {
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
.group-grid th:nth-child(2),
|
||||||
|
.group-grid td:nth-child(2) {
|
||||||
|
width: 92px;
|
||||||
|
}
|
||||||
|
.general-cell {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.group-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.admin-row {
|
.admin-row {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.error-row {
|
.error-row {
|
||||||
color: #ff8080;
|
color: #ff8080;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.legacy-page {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.legacy-title {
|
||||||
|
height: auto;
|
||||||
|
min-height: 55px;
|
||||||
|
}
|
||||||
|
.state-row {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.group-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, minmax(44px, 1fr));
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 6px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.group-tabs button {
|
||||||
|
min-width: 44px;
|
||||||
|
height: 34px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.group-tabs button.active {
|
||||||
|
border-color: #f39c12;
|
||||||
|
background: #8a5b13;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.group-grid {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.group-grid table {
|
||||||
|
display: none;
|
||||||
|
min-width: 370px;
|
||||||
|
}
|
||||||
|
.group-grid table.mobile-active {
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
.group-grid th,
|
||||||
|
.group-grid td {
|
||||||
|
height: 31px;
|
||||||
|
padding: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.group-grid th:first-child,
|
||||||
|
.group-grid td:first-child {
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
.group-grid th:nth-child(2),
|
||||||
|
.group-grid td:nth-child(2) {
|
||||||
|
width: 108px;
|
||||||
|
}
|
||||||
|
.tournament-guide {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.tournament-footer {
|
||||||
|
padding: 10px 0 0;
|
||||||
|
}
|
||||||
|
.tournament-footer small {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { describe, it } from 'node:test';
|
|||||||
|
|
||||||
import { buildTournamentBracket } from '../src/utils/tournamentBracket.ts';
|
import { buildTournamentBracket } from '../src/utils/tournamentBracket.ts';
|
||||||
|
|
||||||
const participants = Array.from({ length: 16 }, (_, index) => ({ id: index + 1, name: `장수${index + 1}` }));
|
const participants = Array.from({ length: 16 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
name: `장수${index + 1}`,
|
||||||
|
picture: `${index + 1}.jpg`,
|
||||||
|
imageServer: index % 2,
|
||||||
|
}));
|
||||||
const matches = [
|
const matches = [
|
||||||
...Array.from({ length: 8 }, (_, index) => ({
|
...Array.from({ length: 8 }, (_, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
@@ -37,6 +42,8 @@ void describe('tournament bracket', () => {
|
|||||||
const bracket = buildTournamentBracket(participants, matches, 1);
|
const bracket = buildTournamentBracket(participants, matches, 1);
|
||||||
|
|
||||||
assert.equal(bracket.champion.name, '장수1');
|
assert.equal(bracket.champion.name, '장수1');
|
||||||
|
assert.equal(bracket.champion.picture, '1.jpg');
|
||||||
|
assert.equal(bracket.top16.slots[1]?.imageServer, 1);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
bracket.top16.slots.map((slot) => slot.name),
|
bracket.top16.slots.map((slot) => slot.name),
|
||||||
participants.map((participant) => participant.name)
|
participants.map((participant) => participant.name)
|
||||||
@@ -52,10 +59,16 @@ void describe('tournament bracket', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
void it('renders missing future rounds as stable empty slots without inventing generals', () => {
|
void it('renders missing future rounds as stable empty slots without inventing generals', () => {
|
||||||
const bracket = buildTournamentBracket(participants, matches.filter((match) => match.stage === 7));
|
const bracket = buildTournamentBracket(
|
||||||
|
participants,
|
||||||
|
matches.filter((match) => match.stage === 7)
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(bracket.champion.name, '-');
|
assert.equal(bracket.champion.name, '-');
|
||||||
assert.deepEqual(bracket.final.slots.map((slot) => slot.name), ['-', '-']);
|
assert.deepEqual(
|
||||||
|
bracket.final.slots.map((slot) => slot.name),
|
||||||
|
['-', '-']
|
||||||
|
);
|
||||||
assert.equal(bracket.top16.slots[0]?.name, '장수1');
|
assert.equal(bracket.top16.slots[0]?.name, '장수1');
|
||||||
assert.equal(bracket.top16.slots[15]?.name, '장수16');
|
assert.equal(bracket.top16.slots[15]?.name, '장수16');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user