diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 63a9496..2c9d29f 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -82,15 +82,22 @@ export const worldRouter = router({ throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); } const nationRows = nations - .map((nation) => ({ - id: nation.id, - name: nation.name, - color: nation.color, - capitalCityId: nation.capitalCityId ?? 0, - level: nation.level, - power: typeof asRecord(nation.meta).power === 'number' ? Number(asRecord(nation.meta).power) : 0, - cities: cities.filter((city) => city.nationId === nation.id).map((city) => city.name), - })) + .map((nation) => { + const meta = asRecord(nation.meta); + return { + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId ?? 0, + level: nation.level, + power: typeof meta.power === 'number' && Number.isFinite(meta.power) ? meta.power : 0, + generalCount: + typeof meta.gennum === 'number' && Number.isFinite(meta.gennum) + ? Math.max(0, Math.trunc(meta.gennum)) + : 0, + cities: cities.filter((city) => city.nationId === nation.id).map((city) => city.name), + }; + }) .sort((left, right) => right.power - left.power || left.id - right.id); const matrix: Record> = {}; for (const nation of nationRows) { diff --git a/app/game-api/test/inGameInfoRouter.test.ts b/app/game-api/test/inGameInfoRouter.test.ts index 9cb7c5d..18f37b2 100644 --- a/app/game-api/test/inGameInfoRouter.test.ts +++ b/app/game-api/test/inGameInfoRouter.test.ts @@ -106,6 +106,7 @@ const context = ( findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => where.userId === me.userId ? me : null ), + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => (where.id === me.id ? me : null)), findMany: vi.fn(async (args: { where?: Record; select?: Record }) => { if (args.where?.nationId === 1 && args.select?.cityId) return [ @@ -128,14 +129,52 @@ const context = ( meta: options.nationMeta ?? {}, })), findMany: vi.fn(async () => [ - { id: 1, name: '아국', color: '#008000', level: 1, capitalCityId: 1, meta: { power: 100 } }, - { id: 2, name: '적국', color: '#800000', level: 1, capitalCityId: 2, meta: { power: 90 } }, + { + id: 1, + name: '아국', + color: '#008000', + level: 1, + capitalCityId: 1, + meta: { power: 100, gennum: 4 }, + }, + { + id: 2, + name: '적국', + color: '#800000', + level: 1, + capitalCityId: 2, + meta: { power: 90, gennum: 3 }, + }, ]), }, city: { findMany: vi.fn(async () => cities) }, - worldState: { findFirst: vi.fn(async () => ({ meta: { turntime: '2026-01-01' } })) }, + worldState: { + findFirst: vi.fn(async () => ({ + currentYear: 200, + currentMonth: 1, + config: { startYear: 180 }, + meta: { turntime: '2026-01-01' }, + })), + }, generalTurn: { findMany: vi.fn(async () => []) }, diplomacy: { findMany: vi.fn(async () => []) }, + $queryRaw: vi + .fn() + .mockResolvedValueOnce( + cities.map((item) => ({ + id: item.id, + level: item.level, + nationId: item.nationId, + region: item.region, + supplyState: item.supplyState, + meta: item.meta, + })) + ) + .mockResolvedValueOnce([ + { id: 1, name: '아국', color: '#008000', capitalCityId: 1, meta: {} }, + { id: 2, name: '적국', color: '#800000', capitalCityId: 2, meta: {} }, + ]) + .mockResolvedValueOnce([{ cityId: me.cityId }]), }; const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; const accessTokenStore = new RedisAccessTokenStore(redis, 'che:default'); @@ -156,6 +195,15 @@ const context = ( }; describe('in-game information permissions', () => { + it('returns the ref nation summary fields in descending power order', async () => { + const result = await appRouter.createCaller(context()).world.getGlobalInfo(); + + expect(result.nations).toEqual([ + expect.objectContaining({ id: 1, name: '아국', power: 100, generalCount: 4, cities: ['도시1', '도시80'] }), + expect.objectContaining({ id: 2, name: '적국', power: 90, generalCount: 3, cities: ['도시2', '도시3'] }), + ]); + }); + it('does not expose nation-only pages to a wandering general', async () => { const caller = appRouter.createCaller(context({ me: general({ nationId: 0, officerLevel: 0 }) })); await expect(caller.nation.getNationInfo()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 1ce594c..cdc6ad3 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -244,7 +244,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom blockGeneralCreate: install?.blockGeneralCreate, npcMode: install?.npcMode, showImgLevel: install?.showImgLevel, - tournamentTrig: install?.tournamentTrig, + tournamentTrig: install?.tournamentTrig ?? true, extendedGeneral: includeExtendedGeneral, turnTermMinutes: install?.turnTermMinutes, syncTurnTime: install?.sync, diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 6fa3b82..7873eab 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -103,6 +103,7 @@ describeDb('scenario database seed', () => { await connector.connect(); try { const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient; + const worldState = await prisma.worldState.findFirst(); const [nationCount, cityCount, generalCount, diplomacyCount, eventCount] = await Promise.all([ prisma.nation.count(), prisma.city.count(), @@ -116,6 +117,7 @@ describeDb('scenario database seed', () => { expect(generalCount).toBe(seed.generals.length); expect(diplomacyCount).toBe(seed.nations.length * Math.max(0, seed.nations.length - 1)); expect(eventCount).toBe(seed.events.length); + expect(worldState?.config).toMatchObject({ tournamentTrig: true }); expect(generalCount).toBeGreaterThan(0); const seededGeneral = await prisma.general.findFirst(); expect(seededGeneral?.startAge).toBe(seededGeneral?.age); @@ -201,7 +203,7 @@ describeDb('scenario database seed', () => { blockGeneralCreate: 2, npcMode: 0, showImgLevel: 3, - tournamentTrig: true, + tournamentTrig: false, joinMode: 'full', autorunUser: { limitMinutes: 60, @@ -234,6 +236,7 @@ describeDb('scenario database seed', () => { const config = (worldState.config ?? {}) as Record; expect(config.extendedGeneral).toBe(false); expect(config.joinMode).toBe('full'); + expect(config.tournamentTrig).toBe(false); const meta = (worldState.meta ?? {}) as Record; const autorun = (meta.autorun_user ?? {}) as Record; diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 528ddb9..a2c34ae 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -219,6 +219,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb capitalCityId: 1, level: 1, power: 1234, + generalCount: 2, cities: ['업'], }, { @@ -228,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb capitalCityId: 2, level: 1, power: 1000, + generalCount: 1, cities: ['허창'], }, ], @@ -350,6 +352,69 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p } }); +test('global-info renders the ref nation summary columns beside the map', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await go(page, 'global-info'); + + const summary = page.locator('.simple-nation-list'); + await expect(summary).toBeVisible(); + await expect(summary.locator('thead')).toContainText('국명'); + await expect(summary.locator('thead')).toContainText('국력'); + await expect(summary.locator('thead')).toContainText('장수'); + await expect(summary.locator('thead')).toContainText('속령'); + await expect(summary.locator('tbody tr').first()).toHaveText(/아국\s*1,234\s*2\s*1/u); + await expect(summary.locator('tbody tr').first().locator('td').last()).toHaveAttribute('title', '업'); + + const geometry = await summary.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const headings = Array.from(element.querySelectorAll('th')).map((heading) => heading.getBoundingClientRect().width); + return { x: rect.x, width: rect.width, headings }; + }); + expect(geometry).toMatchObject({ x: 800, width: 300 }); + expect(geometry.headings[0]).toBeCloseTo((300 * 44) / 97, 0); + expect(geometry.headings[1]).toBeCloseTo((300 * 23) / 97, 0); + expect(geometry.headings[2]).toBeCloseTo((300 * 15) / 97, 0); + expect(geometry.headings[3]).toBeCloseTo((300 * 15) / 97, 0); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await writeFile( + resolve(artifactRoot, 'core-global-info-computed-dom.json'), + `${JSON.stringify( + { + geometry, + headings: await summary.locator('th').allTextContents(), + rows: await summary.locator('tbody tr').allTextContents(), + cityTitles: await summary.locator('tbody td:last-child').evaluateAll((cells) => + cells.map((cell) => cell.getAttribute('title')) + ), + }, + null, + 2 + )}\n`, + 'utf8' + ); + await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-desktop.png'), fullPage: true }); + } + + await page.setViewportSize({ width: 390, height: 844 }); + const mobileGeometry = await page.locator('.map-grid').evaluate((element) => { + const map = element.querySelector('.map-viewer')?.getBoundingClientRect(); + const summary = element.querySelector('.simple-nation-list')?.getBoundingClientRect(); + return { + map: map ? { y: map.y, width: map.width, bottom: map.bottom } : null, + summary: summary ? { y: summary.y, width: summary.width } : null, + }; + }); + expect(mobileGeometry.map?.width).toBe(500); + expect(mobileGeometry.summary?.width).toBe(500); + expect(mobileGeometry.summary?.y).toBe(mobileGeometry.map?.bottom); + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-mobile.png'), fullPage: true }); + } +}); + test('current-city hides values and general rows for a wandering user', async ({ page }) => { await install(page, 'wanderer'); await go(page, 'current-city'); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 09a31bc..2f852a0 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -25,6 +25,7 @@ export default defineConfig({ 'nationGeneralSecret.spec.ts', 'npcPolicy.spec.ts', 'auction.spec.ts', + 'tournamentBracket.spec.ts', 'battleSimulator.spec.ts', 'battleSimulatorRef.spec.ts', 'commandArguments.spec.ts', diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts new file mode 100644 index 0000000..920ffa5 --- /dev/null +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -0,0 +1,201 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { 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 imageRoots = [ + ...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []), + resolve(repositoryRoot, '../image/game'), + resolve(repositoryRoot, '../../image/game'), +]; +const names = [ + '관우', + '장료', + '조운', + '하후돈', + '손책', + '태사자', + '마초', + '황충', + '여포', + '전위', + '감녕', + '문추', + '안량', + '허저', + '주태', + '방덕', +]; +const participants = names.map((name, index) => ({ + id: index + 1, + name, + leadership: 80, + strength: 80, + intel: 80, + level: 10, + groupId: 10 + (index % 8), + groupNo: Math.floor(index / 8), + win: 3 - (index % 2), + draw: index % 2, + lose: 0, + gl: 12 - index, + finalRank: Math.floor(index / 8) + 1, +})); +const matches = [ + ...Array.from({ length: 8 }, (_, index) => ({ + id: index + 1, + stage: 7, + roundIndex: index, + attackerId: index * 2 + 1, + defenderId: index * 2 + 2, + winnerId: index * 2 + 1, + })), + ...Array.from({ length: 4 }, (_, index) => ({ + id: index + 9, + stage: 8, + roundIndex: index, + attackerId: index * 4 + 1, + defenderId: index * 4 + 3, + winnerId: index * 4 + 1, + })), + ...Array.from({ length: 2 }, (_, index) => ({ + id: index + 13, + stage: 9, + roundIndex: index, + attackerId: index * 8 + 1, + defenderId: index * 8 + 5, + winnerId: index * 8 + 1, + })), + { id: 15, stage: 10, roundIndex: 0, attackerId: 1, defenderId: 9, winnerId: 1 }, +]; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const readReferenceImage = async (filename: string): Promise => { + for (const imageRoot of imageRoots) { + try { + return await readFile(resolve(imageRoot, filename)); + } catch { + // Worktrees can be nested at different depths. + } + } + throw new Error(`Reference image not found: ${filename}`); +}; + +const installFixture = async (page: Page) => { + await page.addInitScript((profile) => { + window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright'); + window.localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) }); + }); + } + await page.route(gameTrpcRoute, async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'auth.status') return response({ ok: true }); + if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: names[0] } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } }); + if (operation === 'tournament.getAdminStatus') return response({ ok: false }); + if (operation === 'tournament.getSnapshot') { + return response({ + state: { + stage: 0, + phase: 0, + type: 0, + auto: false, + openYear: 184, + openMonth: 1, + termSeconds: 60, + nextAt: '2026-08-02T00:00:00.000Z', + winnerId: 1, + }, + participants, + matches, + betCount: 16, + }); + } + if (operation === 'tournament.getBettingSummary') { + return response({ + totals: Object.fromEntries(participants.map((participant, index) => [participant.id, 100 + index * 10])), + myTotals: {}, + totalAmount: 2800, + myAmount: 0, + }); + } + return response(null); + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); +}; + +const openTournament = async (page: Page) => { + await installFixture(page); + await page.goto('tournament'); + await expect(page.getByLabel('토너먼트 대진표')).toBeVisible(); +}; + +test('desktop bracket connects every real general slot to the next round', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1365, height: 900 }); + await openTournament(page); + + await expect(page.locator('.bracket-canvas .bracket-name[data-general-id]')).toHaveCount(31); + await expect(page.locator('.bracket-canvas .connector-segment')).toHaveCount(15); + await expect(page.locator('.bracket-canvas .bracket-name.advanced', { hasText: '관우' })).toHaveCount(5); + + const geometry = await page.locator('.bracket-canvas').evaluate((canvas) => { + const firstConnector = canvas.querySelector('.connector-segment')!.getBoundingClientRect(); + const champion = canvas.querySelector('.bracket-champion .bracket-name')!.getBoundingClientRect(); + const finalists = [...canvas.querySelectorAll('.bracket-round:nth-of-type(3) .bracket-name')].map( + (element) => element.getBoundingClientRect() + ); + return { + canvasWidth: canvas.getBoundingClientRect().width, + 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], + }; + }); + expect(geometry.canvasWidth).toBe(2000); + 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 }); +}); + +test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openTournament(page); + + const bracket = page.locator('.mobile-bracket'); + await expect(bracket).toBeVisible(); + await expect(bracket.locator('.mobile-bracket-name')).toHaveCount(31); + await expect(bracket.locator('.mobile-bracket-name', { hasText: '방덕' })).toBeVisible(); + await expect(bracket.locator('.mobile-bracket-name', { hasText: '관우' })).toHaveCount(5); + const bounds = await bracket.evaluate((element) => { + const names = [...element.querySelectorAll('.mobile-bracket-name')].map((name) => + name.getBoundingClientRect() + ); + const own = element.getBoundingClientRect(); + return { + width: own.width, + minX: Math.min(...names.map((rect) => rect.left - own.left)), + maxX: Math.max(...names.map((rect) => rect.right - own.left)), + }; + }); + 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 }); +}); diff --git a/app/game-frontend/e2e/troop.spec.ts b/app/game-frontend/e2e/troop.spec.ts index f043c13..dfbc480 100644 --- a/app/game-frontend/e2e/troop.spec.ts +++ b/app/game-frontend/e2e/troop.spec.ts @@ -259,13 +259,14 @@ test('renders the legacy desktop grid with matching computed geometry and states }); const kickButton = page.getByRole('button', { name: '부대원 추방...' }).first(); + expect(await kickButton.evaluate((button) => getComputedStyle(button).backgroundColor)).toBe('rgb(68, 68, 68)'); await kickButton.hover(); const hoverStyle = await kickButton.evaluate((button) => ({ cursor: getComputedStyle(button).cursor, - filter: getComputedStyle(button).filter, + borderBottomWidth: getComputedStyle(button).borderBottomWidth, })); expect(hoverStyle.cursor).toBe('pointer'); - expect(hoverStyle.filter).not.toBe('none'); + expect(hoverStyle.borderBottomWidth).toBe('3px'); await page.locator('.troopMember').nth(1).hover(); await expect(page.getByRole('tooltip')).toContainText('조운'); diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index c36d02f..6ed76c5 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -15,6 +15,7 @@ "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", "test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs", "test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:tournament-bracket": "playwright test tournamentBracket.spec.ts --config e2e/playwright.config.mjs", "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", "test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:npc-possession": "playwright test npcPossession.spec.ts --config e2e/playwright.config.mjs", diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 071d3f6..481bac0 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -1,5 +1,6 @@ @import 'tailwindcss'; @import './styles/tokens.css'; +@import './styles/legacy-controls.css'; @import './styles/game-shell.css'; @import './styles/ref-shell.css'; @@ -44,33 +45,3 @@ textarea { background-color: #172a52; background-image: var(--sammo-texture-blue); } - -.legacy-button { - display: inline-block; - border: 1px solid #12195b; - border-radius: 3px; - background: #141c65; - color: #fff; - padding: 5px 10px; - font-weight: 700; - line-height: 1.5; - cursor: pointer; -} - -.legacy-button:hover, -.legacy-button:focus, -.legacy-button:active { - border-color: #0f154c; - background: #101651; - color: #fff; -} - -.legacy-button:focus-visible { - outline: 2px solid #f39c12; - outline-offset: 1px; -} - -.legacy-button:disabled { - cursor: default; - opacity: 0.65; -} diff --git a/app/game-frontend/src/assets/styles/legacy-controls.css b/app/game-frontend/src/assets/styles/legacy-controls.css new file mode 100644 index 0000000..00b8289 --- /dev/null +++ b/app/game-frontend/src/assets/styles/legacy-controls.css @@ -0,0 +1,117 @@ +.legacy-button { + display: inline-block; + box-sizing: border-box; + border: 1px solid var(--sammo-button-base1-border); + border-radius: 3px; + padding: 5px 10px; + background: var(--sammo-button-base1-bg); + color: #fff; + font: inherit; + font-weight: 700; + line-height: 1.5; + text-align: center; + text-decoration: none; + cursor: pointer; +} + +.legacy-button:hover, +.legacy-button:focus, +.legacy-button:active { + border-color: var(--sammo-button-base1-hover-border); + background: var(--sammo-button-base1-hover-bg); + color: #fff; +} + +.legacy-button:focus-visible { + outline: 2px solid var(--sammo-color-accent); + outline-offset: 1px; +} + +.legacy-button:disabled, +.legacy-button[aria-disabled='true'] { + cursor: default; + opacity: 0.65; +} + +/* + * Ref Bootstrap 5.2 + Lumen button family. The modifier describes the legacy + * semantic role; width and placement remain in the owning scoped component. + */ +.legacy-button:is( + .legacy-button--primary, + .legacy-button--secondary, + .legacy-button--danger, + .legacy-button--info, + .legacy-button--navigation +) { + --legacy-button-bg: var(--sammo-button-primary-bg); + --legacy-button-border: var(--sammo-button-primary-border); + min-height: 35.5px; + margin-top: 0; + border-color: var(--legacy-button-border); + border-style: solid; + border-width: 0 1px 4px; + border-radius: 5.25px; + padding: 5.25px 10.5px; + background: var(--legacy-button-bg); + color: #fff; + line-height: 21px; +} + +.legacy-button.legacy-button--secondary { + --legacy-button-bg: var(--sammo-button-secondary-bg); + --legacy-button-border: var(--sammo-button-secondary-border); +} + +.legacy-button.legacy-button--danger { + --legacy-button-bg: var(--sammo-button-danger-bg); + --legacy-button-border: var(--sammo-button-danger-border); +} + +.legacy-button.legacy-button--info { + --legacy-button-bg: var(--sammo-button-info-bg); + --legacy-button-border: var(--sammo-button-info-border); +} + +.legacy-button.legacy-button--navigation { + --legacy-button-bg: var(--sammo-button-navigation-bg); + --legacy-button-border: var(--sammo-button-navigation-border); +} + +.legacy-button:is( + .legacy-button--primary, + .legacy-button--secondary, + .legacy-button--danger, + .legacy-button--info, + .legacy-button--navigation + ):not(:disabled, [aria-disabled='true']):hover { + margin-top: 1px; + border-color: var(--legacy-button-border); + border-bottom-width: 3px; + background: var(--legacy-button-bg); +} + +.legacy-button:is( + .legacy-button--primary, + .legacy-button--secondary, + .legacy-button--danger, + .legacy-button--info, + .legacy-button--navigation + ):not(:disabled, [aria-disabled='true']):active { + margin-top: 2px; + border-color: var(--legacy-button-border); + border-bottom-width: 2px; + background: var(--legacy-button-bg); + box-shadow: none; +} + +.legacy-button:is( + .legacy-button--primary, + .legacy-button--secondary, + .legacy-button--danger, + .legacy-button--info, + .legacy-button--navigation + ):focus { + border-color: var(--legacy-button-border); + background: var(--legacy-button-bg); +} diff --git a/app/game-frontend/src/assets/styles/tokens.css b/app/game-frontend/src/assets/styles/tokens.css index d99f49b..f740a9a 100644 --- a/app/game-frontend/src/assets/styles/tokens.css +++ b/app/game-frontend/src/assets/styles/tokens.css @@ -6,6 +6,20 @@ --sammo-color-border: rgba(201, 164, 90, 0.4); --sammo-color-action-bg: rgba(16, 16, 16, 0.6); --sammo-color-error: #f5b7b1; + --sammo-button-base1-bg: #141c65; + --sammo-button-base1-border: #12195b; + --sammo-button-base1-hover-bg: #101651; + --sammo-button-base1-hover-border: #0f154c; + --sammo-button-primary-bg: #375a7f; + --sammo-button-primary-border: #325172; + --sammo-button-secondary-bg: #444; + --sammo-button-secondary-border: #3d3d3d; + --sammo-button-danger-bg: #e74c3c; + --sammo-button-danger-border: #d04436; + --sammo-button-info-bg: #3498db; + --sammo-button-info-border: #2f89c5; + --sammo-button-navigation-bg: #00582c; + --sammo-button-navigation-border: #004f28; --sammo-texture-walnut: url('/image/game/back_walnut.jpg'); --sammo-texture-green: url('/image/game/back_green.jpg'); --sammo-texture-blue: url('/image/game/back_blue.jpg'); diff --git a/app/game-frontend/src/components/tournament/TournamentBracket.vue b/app/game-frontend/src/components/tournament/TournamentBracket.vue new file mode 100644 index 0000000..f1ff856 --- /dev/null +++ b/app/game-frontend/src/components/tournament/TournamentBracket.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/app/game-frontend/src/utils/tournamentBracket.ts b/app/game-frontend/src/utils/tournamentBracket.ts new file mode 100644 index 0000000..2b29e23 --- /dev/null +++ b/app/game-frontend/src/utils/tournamentBracket.ts @@ -0,0 +1,76 @@ +export interface TournamentBracketParticipant { + id: number; + name: string; +} + +export interface TournamentBracketMatch { + id: number; + stage: number; + roundIndex: number; + attackerId: number; + defenderId: number; + winnerId?: number; +} + +export interface TournamentBracketSlot { + id: number | null; + name: string; + advanced: boolean; +} + +export interface TournamentBracketRound { + stage: number; + slots: TournamentBracketSlot[]; +} + +export interface TournamentBracketModel { + champion: TournamentBracketSlot; + final: TournamentBracketRound; + semi: TournamentBracketRound; + quarter: TournamentBracketRound; + top16: TournamentBracketRound; +} + +const emptySlot = (): TournamentBracketSlot => ({ id: null, name: '-', advanced: false }); + +export const buildTournamentBracket = ( + participants: TournamentBracketParticipant[], + matches: TournamentBracketMatch[], + 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 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, + })) + ); + while (slots.length < slotCount) { + slots.push(emptySlot()); + } + return { stage, slots: slots.slice(0, slotCount) }; + }; + + const final = buildRound(10, 2); + const resolvedWinnerId = winnerId ?? matches.find((match) => match.stage === 10)?.winnerId ?? null; + + return { + champion: { + id: resolvedWinnerId, + name: nameOf(resolvedWinnerId), + advanced: resolvedWinnerId !== null, + }, + final, + semi: buildRound(9, 4), + quarter: buildRound(8, 8), + top16: buildRound(7, 16), + }; +}; diff --git a/app/game-frontend/src/views/GlobalInfoView.vue b/app/game-frontend/src/views/GlobalInfoView.vue index 6abd86c..582230f 100644 --- a/app/game-frontend/src/views/GlobalInfoView.vue +++ b/app/game-frontend/src/views/GlobalInfoView.vue @@ -11,6 +11,18 @@ const error = ref(''); const state = (value: number) => ({ 0: '★', 1: '▲', 2: '', 7: '@' })[value] ?? 'ㆍ'; const stateClass = (value: number) => `state-${value}`; const nationMap = computed(() => new Map(data.value?.nations.map((nation) => [nation.id, nation]) ?? [])); +const isBrightColor = (color: string): boolean => { + const normalized = color.trim().replace(/^#/u, ''); + if (!/^[0-9a-f]{6}$/iu.test(normalized)) return false; + const red = Number.parseInt(normalized.slice(0, 2), 16); + const green = Number.parseInt(normalized.slice(2, 4), 16); + const blue = Number.parseInt(normalized.slice(4, 6), 16); + return red * 0.299 + green * 0.587 + blue * 0.114 > 170; +}; +const nationNameStyle = (color: string) => ({ + backgroundColor: color, + color: isBrightColor(color) ? '#000' : '#fff', +}); onMounted(async () => { try { [data.value, layout.value] = await Promise.all([ @@ -96,10 +108,29 @@ onMounted(async () => {
-
- 【{{ nation.name }}】 {{ nation.power.toLocaleString() - }}
{{ nation.cities.join(', ') }} -
+ + + + + + + + + + + + + + + + + +
국명국력장수속령
{{ nation.name }}{{ nation.power.toLocaleString() }}{{ nation.generalCount.toLocaleString() }} + {{ nation.cities.length.toLocaleString() }} +
@@ -218,9 +249,38 @@ onMounted(async () => { display: grid; grid-template-columns: 700px 300px; } -.nation-list > div { - padding: 6px; - border-bottom: 1px solid #666; +.simple-nation-list { + width: 100%; + border-collapse: collapse; +} +.simple-nation-list thead { + background-color: #ccc; + color: #000; + text-align: center; +} +.simple-nation-list th { + border: 0; + border-left: 1px solid gray; + padding: 2px 6px; + font-weight: 700; +} +.simple-nation-list td { + border: 0; + border-left: 1px solid gray; + padding: 1px 6px; + text-align: right; +} +.simple-nation-list td:first-child { + text-align: left; +} +.nation-name-column { + width: 44%; +} +.nation-power-column { + width: 23%; +} +.nation-count-column { + width: 15%; } .footer { margin-top: 20px; diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue index 2a0b924..1f7ba26 100644 --- a/app/game-frontend/src/views/InheritView.vue +++ b/app/game-frontend/src/views/InheritView.vue @@ -427,9 +427,16 @@ onMounted(() => { @@ -438,54 +451,19 @@ onMounted(() => { margin: 0; } -.btn { - min-height: 35.5px; - padding: 5.25px 10.5px; - border: 1px solid transparent; - border-radius: 5.25px; - color: #fff; - font: inherit; - cursor: pointer; -} - -.btn:hover { - filter: brightness(1.15); -} - -.btn:focus-visible { - outline: 2px solid #8ab4f8; - outline-offset: -2px; -} - -.btn:active { - transform: translateY(1px); -} - -.btn:disabled { - opacity: 0.65; - cursor: default; -} - -.btn-sammo-base2 { +.back_btn, +.reload_btn { height: 32px; min-height: 32px; margin-right: 2px; - border-color: #004f28; - background: #00582c; font-weight: 600; - text-align: center; - text-decoration: none; } -.back_bar .btn-sammo-base2 { +.back_bar .back_btn, +.back_bar .reload_btn { width: 88px; } -.btn-primary { - border-color: #0d6efd; - background: #0d6efd; -} - #vote-title { font-size: 1.8em; line-height: 1.5; diff --git a/app/game-frontend/src/views/TournamentView.vue b/app/game-frontend/src/views/TournamentView.vue index f255404..6b40b64 100644 --- a/app/game-frontend/src/views/TournamentView.vue +++ b/app/game-frontend/src/views/TournamentView.vue @@ -1,5 +1,6 @@