From 43ec211ff81a685cdca6f186e71761333b926a5c Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 1 Aug 2026 22:03:03 +0000 Subject: [PATCH] feat(game): align in-game GUI with ref captures --- app/game-api/src/router/public/index.ts | 24 +- app/game-api/src/router/yearbook/index.ts | 30 +- app/game-api/src/server.ts | 7 + app/game-api/test/publicNpcList.test.ts | 50 +- .../test/yearbookArchiveRouter.test.ts | 8 +- app/game-engine/src/turn/yearbookHandler.ts | 22 +- app/game-frontend/e2e/battleSimulator.spec.ts | 23 +- app/game-frontend/e2e/mainNavigation.spec.ts | 6 +- .../components/battle/BattleGeneralCard.vue | 120 +++-- .../src/components/main/CommandListPanel.vue | 87 ++-- .../src/components/main/MapCityDetail.vue | 4 +- .../src/components/main/MapViewer.vue | 36 +- app/game-frontend/src/stores/mapViewer.ts | 2 +- app/game-frontend/src/views/AuctionView.vue | 4 +- .../src/views/BattleCenterView.vue | 62 ++- .../src/views/BattleSimulatorView.vue | 458 ++++++++++++------ app/game-frontend/src/views/BoardView.vue | 11 + .../src/views/ChiefCenterView.vue | 333 +++++-------- app/game-frontend/src/views/InheritView.vue | 32 +- app/game-frontend/src/views/MainView.vue | 265 ++++++++-- app/game-frontend/src/views/MyPageView.vue | 2 +- .../src/views/NationBettingView.vue | 4 + .../src/views/NationGeneralsView.vue | 304 +++++++----- .../src/views/NationInfoView.vue | 33 +- .../src/views/NationSecretView.vue | 4 +- app/game-frontend/src/views/NpcListView.vue | 41 +- .../src/views/TournamentView.vue | 2 +- app/game-frontend/src/views/TrafficView.vue | 2 +- app/game-frontend/src/views/TroopView.vue | 5 +- app/game-frontend/src/views/YearbookView.vue | 101 ++-- 30 files changed, 1306 insertions(+), 776 deletions(-) diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index d252303..e024401 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -492,9 +492,26 @@ export const publicRouter = router({ throw new TRPCError({ code: 'UNAUTHORIZED' }); } const now = new Date(Math.floor(Date.now() / 1000) * 1000); + const poolGeneralIds = includeAllWithToken + ? [] + : ( + await ctx.db.selectPoolEntry.findMany({ + where: { generalId: { not: null } }, + select: { generalId: true }, + }) + ).flatMap(({ generalId }) => (generalId === null ? [] : [generalId])); const [generals, nations, activeTokens, worldState] = await Promise.all([ ctx.db.general.findMany({ - ...(includeAllWithToken ? {} : { where: { npcState: { gt: 0 } } }), + ...(includeAllWithToken + ? {} + : { + where: { + OR: [ + { npcState: 1 }, + { npcState: 0, id: { in: poolGeneralIds } }, + ], + }, + }), select: { id: true, name: true, @@ -546,12 +563,13 @@ export const publicRouter = router({ const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, 255))); const maxDedLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxDedLevel, 30))); - // Legacy public NPC list put pool rows before possessed rows. The token-aware + // Legacy a_npcList.php shows select_pool humans first and possessed npc=1 rows. + // Unpossessed npc=2 candidates belong only to the token-aware selection screen. // selection list instead consumes the raw id-ordered full list before its own comparator. const sourceRows = includeAllWithToken ? generals : [ - ...generals.filter((general) => general.npcState >= 2), + ...generals.filter((general) => general.npcState === 0), ...generals.filter((general) => general.npcState === 1), ]; const rows = sourceRows.map((general) => { diff --git a/app/game-api/src/router/yearbook/index.ts b/app/game-api/src/router/yearbook/index.ts index 7f825a3..da38770 100644 --- a/app/game-api/src/router/yearbook/index.ts +++ b/app/game-api/src/router/yearbook/index.ts @@ -20,6 +20,7 @@ type YearbookNation = { color: string; level: number; power: number; + generalCount: number; cities: string[]; }; @@ -52,13 +53,19 @@ const parseYearbookNations = (value: unknown): YearbookNation[] => { const color = typeof item.color === 'string' ? item.color : null; const level = typeof item.level === 'number' ? item.level : null; const power = typeof item.power === 'number' ? item.power : null; + const generalCount = + typeof item.generalCount === 'number' + ? item.generalCount + : typeof item.gennum === 'number' + ? item.gennum + : 0; const cities = Array.isArray(item.cities) ? item.cities.filter((city): city is string => typeof city === 'string') : null; if (id === null || name === null || color === null || level === null || power === null || !cities) { continue; } - output.push({ id, name, color, level, power, cities }); + output.push({ id, name, color, level, power, generalCount, cities }); } return output; }; @@ -155,9 +162,18 @@ const buildNationSnapshot = async (ctx: GameApiContext) => { cityNamesByNation.set(city.nationId, cityNames); } - const generalStatsByNation = new Map(); + const generalStatsByNation = new Map< + number, + { goldRice: number; statPower: number; expDed: number; generalCount: number } + >(); for (const general of generalRows) { - const entry = generalStatsByNation.get(general.nationId) ?? { goldRice: 0, statPower: 0, expDed: 0 }; + const entry = generalStatsByNation.get(general.nationId) ?? { + goldRice: 0, + statPower: 0, + expDed: 0, + generalCount: 0, + }; + entry.generalCount += 1; entry.goldRice += general.gold + general.rice; const leadership = general.leadership; const strength = general.strength; @@ -170,7 +186,12 @@ const buildNationSnapshot = async (ctx: GameApiContext) => { } return nationRows.map((nation) => { - const generalStats = generalStatsByNation.get(nation.id) ?? { goldRice: 0, statPower: 0, expDed: 0 }; + const generalStats = generalStatsByNation.get(nation.id) ?? { + goldRice: 0, + statPower: 0, + expDed: 0, + generalCount: 0, + }; const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100); const tech = nation.tech ?? 0; @@ -187,6 +208,7 @@ const buildNationSnapshot = async (ctx: GameApiContext) => { color: nation.color, level: nation.level, power, + generalCount: generalStats.generalCount, cities: cityNamesByNation.get(nation.id) ?? [], }; }); diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 662642d..b536168 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -218,6 +218,13 @@ export const createGameApiServer = async () => { } reply.hijack(); + const requestOrigin = request.headers.origin; + if (typeof requestOrigin === 'string' && requestOrigin.length > 0) { + // Hijacked SSE responses bypass Fastify's normal CORS response hook. + reply.raw.setHeader('Access-Control-Allow-Origin', requestOrigin); + reply.raw.setHeader('Access-Control-Allow-Credentials', 'true'); + reply.raw.setHeader('Vary', 'Origin'); + } reply.raw.setHeader('Content-Type', 'text/event-stream'); reply.raw.setHeader('Cache-Control', 'no-cache'); reply.raw.setHeader('Connection', 'keep-alive'); diff --git a/app/game-api/test/publicNpcList.test.ts b/app/game-api/test/publicNpcList.test.ts index 6f51d79..a53a805 100644 --- a/app/game-api/test/publicNpcList.test.ts +++ b/app/game-api/test/publicNpcList.test.ts @@ -65,9 +65,9 @@ const buildContext = (auth: GameSessionTokenPayload | null = null): GameApiConte age: 44, officerLevel: 12, nationId: 1, - leadership: 80, - strength: 70, - intel: 85, + leadership: 90, + strength: 95, + intel: 75, experience: 16_000, dedication: 10_000, personalCode: 'None', @@ -78,14 +78,21 @@ const buildContext = (auth: GameSessionTokenPayload | null = null): GameApiConte ]; const db = { general: { - findMany: async (args: { where?: { npcState: { gt: number } } }) => { + findMany: async (args: { + where?: { OR: [{ npcState: number }, { npcState: number; id: { in: number[] } }] }; + }) => { if (args.where) { - expect(args.where).toEqual({ npcState: { gt: 0 } }); - return generalRows.filter((general) => general.npcState > 0); + expect(args.where).toEqual({ + OR: [{ npcState: 1 }, { npcState: 0, id: { in: [30] } }], + }); + return generalRows.filter((general) => general.npcState === 1 || general.id === 30); } return generalRows; }, }, + selectPoolEntry: { + findMany: async () => [{ generalId: 30 }], + }, nation: { findMany: async () => [{ id: 1, name: '촉', level: 5 }], }, @@ -165,32 +172,7 @@ describe('public.getNpcList', () => { dedication: 700, dedicationText: '28품관', }, - { - id: 20, - name: '조운', - picture: '20.jpg', - imageServer: 1, - npcState: 2, - ownerName: '', - age: 35, - level: 5, - officerLevel: 0, - killturn: 0, - nationId: 0, - nationName: '-', - nationLevel: 0, - personality: null, - specialDomestic: null, - specialWar: null, - statTotal: 260, - leadership: 90, - strength: 95, - intelligence: 75, - experience: 900, - experienceText: '무명', - dedication: 600, - dedicationText: '28품관', - }, + expect.objectContaining({ id: 30, name: '유비', npcState: 0, ownerName: '' }), ]); expect(result.tokenKeepCounts).toEqual({}); expect(JSON.stringify(result)).not.toContain('노출 금지'); @@ -237,7 +219,7 @@ describe('public.getNpcList', () => { it('keeps pool rows before possessed NPCs when the selected value is tied', async () => { const result = await appRouter.createCaller(buildContext()).public.getNpcList({ sort: 3 }); - expect(result.generals.map((general) => general.id)).toEqual([20, 10]); + expect(result.generals.map((general) => general.id)).toEqual([30, 10]); }); it('falls an invalid legacy sort value back to name order', async () => { @@ -245,6 +227,6 @@ describe('public.getNpcList', () => { const result = await caller.public.getNpcList({ sort: 99 } as unknown as { sort: 1 }); expect(result.sort).toBe(1); - expect(result.generals.map((general) => general.name)).toEqual(['관우', '조운']); + expect(result.generals.map((general) => general.name)).toEqual(['관우', '유비']); }); }); diff --git a/app/game-api/test/yearbookArchiveRouter.test.ts b/app/game-api/test/yearbookArchiveRouter.test.ts index 3e19472..24e8076 100644 --- a/app/game-api/test/yearbookArchiveRouter.test.ts +++ b/app/game-api/test/yearbookArchiveRouter.test.ts @@ -26,7 +26,7 @@ const archiveRows = [ year: 200, month: 1, map: { year: 200, month: 1, startYear: 190, cityList: [], nationList: [] }, - nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1000, cities: ['성도'] }], + nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1000, generalCount: 8, cities: ['성도'] }], globalHistory: ['●1월: 기록 없음'], globalAction: ['●1월: 기록 없음'], hash: 'archive-1', @@ -39,7 +39,7 @@ const archiveRows = [ year: 200, month: 2, map: { year: 200, month: 2, startYear: 190, cityList: [], nationList: [] }, - nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1200, cities: ['성도'] }], + nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1200, generalCount: 9, cities: ['성도'] }], globalHistory: ['●2월: 기록 없음'], globalAction: ['●2월: 기록 없음'], hash: 'archive-2', @@ -52,7 +52,7 @@ const archiveRows = [ year: 219, month: 12, map: { year: 219, month: 12, startYear: 190, cityList: [], nationList: [] }, - nations: [{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, cities: ['낙양'] }], + nations: [{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, generalCount: 10, cities: ['낙양'] }], globalHistory: ['저장된 현재 기수 과거 기록'], globalAction: ['저장된 현재 기수 과거 행동'], hash: 'current-archive', @@ -151,7 +151,7 @@ describe('historical yearbook access from dynasty', () => { data: { year: 200, month: 2, - nations: [{ id: 1, name: '촉', cities: ['성도'] }], + nations: [{ id: 1, name: '촉', generalCount: 9, cities: ['성도'] }], globalHistory: ['●2월: 기록 없음'], globalAction: ['●2월: 기록 없음'], }, diff --git a/app/game-engine/src/turn/yearbookHandler.ts b/app/game-engine/src/turn/yearbookHandler.ts index 60ae28c..5959f4a 100644 --- a/app/game-engine/src/turn/yearbookHandler.ts +++ b/app/game-engine/src/turn/yearbookHandler.ts @@ -24,6 +24,7 @@ type YearbookNation = { color: string; level: number; power: number; + generalCount: number; cities: string[]; }; @@ -110,9 +111,18 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => { cityNamesByNation.set(city.nationId, cityNames); } - const generalStatsByNation = new Map(); + const generalStatsByNation = new Map< + number, + { goldRice: number; statPower: number; expDed: number; generalCount: number } + >(); for (const general of generals) { - const entry = generalStatsByNation.get(general.nationId) ?? { goldRice: 0, statPower: 0, expDed: 0 }; + const entry = generalStatsByNation.get(general.nationId) ?? { + goldRice: 0, + statPower: 0, + expDed: 0, + generalCount: 0, + }; + entry.generalCount += 1; entry.goldRice += general.gold + general.rice; const leadership = general.stats.leadership; const strength = general.stats.strength; @@ -125,7 +135,12 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => { } return nations.map((nation) => { - const generalStats = generalStatsByNation.get(nation.id) ?? { goldRice: 0, statPower: 0, expDed: 0 }; + const generalStats = generalStatsByNation.get(nation.id) ?? { + goldRice: 0, + statPower: 0, + expDed: 0, + generalCount: 0, + }; const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100); const tech = asNumber(asRecord(nation.meta).tech, 0); @@ -142,6 +157,7 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => { color: nation.color, level: nation.level, power, + generalCount: generalStats.generalCount, cities: cityNamesByNation.get(nation.id) ?? [], }; }); diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index d58212e..d3389a2 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -255,7 +255,7 @@ const installApi = async (page: Page, fixture: Fixture) => { const gotoSimulator = async (page: Page) => { await page.goto('battle-simulator'); - await expect(page.getByRole('heading', { name: '전투 시뮬레이터' })).toBeVisible(); + await expect(page.getByText('전역 설정')).toBeVisible(); await expect(page.getByLabel('시뮬레이터 데이터 안내')).toBeVisible(); await expect(page.getByText('출병자 설정')).toBeVisible(); }; @@ -274,8 +274,8 @@ test('operates independent/game presets, imports my general, and renders battle const notice = page.getByLabel('시뮬레이터 데이터 안내'); const noticeRect = await notice.boundingBox(); - expect(noticeRect?.width).toBeGreaterThan(900); - expect(await notice.evaluate((element) => getComputedStyle(element).display)).toBe('flex'); + expect(noticeRect?.width).toBeLessThan(100); + await notice.locator('summary').click(); await page.getByRole('button', { name: '독립 기본값' }).click(); await expect(page.getByLabel('연도', { exact: true })).toHaveValue('190'); @@ -288,10 +288,7 @@ test('operates independent/game presets, imports my general, and renders battle await page.getByRole('button', { name: '내 장수를 출병자로' }).click(); await expect(page.getByLabel('이름').first()).toHaveValue('유비'); await expect(page.getByLabel('병사').first()).toHaveValue('4321'); - const attackerDomesticTrait = page.getByLabel('내정특기').first(); - await attackerDomesticTrait.selectOption('che_event_신산'); - await expect(attackerDomesticTrait).toHaveValue('che_event_신산'); - + await notice.locator('summary').click(); const battleButton = page.getByRole('button', { name: '전투', exact: true }); await battleButton.hover(); expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); @@ -315,15 +312,10 @@ test('operates independent/game presets, imports my general, and renders battle objType: string; data: { attackerGeneral: { special?: string | null } }; }; - expect(exportedBattle).toMatchObject({ - objType: 'battle', - data: { attackerGeneral: { special: 'che_event_신산' } }, - }); + expect(exportedBattle).toMatchObject({ objType: 'battle' }); + expect(exportedBattle).not.toHaveProperty('data.attackerGeneral.special'); - await attackerDomesticTrait.selectOption({ label: '-' }); - await expect(attackerDomesticTrait).toHaveValue('-'); await page.locator('.header-actions input[type="file"]').setInputFiles(downloadPath!); - await expect(attackerDomesticTrait).toHaveValue('che_event_신산'); await battleButton.click(); await expect.poll(() => fixture.simulationPayloads.length).toBe(2); @@ -353,6 +345,7 @@ test('keeps simulation available without a game general and preserves input afte await gotoSimulator(page); await expect(page).toHaveURL(/battle-simulator/); + await page.getByLabel('시뮬레이터 데이터 안내').locator('summary').click(); await expect(page.getByRole('button', { name: '내 장수를 출병자로' })).toBeDisabled(); await expect(page.getByRole('button', { name: '서버에서 가져오기' }).first()).toBeDisabled(); @@ -366,7 +359,7 @@ test('keeps simulation available without a game general and preserves input afte await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0); const notice = page.getByLabel('시뮬레이터 데이터 안내'); - expect(await notice.evaluate((element) => getComputedStyle(element).flexDirection)).toBe('column'); + expect(await notice.evaluate((element) => getComputedStyle(element).position)).toBe('absolute'); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); if (artifactRoot) { diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 2f7d5fd..b7979b7 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -331,7 +331,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); -test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => { +test('the 939/940 boundary switches to the Ref-style 502px single document', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -367,8 +367,8 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy }); expect(documentGeometry).toMatchObject({ x: 0, - width: 500, - scrollWidth: 500, + width: 502, + scrollWidth: 502, }); expect(documentGeometry.height).toBeGreaterThan(900); for (const selector of [ diff --git a/app/game-frontend/src/components/battle/BattleGeneralCard.vue b/app/game-frontend/src/components/battle/BattleGeneralCard.vue index 710f7ec..379f7c1 100644 --- a/app/game-frontend/src/components/battle/BattleGeneralCard.vue +++ b/app/game-frontend/src/components/battle/BattleGeneralCard.vue @@ -206,21 +206,6 @@ const officerLevelOptions = [ 사기 - - -