From 2d711d0bab69a5b9e09408d37d833aa03ebbb950 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 25 Aug 2026 01:18:27 +0000 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20NPC=20=EC=9E=A5=EC=88=98?= =?UTF-8?q?=EB=AA=85=20=EC=83=89=EC=83=81=EC=9D=84=20Ref=EC=99=80=20?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=EC=8B=9C=ED=82=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 암행부와 국가 장수 목록의 잘못되거나 누락된 색상을 공통 팔레트로 통일한다. 명령 대상, 토너먼트와 베팅 응답에도 npcState를 전달해 같은 표시 계약을 적용한다. 데스크톱과 모바일 Chromium 회귀 테스트로 상태별 색상을 검증한다. --- app/game-api/src/router/tournament/index.ts | 3 +- app/game-api/src/turns/commandInput.ts | 1 + app/game-api/src/turns/commandTargets.ts | 1 + app/game-api/test/commandTargets.test.ts | 1 + .../e2e/commandArguments.spec.ts | 5 + .../e2e/nationGeneralSecret.spec.ts | 187 ++++++++++++------ .../e2e/tournamentBracket.spec.ts | 46 ++++- .../src/components/command/types.ts | 1 + .../components/main/CommandArgumentForm.vue | 18 +- .../tournament/TournamentBracket.vue | 14 +- .../tournament/TournamentGroupCard.vue | 2 + .../src/components/ui/GeneralIdentity.vue | 11 +- .../src/utils/tournamentBracket.ts | 4 + app/game-frontend/src/views/BettingView.vue | 2 + .../src/views/NationGeneralsView.vue | 24 +-- .../src/views/NationSecretView.vue | 15 +- app/game-frontend/src/views/NpcListView.vue | 7 +- 17 files changed, 253 insertions(+), 89 deletions(-) diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index f6e9b698..8e61d694 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -160,7 +160,7 @@ export const tournamentRouter = router({ ? [] : await ctx.db.general.findMany({ where: { id: { in: participantIds } }, - select: { id: true, picture: true, imageServer: true }, + select: { id: true, picture: true, imageServer: true, npcState: true }, }); const iconsByGeneralId = new Map(iconRows.map((general) => [general.id, general])); const publicParticipants = participants.map((participant) => { @@ -169,6 +169,7 @@ export const tournamentRouter = router({ ...participant, picture: icon?.picture ?? null, imageServer: icon?.imageServer ?? 0, + npcState: icon?.npcState ?? 0, }; }); return { state, participants: publicParticipants, matches, betCount: bets.length, sourceRevision }; diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index ea99df55..e0bffabe 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -26,6 +26,7 @@ export interface TurnCommandOption { rice?: number; crew?: number; troopId?: number; + npcState?: number; } export interface TurnCommandAmountPreset { diff --git a/app/game-api/src/turns/commandTargets.ts b/app/game-api/src/turns/commandTargets.ts index 44a3556f..b551a8db 100644 --- a/app/game-api/src/turns/commandTargets.ts +++ b/app/game-api/src/turns/commandTargets.ts @@ -83,6 +83,7 @@ export const buildRefGeneralTargetOptions = (options: { ...(entry.rice === undefined ? {} : { rice: entry.rice }), ...(entry.crew === undefined ? {} : { crew: entry.crew }), ...(entry.troopId === undefined ? {} : { troopId: entry.troopId }), + npcState: entry.npcState, }; }; const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] => diff --git a/app/game-api/test/commandTargets.test.ts b/app/game-api/test/commandTargets.test.ts index a94fb519..f56d7daa 100644 --- a/app/game-api/test/commandTargets.test.ts +++ b/app/game-api/test/commandTargets.test.ts @@ -52,6 +52,7 @@ describe('Ref command general targets', () => { expect(ids('che_등용')).toEqual([4]); expect(ids('che_장수대상임관')).toEqual([2, 3, 4, 5]); expect(result.generals.map((entry) => entry.value)).toEqual([1, 2, 4]); + expect(result.generalTargets.che_포상?.map((entry) => entry.npcState)).toEqual([0, 0, 2]); }); it('adds resource, crew, and troop details and puts actual troop members first for kick orders', () => { diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index d2718582..66914b0e 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -146,6 +146,7 @@ const inputOptions = { { value: 3, label: '여포NPC (아국 · 업)', + npcState: 2, gold: 3000, rice: 500, crew: 1500, @@ -2221,6 +2222,10 @@ test('offers Ref amount presets and rich, command-specific general lists', async '여포NPC (아국 · 업)', '장수 (아국 · 업)', ]); + await expect(generalList.locator('.target-option').filter({ hasText: '여포NPC' }).locator('strong')).toHaveCSS( + 'color', + 'rgb(0, 255, 255)' + ); await expect(generalList).toContainText('금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)'); await form.getByRole('button', { name: '쌀', exact: true }).click(); await expect(generalList.locator('.target-option strong')).toHaveText([ diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index 47f4426c..4accc26e 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -77,7 +77,38 @@ const otherGeneral = { belong: 4, refreshScoreTotal: 20, }; -const install = async (page: Page, secretAllowed = true) => { +const npcColorStates = [0, 1, 2, 4, 5, 6] as const; +const npcColorGenerals = npcColorStates.map((npcState) => ({ + ...general, + id: 100 + npcState, + name: `색상장수${npcState}`, + npcState, +})); +const npcColorSecretGenerals = npcColorStates.map((npcState) => ({ + id: 100 + npcState, + name: `색상장수${npcState}`, + npcState, + injury: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + leadershipBonus: 0, + experienceLevel: 9, + troopId: 0, + troopName: null, + gold: 1000, + rice: 2000, + cityId: 1, + cityName: '업', + defenceTrain: 90, + defenceTrainText: '☆', + crewTypeId: 1, + crew: 300, + train: 90, + atmos: 90, + killTurn: 7, + turnTime: `2026-01-01T01:0${npcState}:00.000Z`, + reservedCommands: [], +})); +const install = async (page: Page, secretAllowed = true, npcColorFixture = false) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_general'); localStorage.setItem('sammo-game-profile', profile); @@ -91,7 +122,7 @@ const install = async (page: Page, secretAllowed = true) => { return response({ nation: { id: 1, name: '위', color: '#008000', level: 3 }, viewer: { generalId: 1, permission: 0 }, - generals: [general, otherGeneral], + generals: npcColorFixture ? npcColorGenerals : [general, otherGeneral], }); if (operation === 'nation.getSecretGeneralList') { if (!secretAllowed) @@ -118,62 +149,64 @@ const install = async (page: Page, secretAllowed = true) => { 60: { crew: 300, generals: 1 }, }, }, - generals: [ - { - id: 1, - name: '테스트장수', - npcState: 0, - injury: 0, - stats: { leadership: 70, strength: 60, intelligence: 50 }, - leadershipBonus: 0, - experienceLevel: 9, - troopId: 0, - troopName: null, - gold: 1000, - rice: 2000, - cityId: 1, - cityName: '업', - defenceTrain: 90, - defenceTrainText: '☆', - crewTypeId: 1, - crew: 300, - train: 90, - atmos: 90, - killTurn: 7, - turnTime: '2026-01-01T01:02:00.000Z', - reservedCommands: [ - { action: 'che_이동', args: { destCityId: 1 } }, - { action: 'che_징병', args: { crewType: 1, amount: 300 } }, - { action: 'che_증여', args: { destGeneralId: 2, isGold: false, amount: 200 } }, - { action: 'che_화계', args: { destCityId: 1 } }, - { action: '휴식', args: {} }, - ], - }, - { - id: 2, - name: '부유장수', - npcState: 0, - injury: 0, - stats: { leadership: 60, strength: 50, intelligence: 40 }, - leadershipBonus: 0, - experienceLevel: 8, - troopId: 1, - troopName: '제1부대', - gold: 3000, - rice: 1000, - cityId: 2, - cityName: '낙양', - defenceTrain: 80, - defenceTrainText: '◎', - crewTypeId: 2, - crew: 100, - train: 80, - atmos: 80, - killTurn: 3, - turnTime: '2026-01-01T02:02:00.000Z', - reservedCommands: [], - }, - ], + generals: npcColorFixture + ? npcColorSecretGenerals + : [ + { + id: 1, + name: '테스트장수', + npcState: 0, + injury: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + leadershipBonus: 0, + experienceLevel: 9, + troopId: 0, + troopName: null, + gold: 1000, + rice: 2000, + cityId: 1, + cityName: '업', + defenceTrain: 90, + defenceTrainText: '☆', + crewTypeId: 1, + crew: 300, + train: 90, + atmos: 90, + killTurn: 7, + turnTime: '2026-01-01T01:02:00.000Z', + reservedCommands: [ + { action: 'che_이동', args: { destCityId: 1 } }, + { action: 'che_징병', args: { crewType: 1, amount: 300 } }, + { action: 'che_증여', args: { destGeneralId: 2, isGold: false, amount: 200 } }, + { action: 'che_화계', args: { destCityId: 1 } }, + { action: '휴식', args: {} }, + ], + }, + { + id: 2, + name: '부유장수', + npcState: 0, + injury: 0, + stats: { leadership: 60, strength: 50, intelligence: 40 }, + leadershipBonus: 0, + experienceLevel: 8, + troopId: 1, + troopName: '제1부대', + gold: 3000, + rice: 1000, + cityId: 2, + cityName: '낙양', + defenceTrain: 80, + defenceTrainText: '◎', + crewTypeId: 2, + crew: 100, + train: 80, + atmos: 80, + killTurn: 3, + turnTime: '2026-01-01T02:02:00.000Z', + reservedCommands: [], + }, + ], }); } if (operation === 'turns.getCommandTable') return response(commandTable); @@ -183,6 +216,44 @@ const install = async (page: Page, secretAllowed = true) => { }); }; +test('NPC general names use the complete Ref palette in secret and nation lists on desktop and mobile', async ({ + page, +}, testInfo) => { + await install(page, true, true); + const expectedColors = new Map([ + [1, 'rgb(135, 206, 235)'], + [2, 'rgb(0, 255, 255)'], + [4, 'rgb(0, 191, 255)'], + [5, 'rgb(0, 139, 139)'], + [6, 'rgb(102, 205, 170)'], + ]); + + for (const viewport of [ + { width: 1200, height: 900 }, + { width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + for (const path of ['nation/secret', 'nation/generals']) { + await page.goto(path); + const table = page.locator(path.endsWith('secret') ? '#secret-general-list' : '#nation-general-list'); + await expect(table).toBeVisible(); + for (const npcState of npcColorStates) { + const name = table.locator(`[data-npc-state="${npcState}"] [data-general-name]`); + await expect(name).toBeVisible(); + if (npcState === 0) { + expect(await name.evaluate((element) => (element as HTMLElement).style.color)).toBe(''); + } else { + await expect(name).toHaveCSS('color', expectedColors.get(npcState)!); + } + } + await page.screenshot({ + path: testInfo.outputPath(`npc-colors-${path.replace('/', '-')}-${viewport.width}.png`), + fullPage: true, + }); + } + } +}); + test('nation generals keeps the 1000px legacy grid and redacted member columns', async ({ page }) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 673fd0c5..debc1a6f 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -37,6 +37,7 @@ const names = [ longGeneralName, ...Array.from({ length: 48 }, (_, index) => `예선장수${index + 17}`), ]; +const tournamentNpcStates = [0, 1, 2, 4, 5, 6] as const; const participants = names.map((name, index) => ({ id: index + 1, name, @@ -46,6 +47,7 @@ const participants = names.map((name, index) => ({ level: 10, picture: 'default.jpg', imageServer: 0, + npcState: tournamentNpcStates[index % tournamentNpcStates.length], groupId: Math.floor(index / 8) < 4 ? 10 + (index % 8) : index % 8, groupNo: Math.floor(index / 8), win: Math.floor(index / 8) < 4 ? 3 - (index % 2) : 7 - Math.floor(index / 8), @@ -305,7 +307,7 @@ const installFixture = async ( name: participant.name, picture: participant.picture, imageServer: participant.imageServer, - npcState: 0, + npcState: participant.npcState, stat: 240 - index, games: 10, win: 7, @@ -368,6 +370,48 @@ const openTournament = async (page: Page) => { await expect(page.getByLabel('토너먼트 대진표')).toBeVisible(); }; +test('tournament and betting identities preserve Ref NPC name colors on desktop and mobile', async ({ + page, +}, testInfo) => { + await installFixture(page, { tournamentStage: 7 }); + const expectedColors = [ + '', + 'rgb(135, 206, 235)', + 'rgb(0, 255, 255)', + 'rgb(0, 191, 255)', + 'rgb(0, 139, 139)', + 'rgb(102, 205, 170)', + ]; + + for (const viewport of [ + { width: 1365, height: 900 }, + { width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + await page.goto('tournament'); + const scope = viewport.width > 800 ? '.desktop-bracket' : '.mobile-bracket'; + for (let index = 0; index < expectedColors.length; index += 1) { + const name = page.locator(`${scope} [data-general-id="${index + 1}"] .general-identity-name`).first(); + await expect(name).toBeVisible(); + if (expectedColors[index]) await expect(name).toHaveCSS('color', expectedColors[index]!); + else expect(await name.evaluate((element) => (element as HTMLElement).style.color)).toBe(''); + } + await page.screenshot({ + path: testInfo.outputPath(`tournament-npc-colors-${viewport.width}.png`), + fullPage: true, + }); + } + + await page.setViewportSize({ width: 1365, height: 900 }); + await page.goto('betting'); + const rankingNames = page.locator('.ranking-general .general-identity-name'); + for (let index = 0; index < expectedColors.length; index += 1) { + if (expectedColors[index]) await expect(rankingNames.nth(index)).toHaveCSS('color', expectedColors[index]!); + else expect(await rankingNames.nth(index).evaluate((element) => (element as HTMLElement).style.color)).toBe(''); + } + await page.screenshot({ path: testInfo.outputPath('betting-npc-colors-1365.png'), fullPage: true }); +}); + 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); diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index bb881946..a0e5a619 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -8,6 +8,7 @@ export type CommandOption = { rice?: number; crew?: number; troopId?: number; + npcState?: number; }; export type CommandAmountPreset = { diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index 5f7278a4..3465dc3a 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -9,6 +9,7 @@ import { type CommandArgumentFieldContract, } from '../command/commandArgumentDraft'; import { legacyNationTextColor } from '../../utils/legacyNationColor'; +import { getNpcColor } from '../../utils/npcColor'; import type { CommandInputContext, CommandInputField, @@ -141,11 +142,16 @@ const selectedOptionFor = (field: CommandInputField): CommandOption | undefined optionsFor(field).find((entry) => entry.value === values[field.key]); const colorOptionStyle = (field: CommandInputField, option?: CommandOption): CSSProperties | undefined => { - if (field.optionSource !== 'colors' || !option?.color) return undefined; - return { - backgroundColor: option.color, - color: legacyNationTextColor(option.color), - }; + if (field.optionSource === 'colors' && option?.color) { + return { + backgroundColor: option.color, + color: legacyNationTextColor(option.color), + }; + } + if (field.optionSource === 'generals' && option?.npcState !== undefined) { + return { color: getNpcColor(option.npcState) }; + } + return undefined; }; const cityTargetField = computed(() => @@ -561,7 +567,7 @@ watch( @click="setSelectValue(field, String(option.value))" > - {{ option.label }} + {{ option.label }} {{ option.availableNow === false ? '현재 불가' : option.availableNow ? '우선 대상' : '대상' }} diff --git a/app/game-frontend/src/components/tournament/TournamentBracket.vue b/app/game-frontend/src/components/tournament/TournamentBracket.vue index 3d494fe3..d1982010 100644 --- a/app/game-frontend/src/components/tournament/TournamentBracket.vue +++ b/app/game-frontend/src/components/tournament/TournamentBracket.vue @@ -134,7 +134,12 @@ const mobilePairs = computed(() => { top: `${slotY(columnIndex, slotIndex)}px`, }" > - +