diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 77abd65e..fec115e6 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -137,7 +137,24 @@ export const tournamentRouter = router({ store.getMatches(), 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 }) => { await getMyGeneral(ctx); @@ -177,7 +194,16 @@ export const tournamentRouter = router({ } const generals = await ctx.db.general.findMany({ 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) => { @@ -201,6 +227,8 @@ export const tournamentRouter = router({ generalId: general.id, name: general.name, npcState: general.npcState, + picture: general.picture, + imageServer: general.imageServer, stat, games: win + draw + lose, win, diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index d2f59ac9..5cef826e 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -84,6 +84,8 @@ const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow => id, userId, name: `장수${id}`, + picture: `${id}.jpg`, + imageServer: id % 2, leadership: 70 + id, strength: 60 + id, intel: 50 + id, @@ -296,6 +298,10 @@ describe('tournament router permissions and mutations', () => { const sections = await ownerCaller.tournament.getRankings(); expect(sections).toHaveLength(4); 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( 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' }); }); + 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 () => { const redis = new MemoryRedis(); const transport = new TournamentTransport(); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 202496d2..c8661e47 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -646,7 +646,7 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p 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: [] }; await install(page, state); 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.minWidth).toBe('500px'); + expect(desktop.minWidth).toBe('0px'); expect(desktop.fontSize).toBe('14px'); expect(desktop.columns.split(' ')).toHaveLength(2); 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'); } - await page.setViewportSize({ width: 500, height: 900 }); + await page.setViewportSize({ width: 390, height: 900 }); await page.reload(); const mobile = await page.locator('#container').evaluate((element) => { const rect = element.getBoundingClientRect(); const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); + const icon = element.querySelector('.portrait-image')!.getBoundingClientRect(); + const name = element.querySelector('.portrait-cell strong')!.getBoundingClientRect(); return { width: rect.width, scrollWidth: document.documentElement.scrollWidth, columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns, settingsOffset: settings.x - rect.x, 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({ - width: 500, - scrollWidth: 500, - columns: '500px', + width: 390, + scrollWidth: 390, + columns: '390px', 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); }); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 920ffa54..9ae56137 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -1,15 +1,22 @@ 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 { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const responsiveArtifactDir = process.env.TOURNAMENT_RESPONSIVE_ARTIFACT_DIR; const imageRoots = [ ...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, '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 = [ '관우', '장료', @@ -35,6 +42,8 @@ const participants = names.map((name, index) => ({ strength: 80, intel: 80, level: 10, + picture: 'default.jpg', + imageServer: 0, groupId: 10 + (index % 8), groupNo: Math.floor(index / 8), win: 3 - (index % 2), @@ -88,6 +97,26 @@ const readReferenceImage = async (filename: string): Promise => { throw new Error(`Reference image not found: ${filename}`); }; +const readReferenceIcon = async (filename: string): Promise => { + 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) => { await page.addInitScript((profile) => { 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 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) => { const results = operationNames(route).map((operation) => { if (operation === 'auth.status') return response({ ok: true }); @@ -125,12 +157,43 @@ const installFixture = async (page: Page) => { } if (operation === 'tournament.getBettingSummary') { 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: {}, totalAmount: 2800, 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); }); 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, championCenter: champion.x + champion.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(geometry.finalistCenters).toHaveLength(2); expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).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) => { @@ -197,5 +264,62 @@ test('mobile bracket shows every round and general within the handheld width', a expect(bounds.width).toBe(390); expect(bounds.minX).toBeGreaterThanOrEqual(0); 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('.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('.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')); }); diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 9f05f19f..0564b4d9 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -39,6 +39,13 @@ body { 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(.chief-page), body:has(.global-page), diff --git a/app/game-frontend/src/components/tournament/TournamentBracket.vue b/app/game-frontend/src/components/tournament/TournamentBracket.vue index 3d9dff6e..680665f2 100644 --- a/app/game-frontend/src/components/tournament/TournamentBracket.vue +++ b/app/game-frontend/src/components/tournament/TournamentBracket.vue @@ -1,5 +1,6 @@ + + + + diff --git a/app/game-frontend/src/utils/tournamentBracket.ts b/app/game-frontend/src/utils/tournamentBracket.ts index 2b29e237..7303a57f 100644 --- a/app/game-frontend/src/utils/tournamentBracket.ts +++ b/app/game-frontend/src/utils/tournamentBracket.ts @@ -1,6 +1,8 @@ export interface TournamentBracketParticipant { id: number; name: string; + picture?: string | null; + imageServer?: number | null; } export interface TournamentBracketMatch { @@ -15,6 +17,8 @@ export interface TournamentBracketMatch { export interface TournamentBracketSlot { id: number | null; name: string; + picture: string | null; + imageServer: number; advanced: boolean; } @@ -31,7 +35,13 @@ export interface TournamentBracketModel { 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 = ( participants: TournamentBracketParticipant[], @@ -39,19 +49,24 @@ export const buildTournamentBracket = ( winnerId?: number ): TournamentBracketModel => { const participantsById = new Map(participants.map((participant) => [participant.id, participant])); - const nameOf = (id: number | null): string => - id === null ? '-' : (participantsById.get(id)?.name ?? `#${id}`); + const participantOf = (id: number | null): TournamentBracketParticipant | null => + id === null ? null : (participantsById.get(id) ?? { id, name: `#${id}` }); const buildRound = (stage: number, slotCount: number): TournamentBracketRound => { const roundMatches = matches .filter((match) => match.stage === stage) .sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id); const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) => - [match.attackerId, match.defenderId].map((id) => ({ - id, - name: nameOf(id), - advanced: match.winnerId === id, - })) + [match.attackerId, match.defenderId].map((id) => { + const participant = participantOf(id); + return { + id, + name: participant?.name ?? '-', + picture: participant?.picture ?? null, + imageServer: participant?.imageServer ?? 0, + advanced: match.winnerId === id, + }; + }) ); while (slots.length < slotCount) { slots.push(emptySlot()); @@ -65,7 +80,9 @@ export const buildTournamentBracket = ( return { champion: { id: resolvedWinnerId, - name: nameOf(resolvedWinnerId), + name: participantOf(resolvedWinnerId)?.name ?? '-', + picture: participantOf(resolvedWinnerId)?.picture ?? null, + imageServer: participantOf(resolvedWinnerId)?.imageServer ?? 0, advanced: resolvedWinnerId !== null, }, final, diff --git a/app/game-frontend/src/views/BettingView.vue b/app/game-frontend/src/views/BettingView.vue index fd1f8f6e..42dc7844 100644 --- a/app/game-frontend/src/views/BettingView.vue +++ b/app/game-frontend/src/views/BettingView.vue @@ -2,6 +2,7 @@ import { formatServerDateTime } from '@sammo-ts/common'; import { computed, onMounted, ref } from 'vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue'; +import GeneralIdentity from '../components/ui/GeneralIdentity.vue'; import { trpc } from '../utils/trpc'; type Snapshot = Awaited>; @@ -13,6 +14,7 @@ const loading = ref(false); const error = ref(null); const message = ref(null); const amounts = ref>({}); +const activeRankingPrefix = ref('tt'); const typeNames = ['전력전', '통솔전', '일기토', '설전']; const stageNames = [ '경기 없음', @@ -58,7 +60,13 @@ const final16Ids = computed(() => const candidates = computed(() => Array.from({ length: 16 }, (_, index) => { 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); @@ -132,58 +140,43 @@ const placeBet = async (targetId: number) => { :bet-totals="betTotals" :total-bet="totalAmount" :show-legend="false" - force-desktop />
-
- {{ candidate.name }} +
+
+ +
+ {{ ratio(candidate.id) }} + + {{ amounts[candidate.id] ?? 10 }} + + {{ expected(candidate.id) }} +
+
+ + +
+
-
- {{ - ratio(candidate.id) - }} -
-
- × -
-
- -
-
- {{ - expected(candidate.id) - }} -
-
- -
-
- -
-

+

배당률 × 베팅금 = 적중시 환수금
( 베팅후 500원 이하일땐 베팅이 불가능합니다. ) @@ -204,8 +197,26 @@ const placeBet = async (targetId: number) => {

순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
+
+ +
- +
@@ -225,7 +236,14 @@ const placeBet = async (targetId: number) => { - + @@ -259,9 +277,10 @@ const placeBet = async (targetId: number) => { diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 45cbd49b..a7caa5bc 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -167,6 +167,7 @@ const items = 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 showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false); @@ -360,7 +361,7 @@ onMounted(() => {
{{ section.title }}
{{ entry.rank }}{{ entry.name }} + + {{ entry.stat }} {{ entry.games }} {{ entry.win }}