diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts index 84f4f50..18a688b 100644 --- a/app/game-api/test/rankingRouter.test.ts +++ b/app/game-api/test/rankingRouter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { RANK_DATA_TYPES } from '@sammo-ts/common'; import type { RedisConnector } from '@sammo-ts/infra'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; @@ -32,7 +33,24 @@ const auth: GameSessionTokenPayload = { sanctions: {}, }; -const generalRows = [ +interface RankingGeneralRow { + id: number; + name: string; + nationId: number; + userId: string | null; + npcState: number; + picture: string | null; + imageServer: number; + meta: Record; + experience: number; + dedication: number; + horseCode: string; + weaponCode: string; + bookCode: string; + itemCode: string; +} + +const generalRows: RankingGeneralRow[] = [ { id: 1, name: '유비', @@ -81,13 +99,16 @@ const generalRows = [ bookCode: 'None', itemCode: 'None', }, -] as const; +]; const buildContext = (options?: { authenticated?: boolean; isUnited?: boolean; includeOwnerDisplayName?: boolean; + generals?: RankingGeneralRow[]; + rankRows?: Array<{ generalId: number; type: string; value: number }>; }): GameApiContext => { + const selectedGeneralRows = options?.generals ?? generalRows; const db = { worldState: { findFirst: async () => ({ @@ -112,21 +133,22 @@ const buildContext = (options?: { }, general: { findMany: async (args: { where: { npcState: { lt?: number; gte?: number } } }) => - generalRows.filter((general) => + selectedGeneralRows.filter((general) => args.where.npcState.gte !== undefined ? general.npcState >= args.where.npcState.gte : general.npcState < (args.where.npcState.lt ?? Number.POSITIVE_INFINITY) ), }, rankData: { - findMany: async () => [ - { generalId: 1, type: 'firenum', value: 10 }, - { generalId: 2, type: 'firenum', value: 20 }, - { generalId: 3, type: 'firenum', value: 30 }, - { generalId: 1, type: 'dex1', value: 999 }, - { generalId: 2, type: 'dex1', value: 999 }, - { generalId: 3, type: 'dex1', value: 999 }, - ], + findMany: async () => + options?.rankRows ?? [ + { generalId: 1, type: 'firenum', value: 10 }, + { generalId: 2, type: 'firenum', value: 20 }, + { generalId: 3, type: 'firenum', value: 30 }, + { generalId: 1, type: 'dex1', value: 999 }, + { generalId: 2, type: 'dex1', value: 999 }, + { generalId: 3, type: 'dex1', value: 999 }, + ], }, auction: { findMany: async () => [{ targetCode: 'che_명마_15_적토마' }], @@ -237,6 +259,58 @@ describe('ranking.getBestGeneral', () => { }); }); + it('returns positions one through ten for every populated ranking section', async () => { + const generals = Array.from({ length: 12 }, (_, index) => { + const id = index + 1; + const value = id * 1_000; + return { + id, + name: `상위${id}`, + nationId: 1, + userId: `top-${id}`, + npcState: 0, + picture: null, + imageServer: 0, + meta: { dex1: value, dex2: value, dex3: value, dex4: value, dex5: value }, + experience: value, + dedication: value, + horseCode: 'None', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + }; + }); + const rankRows = generals.flatMap((general) => + RANK_DATA_TYPES.filter( + (type) => type !== 'experience' && type !== 'dedication' && !type.startsWith('dex') + ).map((type) => ({ + generalId: general.id, + type, + value: + type === 'warnum' || type === 'deathcrew' || type === 'deathcrew_person' + ? 1_000 + : type === 'ttd' || type === 'ttl' || type === 'tld' || type === 'tll' || + type === 'tsd' || type === 'tsl' || type === 'tid' || type === 'til' || + type === 'betgold' + ? 1_000 + : general.id * 1_000, + })) + ); + const result = await appRouter + .createCaller(buildContext({ isUnited: true, generals, rankRows })) + .ranking.getBestGeneral({ view: 'user' }); + + expect(result.sections).toHaveLength(26); + for (const section of result.sections) { + expect(section.entries, section.title).toHaveLength(10); + expect(new Set(section.entries.map((entry) => entry.id)).size, section.title).toBe(10); + expect(section.entries.every((entry) => entry.value > 0), section.title).toBe(true); + } + expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries.map((entry) => entry.id)).toEqual([ + 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, + ]); + }); + it('matches PHP number_format rounding and the legacy fixed color table', () => { expect(formatLegacyRankingNumber(1.005, 2)).toBe('1.01'); expect(formatLegacyRankingNumber(12345.6, 2)).toBe('12,345.60'); diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 4c89803..420cfa7 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -176,13 +176,14 @@ const runTournamentToCompletion = async (options: { store: TournamentStore; prisma: ReturnType; baseSeed: string; + daemonTransport?: TurnDaemonTransport; }): Promise => { let state = await options.store.getState(); if (!state) { throw new Error('토너먼트 상태가 없습니다.'); } - const daemonTransport = createNoopDaemonTransport(); + const daemonTransport = options.daemonTransport ?? createNoopDaemonTransport(); for (let i = 0; i < 2000; i += 1) { if (state.stage === 0) { @@ -198,7 +199,7 @@ const runTournamentToCompletion = async (options: { continue; } if (state.stage >= 7 && state.stage <= 10) { - state = await applyBattle(options.store, state, options.baseSeed); + state = await applyBattle(options.store, state, options.baseSeed, daemonTransport); continue; } break; @@ -270,6 +271,61 @@ describe('tournament worker (in-memory)', () => { }); }); + it('runs all four tournament types and emits enough rank and NPC-betting commands for a top ten', async () => { + for (const type of [ + TournamentType.TOTAL, + TournamentType.LEADERSHIP, + TournamentType.STRENGTH, + TournamentType.INTEL, + ]) { + const redis = new MemoryRedis(); + const store = new TournamentStore(redis, buildTournamentKeys(`ranking-audit-${type}`)); + const participants = createParticipants(16, 16, 32); + await store.setParticipants(participants); + await store.setState(createTournamentState({ stage: 1, type })); + const npcBetting = participants.slice(0, 12).map((entry) => ({ + ...entry, + meta: {}, + npcState: 2, + gold: 10_000, + })); + const commands: TurnDaemonCommand[] = []; + const transport: TurnDaemonTransport = { + sendCommand: async (command) => { + commands.push(command); + return 'ok'; + }, + requestCommand: async () => null, + requestStatus: async () => null, + }; + + await runTournamentToCompletion({ + store, + prisma: createPrismaMock({ baseSeed: `ranking-audit-${type}`, npcBetting, currentYear: 10 }), + baseSeed: `ranking-audit-${type}`, + daemonTransport: transport, + }); + + const matchCommands = commands.filter((command) => command.type === 'tournamentMatchResult'); + const rankedGeneralIds = new Set( + matchCommands.flatMap((command) => + command.type === 'tournamentMatchResult' ? [command.attackerId, command.defenderId] : [] + ) + ); + expect(matchCommands.length).toBeGreaterThan(50); + expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10); + expect(commands).toContainEqual( + expect.objectContaining({ + type: 'adjustGeneralMeta', + reason: 'tournamentNpcBet', + adjustments: expect.arrayContaining([ + expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }), + ]), + }) + ); + } + }); + it('우승 결과에 따라 베팅 정산 명령이 생성된다', async () => { const redis = new MemoryRedis(); const store = new TournamentStore(redis, buildTournamentKeys('test-bet')); diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 05a7d5a..df370a5 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -248,7 +248,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/bestGeneralRankPersistence.integration.test.ts b/app/game-engine/test/bestGeneralRankPersistence.integration.test.ts new file mode 100644 index 0000000..f60b6e0 --- /dev/null +++ b/app/game-engine/test/bestGeneralRankPersistence.integration.test.ts @@ -0,0 +1,209 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { RANK_DATA_TYPES, rankDataMetaKey } from '@sammo-ts/common'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import type { GeneralMeta } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const worldId = 992_400; +const nationId = 992_400; +const cityId = 992_400; +const generalIds = Array.from({ length: 12 }, (_, index) => 992_401 + index); + +const makeGeneral = (id: number): TurnGeneral => ({ + id, + name: `랭킹감사${id}`, + nationId, + cityId, + troopId: 0, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + turnTime: new Date('0190-01-01T00:10:00.000Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + penalty: {}, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 1, + train: 100, + atmos: 100, + age: 30, + npcState: 2, +}); + +integration('best-general rank persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const cleanup = async () => { + await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.general.deleteMany({ where: { id: { in: generalIds } } }); + await db.city.deleteMany({ where: { id: cityId } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await db.worldState.deleteMany({ where: { id: worldId } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await cleanup(); + }); + + afterAll(async () => { + await cleanup(); + await closeDb?.(); + }); + + it('flushes every ranking field for twelve active NPCs and keeps the ordered top ten', async () => { + await db.worldState.create({ + data: { + id: worldId, + scenarioCode: 'best-general-rank-persistence', + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + }, + }); + await db.nation.create({ + data: { + id: nationId, + name: '랭킹감사국', + color: '#330000', + level: 1, + }, + }); + await db.city.create({ + data: { + id: cityId, + name: '랭킹감사성', + level: 5, + nationId, + population: 10_000, + populationMax: 20_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + region: 1, + }, + }); + const initialGenerals = generalIds.map(makeGeneral); + await db.general.createMany({ + data: initialGenerals.map((general) => ({ + id: general.id, + name: general.name, + nationId, + cityId, + npcState: general.npcState, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + turnTime: general.turnTime, + meta: general.meta, + })), + }); + + const state: TurnWorldState = { + id: worldId, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0190-01-01T00:00:00.000Z'), + meta: {}, + }; + const snapshot: TurnWorldSnapshot = { + generals: initialGenerals, + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'test', + name: '랭킹 감사 지도', + cities: [], + }, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'test' }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + for (const [index, generalId] of generalIds.entries()) { + const value = index + 1; + const meta: GeneralMeta = { killturn: 24 }; + for (const type of RANK_DATA_TYPES) { + if (type !== 'experience' && type !== 'dedication') { + meta[rankDataMetaKey(type)] = value; + } + } + world.updateGeneral(generalId, { + experience: value, + dedication: value, + meta, + }); + } + + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: generalIds.length, + processedTurns: generalIds.length, + durationMs: 0, + partial: false, + }); + } finally { + await dbHooks.close(); + } + + const rows = await db.rankData.findMany({ + where: { generalId: { in: generalIds } }, + orderBy: [{ type: 'asc' }, { value: 'desc' }, { generalId: 'asc' }], + }); + expect(rows).toHaveLength(generalIds.length * RANK_DATA_TYPES.length); + for (const type of RANK_DATA_TYPES) { + const topTen = rows.filter((row) => row.type === type).slice(0, 10); + expect(topTen.map((row) => row.value)).toEqual([12, 11, 10, 9, 8, 7, 6, 5, 4, 3]); + expect(topTen.map((row) => row.generalId)).toEqual(generalIds.slice(2).reverse()); + } + + const persistedGenerals = await db.general.findMany({ + where: { id: { in: generalIds } }, + orderBy: { experience: 'desc' }, + select: { id: true, experience: true, dedication: true, meta: true }, + }); + expect(persistedGenerals.slice(0, 10).map((general) => general.id)).toEqual(generalIds.slice(2).reverse()); + }); +}); diff --git a/app/game-engine/test/npcNationUprisingUnification.test.ts b/app/game-engine/test/npcNationUprisingUnification.test.ts index a074456..a443fe4 100644 --- a/app/game-engine/test/npcNationUprisingUnification.test.ts +++ b/app/game-engine/test/npcNationUprisingUnification.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { performance } from 'node:perf_hooks'; +import { RANK_DATA_TYPES, rankDataMetaKey } from '@sammo-ts/common'; import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js'; @@ -153,6 +154,7 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => { describe('NPC 건국/통일 장기 시뮬레이션', () => { it('건국, 선포, 출병, 점령과 장기 국가 감소가 안정적으로 진행되어야 한다', async () => { const memoryProfileEnabled = process.env.NPC_UNIFICATION_MEMORY_PROFILE === '1'; + const rankingAuditEnabled = process.env.NPC_RANKING_AUDIT === '1'; const profileStartedAtMs = performance.now(); const cities = buildLargeTestCities().map(maxCityStats); for (const city of cities) { @@ -206,7 +208,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { }; const generals: TurnGeneral[] = []; - for (let i = 0; i < 300; i += 1) { + const initialGeneralCount = rankingAuditEnabled ? 150 : 300; + for (let i = 0; i < initialGeneralCount; i += 1) { const cityId = cities[i % cities.length]!.id; const stats = i % 2 === 0 @@ -580,6 +583,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { } if ( + (rankingAuditEnabled && sortieCount >= 50) || world.getState().currentYear > 260 || (world.getState().currentYear === 260 && world.getState().currentMonth >= 1) ) { @@ -595,11 +599,85 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { expect(meta.isUnited).toBe(2); expect(hasUnificationLog).toBe(true); } else { - expect(prevNationCount).toBeLessThan(foundedNationCount); + if (!rankingAuditEnabled) { + expect(prevNationCount).toBeLessThan(foundedNationCount); + } expect(meta.isUnited ?? 0).toBe(0); } expect(sortieCount).toBeGreaterThan(0); + if (rankingAuditEnabled) { + const rankingAudit = RANK_DATA_TYPES.map((type) => { + const entries = world + .listGenerals() + .map((general) => { + const rawValue = + type === 'experience' + ? general.experience + : type === 'dedication' + ? general.dedication + : general.meta[rankDataMetaKey(type)]; + const value = typeof rawValue === 'number' && Number.isFinite(rawValue) ? rawValue : 0; + return { generalId: general.id, name: general.name, value }; + }) + .filter((entry) => entry.value > 0) + .sort((lhs, rhs) => rhs.value - lhs.value || lhs.generalId - rhs.generalId) + .slice(0, 10); + return { type, entries }; + }); + const byType = new Map(rankingAudit.map((entry) => [entry.type, entry.entries])); + expect(byType.get('firenum')).toHaveLength(0); + for (const type of [ + 'experience', + 'dedication', + 'warnum', + 'killnum', + 'deathnum', + 'killcrew', + 'deathcrew', + 'killcrew_person', + 'deathcrew_person', + 'dex1', + 'dex2', + 'dex3', + 'dex4', + 'dex5', + ] as const) { + expect(byType.get(type), type).toHaveLength(10); + } + const reportPath = resolve( + process.env.NPC_RANKING_AUDIT_REPORT_PATH ?? 'test-results/npc-ranking-audit.json' + ); + mkdirSync(dirname(reportPath), { recursive: true }); + writeFileSync( + reportPath, + `${JSON.stringify( + { + year: world.getState().currentYear, + month: world.getState().currentMonth, + initialGeneralCount, + finalGeneralCount: world.listGenerals().length, + declarationCount, + sortieCount, + rankingAudit, + }, + null, + 2 + )}\n`, + 'utf8' + ); + console.log( + `[NPC_RANKING_AUDIT]${JSON.stringify({ + reportPath, + year: world.getState().currentYear, + month: world.getState().currentMonth, + declarationCount, + sortieCount, + topTenTypes: Array.from(byType.values()).filter((entries) => entries.length === 10).length, + })}` + ); + } + if (memoryProfiler) { expect(typeof globalThis.gc).toBe('function'); expect(unifiedAt).not.toBeNull(); 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-engine/test/tournamentCommands.test.ts b/app/game-engine/test/tournamentCommands.test.ts index 6e1db68..2369c36 100644 --- a/app/game-engine/test/tournamentCommands.test.ts +++ b/app/game-engine/test/tournamentCommands.test.ts @@ -5,6 +5,7 @@ import type { TurnSchedule } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; +import { buildPersistedRankRows } from '../src/turn/rankData.js'; const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; @@ -116,6 +117,46 @@ describe('tournament world commands', () => { expect(world.getGeneralById(1)?.meta).not.toHaveProperty('rank_betwin'); }); + it('records all four tournament types and NPC betting for at least ten generals', async () => { + const generals = Array.from({ length: 12 }, (_, index) => buildGeneral(index + 1)); + const world = buildWorld(generals); + const handler = createTurnDaemonCommandHandler({ world }); + + for (const tournamentType of [0, 1, 2, 3] as const) { + for (const general of generals) { + const result = await handler.handle({ + type: 'tournamentMatchResult', + tournamentType, + attackerId: general.id, + defenderId: (general.id % generals.length) + 1, + result: 'attacker', + }); + expect(result).toMatchObject({ ok: true }); + } + } + await handler.handle({ + type: 'adjustGeneralMeta', + reason: 'tournamentNpcBet', + adjustments: generals.map((general) => ({ + generalId: general.id, + metaDelta: { betgold: 1_000 }, + })), + }); + await handler.handle({ + type: 'tournamentBettingPayout', + bettingId: 1, + payouts: generals.map((general) => ({ generalId: general.id, amount: 2_000 })), + }); + + for (const type of ['ttw', 'tlw', 'tsw', 'tiw', 'betgold', 'betwin', 'betwingold'] as const) { + const positiveRows = world + .listGenerals() + .flatMap(buildPersistedRankRows) + .filter((row) => row.type === type && row.value > 0); + expect(positiveRows, type).toHaveLength(12); + } + }); + it('enforces a command-specific minimum remaining gold atomically', async () => { const world = buildWorld([buildGeneral(1)]); const handler = createTurnDaemonCommandHandler({ world }); diff --git a/app/game-frontend/e2e/chiefCenter.live.playwright.config.mjs b/app/game-frontend/e2e/chiefCenter.live.playwright.config.mjs new file mode 100644 index 0000000..da9bc88 --- /dev/null +++ b/app/game-frontend/e2e/chiefCenter.live.playwright.config.mjs @@ -0,0 +1,22 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const frontendUrl = process.env.CHIEF_CENTER_LIVE_FRONTEND_URL ?? 'http://127.0.0.1:15160/hwe/'; + +export default defineConfig({ + testDir: '.', + testMatch: ['chiefCenterLive.spec.ts'], + fullyParallel: false, + workers: 1, + timeout: 90_000, + expect: { timeout: 15_000 }, + reporter: [['list']], + outputDir: resolve(repositoryRoot, 'test-results/chief-center-live'), + use: { + baseURL: frontendUrl, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, +}); diff --git a/app/game-frontend/e2e/chiefCenterLive.spec.ts b/app/game-frontend/e2e/chiefCenterLive.spec.ts new file mode 100644 index 0000000..4756114 --- /dev/null +++ b/app/game-frontend/e2e/chiefCenterLive.spec.ts @@ -0,0 +1,205 @@ +import { randomUUID } from 'node:crypto'; + +import { expect, test, type Browser, type Page } from '@playwright/test'; +import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js'; +import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js'; + +const databaseUrl = process.env.CHIEF_CENTER_LIVE_DATABASE_URL; +const gameTokenSecret = process.env.CHIEF_CENTER_LIVE_GAME_SECRET; +const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:1010'; +const hasLiveFixture = Boolean(databaseUrl && gameTokenSecret); +const gameSchema = profile.split(':', 1)[0] ?? ''; + +const resolveGameDatabaseUrl = (): string => { + const parsed = new URL(databaseUrl!); + const sourceSchema = parsed.searchParams.get('schema'); + if (!gameSchema || (sourceSchema !== 'public' && sourceSchema !== gameSchema)) { + throw new Error(`Refusing unexpected chief-center schema: ${sourceSchema ?? '(missing)'}`); + } + parsed.searchParams.set('schema', gameSchema); + return parsed.toString(); +}; + +const installSession = async (page: Page, userId: string, displayName: string): Promise => { + const now = new Date(); + const token = encryptGameSessionToken( + { + version: 1, + profile, + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 3_600_000).toISOString(), + sessionId: `chief-center-live-${randomUUID()}`, + user: { + id: userId, + username: userId, + displayName, + roles: ['user'], + canUseGeneralPicture: false, + }, + sanctions: {}, + identity: { + kakaoVerified: true, + canCreateGeneral: true, + requiresKakaoVerification: false, + graceEndsAt: null, + }, + }, + gameTokenSecret! + ); + await page.addInitScript( + ({ gameToken, gameProfile }) => { + localStorage.setItem('sammo-game-token', gameToken); + localStorage.setItem('sammo-game-profile', gameProfile); + }, + { gameToken: token, gameProfile: profile } + ); +}; + +const newPage = async (browser: Browser, userId: string, displayName: string): Promise => { + const context = await browser.newContext({ + viewport: { width: 1365, height: 900 }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + colorScheme: 'dark', + }); + const page = await context.newPage(); + await installSession(page, userId, displayName); + return page; +}; + +test('persists one chief command and exposes it to a normal nation user and another chief', async ({ + browser, +}, testInfo) => { + test.skip(!hasLiveFixture, 'isolated chief-center PostgreSQL and token secret are required'); + test.setTimeout(90_000); + + const connector = createGamePostgresConnector({ url: resolveGameDatabaseUrl() }); + await connector.connect(); + const db = connector.prisma; + const editor = await db.general.findFirstOrThrow({ where: { name: 'GUI비교관리자' } }); + const candidates = await db.general.findMany({ + where: { nationId: editor.nationId, userId: null, id: { not: editor.id } }, + orderBy: { id: 'asc' }, + take: 2, + }); + if (candidates.length !== 2) throw new Error('Two isolated visibility candidates are required.'); + const [viewer, otherChief] = candidates; + const viewerUserId = `chief-center-viewer-${randomUUID()}`; + const otherChiefUserId = `chief-center-peer-${randomUUID()}`; + const originalTurns = await db.nationTurn.findMany({ + where: { nationId: editor.nationId, officerLevel: editor.officerLevel }, + orderBy: { turnIdx: 'asc' }, + }); + const originalRevision = await db.nationTurnRevision.findUnique({ + where: { + nationId_officerLevel: { nationId: editor.nationId, officerLevel: editor.officerLevel }, + }, + }); + let selectedTargetId: number | undefined; + + try { + await db.$transaction([ + db.general.update({ + where: { id: viewer.id }, + data: { + userId: viewerUserId, + officerLevel: 1, + npcState: 0, + meta: { ...(viewer.meta as Record), belong: 999 }, + penalty: {}, + }, + }), + db.general.update({ + where: { id: otherChief.id }, + data: { userId: otherChiefUserId, officerLevel: 10, npcState: 0, penalty: {} }, + }), + ]); + + const editorPage = await newPage(browser, editor.userId!, '사령부입력자'); + await editorPage.goto('chief-center'); + await expect(editorPage.getByRole('heading', { name: '사령부', exact: true })).toBeVisible(); + await editorPage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = editorPage.getByTestId('chief-command-picker'); + await expect(picker).toBeVisible(); + await picker.getByRole('button', { name: '인사', exact: true }).click(); + const reward = picker.getByRole('button', { name: /포상/ }); + await expect(reward).toBeEnabled(); + await reward.click(); + const argumentForm = picker.getByTestId('command-argument-form'); + await argumentForm.getByRole('button', { name: '쌀', exact: true }).click(); + await argumentForm.locator('input[type=number]').fill('1'); + const selectableGeneralIds = await argumentForm + .locator('select option') + .evaluateAll((options) => + options + .map((option) => Number((option as HTMLOptionElement).value)) + .filter((value) => Number.isInteger(value) && value > 0) + ); + selectedTargetId = selectableGeneralIds.find((generalId) => generalId !== editor.id); + if (!selectedTargetId) throw new Error('No reward target is available in the live command table.'); + await argumentForm.locator('select').selectOption(String(selectedTargetId)); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + await expect( + editorPage.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first() + ).toHaveText('포상'); + + const persisted = await db.nationTurn.findUniqueOrThrow({ + where: { + nationId_officerLevel_turnIdx: { + nationId: editor.nationId, + officerLevel: editor.officerLevel, + turnIdx: 0, + }, + }, + }); + expect(persisted.actionCode).toBe('che_포상'); + expect(persisted.arg).toEqual({ isGold: false, amount: 1, destGeneralId: selectedTargetId }); + await editorPage.screenshot({ path: testInfo.outputPath('chief-editor-command-entered.png'), fullPage: true }); + + const viewerPage = await newPage(browser, viewerUserId, '일반국가원'); + await viewerPage.goto('chief-center'); + await expect(viewerPage.getByRole('heading', { name: '사령부', exact: true })).toBeVisible(); + await expect(viewerPage.getByTestId('chief-command-editor')).toHaveCount(0); + await expect(viewerPage.locator('.chief-grid-row').first().getByText('포상', { exact: true })).toBeVisible(); + await viewerPage.screenshot({ path: testInfo.outputPath('chief-normal-user-visible.png'), fullPage: true }); + + const peerPage = await newPage(browser, otherChiefUserId, '다른수뇌'); + await peerPage.goto('chief-center'); + await expect(peerPage.getByTestId('chief-command-editor')).toBeVisible(); + await expect(peerPage.locator('.chief-grid-row').first().getByText('포상', { exact: true })).toBeVisible(); + await peerPage.screenshot({ path: testInfo.outputPath('chief-peer-visible.png'), fullPage: true }); + } finally { + await db.$transaction(async (transaction) => { + await transaction.nationTurn.deleteMany({ + where: { nationId: editor.nationId, officerLevel: editor.officerLevel }, + }); + if (originalTurns.length) await transaction.nationTurn.createMany({ data: originalTurns }); + await transaction.nationTurnRevision.deleteMany({ + where: { nationId: editor.nationId, officerLevel: editor.officerLevel }, + }); + if (originalRevision) await transaction.nationTurnRevision.create({ data: originalRevision }); + await transaction.general.update({ + where: { id: viewer.id }, + data: { + userId: viewer.userId, + officerLevel: viewer.officerLevel, + npcState: viewer.npcState, + meta: viewer.meta, + penalty: viewer.penalty, + }, + }); + await transaction.general.update({ + where: { id: otherChief.id }, + data: { + userId: otherChief.userId, + officerLevel: otherChief.officerLevel, + npcState: otherChief.npcState, + meta: otherChief.meta, + penalty: otherChief.penalty, + }, + }); + }); + await connector.disconnect(); + } +}); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 6768d9c..95a7af7 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -211,6 +211,7 @@ const install = async (page: Page, rejectGeneral = false) => { requests.push(body); return response({ ok: true, + revision: 1, turns: [{ index: 0, action: 'che_포상', args: { isGold: false, amount: 300, destGeneralId: 2 } }], }); } @@ -281,7 +282,7 @@ test('keeps the entered command visible and reports a server validation error', }); test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => { - await install(page); + const requests = await install(page); await page.setViewportSize({ width: 1000, height: 900 }); await page.goto('/'); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); @@ -329,10 +330,23 @@ test('keeps the shared main and chief shell geometry and interaction states', as await page.locator('.main-nation-menu').first().locator('[data-navigation-id="chief-center"]').click(); await expect(page).toHaveURL(/\/che\/chief-center$/); await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible(); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + await expect(page.getByTestId('chief-command-picker')).toBeVisible(); + await page.getByTestId('chief-command-picker').getByRole('button', { name: /포상/ }).click(); + const chiefArgumentForm = page.getByTestId('chief-command-picker').getByTestId('command-argument-form'); + await chiefArgumentForm.getByRole('button', { name: '쌀' }).click(); + await chiefArgumentForm.locator('input[type=number]').fill('300'); + await chiefArgumentForm.locator('select').selectOption('2'); + await page.getByTestId('chief-command-picker').getByRole('button', { name: '입력', exact: true }).click(); + await expect(page.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first()).toHaveText( + '포상' + ); + expect(JSON.stringify(requests)).toContain('"action":"che_포상"'); + expect(JSON.stringify(requests)).toContain('"destGeneralId":2'); const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({ width: element.getBoundingClientRect().width, padding: getComputedStyle(element).padding, - headerWidth: element.querySelector('.game-shell__header')!.getBoundingClientRect().width, + headerWidth: element.querySelector('.chief-top')!.getBoundingClientRect().width, })); expect(chiefDesktop).toEqual({ width: 1000, padding: '0px', headerWidth: 1000 }); @@ -340,7 +354,7 @@ test('keeps the shared main and chief shell geometry and interaction states', as const chiefMobile = await page.locator('.chief-page').evaluate((element) => ({ width: element.getBoundingClientRect().width, padding: getComputedStyle(element).padding, - headerWidth: element.querySelector('.game-shell__header')!.getBoundingClientRect().width, + headerWidth: element.querySelector('.chief-top')!.getBoundingClientRect().width, })); expect(chiefMobile).toEqual({ width: 500, padding: '0px', headerWidth: 500 }); }); 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/playwright.live.tsconfig.json b/app/game-frontend/e2e/playwright.live.tsconfig.json index acbae34..8c3eddf 100644 --- a/app/game-frontend/e2e/playwright.live.tsconfig.json +++ b/app/game-frontend/e2e/playwright.live.tsconfig.json @@ -16,6 +16,8 @@ "./npcPossessionLive.spec.ts", "./npcPossession.live.playwright.config.mjs", "./dieOnPrestartLive.spec.ts", - "./dieOnPrestart.live.playwright.config.mjs" + "./dieOnPrestart.live.playwright.config.mjs", + "./chiefCenterLive.spec.ts", + "./chiefCenter.live.playwright.config.mjs" ] } 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/chief/ChiefCommandEditor.vue b/app/game-frontend/src/components/chief/ChiefCommandEditor.vue new file mode 100644 index 0000000..a452d4f --- /dev/null +++ b/app/game-frontend/src/components/chief/ChiefCommandEditor.vue @@ -0,0 +1,445 @@ + + + + + diff --git a/app/game-frontend/src/components/chief/ChiefTurnCard.vue b/app/game-frontend/src/components/chief/ChiefTurnCard.vue index 1be9216..cf72796 100644 --- a/app/game-frontend/src/components/chief/ChiefTurnCard.vue +++ b/app/game-frontend/src/components/chief/ChiefTurnCard.vue @@ -18,6 +18,7 @@ const props = defineProps<{ compact?: boolean; isMe?: boolean; clickable?: boolean; + turnTimeLabel?: string; }>(); const emit = defineEmits<{ @@ -40,13 +41,26 @@ const handleClick = () => { @click="handleClick" >
-
- {{ props.officerLevelText }} - - {{ props.name ?? '-' }} - -
- ME + +
@@ -72,7 +86,9 @@ const handleClick = () => { .chief-card.clickable { cursor: pointer; - transition: border-color 0.2s ease, box-shadow 0.2s ease; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; } .chief-card.clickable:hover { @@ -163,6 +179,28 @@ const handleClick = () => { font-size: 0.65rem; } +.compact-name, +.compact-meta { + display: grid; + place-items: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} +.compact-meta { + grid-template-columns: 1fr 1fr; +} +.chief-card.compact .chief-header { + height: 72px; + grid-template-rows: 36px 36px; + display: grid; + padding: 0; +} +.chief-card.compact .chief-row { + height: 46px; + line-height: 46px; +} + .chief-card.compact .chief-level, .chief-card.compact .chief-name { font-size: 0.6rem; diff --git a/app/game-frontend/src/components/main/CommandSelectForm.vue b/app/game-frontend/src/components/main/CommandSelectForm.vue index 9015cfa..3228315 100644 --- a/app/game-frontend/src/components/main/CommandSelectForm.vue +++ b/app/game-frontend/src/components/main/CommandSelectForm.vue @@ -25,6 +25,7 @@ const props = defineProps<{ commandTable: CommandTable | null; loading: boolean; activeCategory?: string; + scope?: 'all' | 'general' | 'nation'; }>(); const emit = defineEmits<{ @@ -48,6 +49,10 @@ const categories = computed(() => { category: group.category, groupType: 'nation' as const, })); + if (props.scope === 'general') return general; + if (props.scope === 'nation') { + return nation.map((entry) => ({ ...entry, label: entry.category === '국가' ? '기타' : entry.category })); + } return [...general, ...nation]; }); @@ -58,7 +63,10 @@ const selectedGroup = computed(() => { } const [scope, ...categoryParts] = selectedCategory.value.split(':'); const category = categoryParts.join(':'); - return props.commandTable[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ?? null; + return ( + props.commandTable[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ?? + null + ); }); watch( @@ -109,9 +117,7 @@ const statusLabel = (command: CommandAvailability) => {
-
- 명령 목록을 불러오지 못했습니다. -
+
명령 목록을 불러오지 못했습니다.