diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index da826725..4819e965 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -28,7 +28,20 @@ import { sanitizeInternalDisplayCode, } from '../../services/gameDisplayNames.js'; import { getMyGeneral } from '../shared/general.js'; -import { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js'; +import { + loadTraitNames, + resolveNationBill, + resolveNationBlockScout, + resolveNationBlockWar, + resolveNationNotice, + resolveNationRate, + type TraitNameMap, +} from '../nation/shared.js'; +import { + resolveImpossibleStrategicCommands, + resolveMainNationTech, + splitNationTraitInfo, +} from '../../services/mainNationProjection.js'; const zGeneralSettings = z.object({ tnmt: z.number().int().optional(), @@ -54,6 +67,7 @@ const NEUTRAL_NATION_CONTEXT = { tech: 0, typeCode: 'None', capitalCityId: null, + meta: {}, } as const; const resolveImmediateActionRequestId = ( @@ -296,20 +310,42 @@ export const getGeneralContext = async (ctx: GameApiContext) => { tech: true, typeCode: true, capitalCityId: true, + meta: true, }, }) : Promise.resolve(NEUTRAL_NATION_CONTEXT), - ctx.db.worldState.findFirst({ select: { config: true } }), + ctx.db.worldState.findFirst({ select: { currentYear: true, currentMonth: true, config: true, meta: true } }), ]); const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT; - const [capitalCity, cityNation] = await Promise.all([ + const [capitalCity, cityNation, nationPopulation, nationCrew, topChiefRows] = await Promise.all([ nation.capitalCityId ? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } }) : Promise.resolve(null), city && city.nationId > 0 ? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true } }) : Promise.resolve(null), + nation.id > 0 + ? ctx.db.city.aggregate({ + where: { nationId: nation.id }, + _count: true, + _sum: { population: true, populationMax: true }, + }) + : Promise.resolve({ _count: 0, _sum: { population: 0, populationMax: 0 } }), + nation.id > 0 + ? ctx.db.general.aggregate({ + where: { nationId: nation.id, npcState: { not: 5 } }, + _count: true, + _sum: { crew: true, leadership: true }, + }) + : Promise.resolve({ _count: 0, _sum: { crew: 0, leadership: 0 } }), + nation.id > 0 + ? ctx.db.general.findMany({ + where: { nationId: nation.id, officerLevel: { gte: 11 } }, + select: { id: true, name: true, npcState: true, officerLevel: true }, + orderBy: { id: 'asc' }, + }) + : Promise.resolve([]), ]); const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeNames, itemNames] = await Promise.all([ loadTraitNames([general.personalCode], 'personality'), @@ -327,6 +363,18 @@ export const getGeneralContext = async (ctx: GameApiContext) => { const settings = resolveUserSettings(metaRecord); const penalties = resolvePenalty(general.penalty); const dedicationLevel = readNumber(metaRecord.dedlevel, 0); + const nationMeta = asRecord(nation.meta); + const nationType = nationTypeNames.get(nation.typeCode); + const nationTypeEffects = splitNationTraitInfo(nationType?.info ?? ''); + const nationTech = resolveMainNationTech({ + tech: nation.tech, + currentYear: worldState?.currentYear ?? 0, + worldConfig: worldState?.config, + worldMeta: worldState?.meta, + }); + const topChiefs = Object.fromEntries( + topChiefRows.map((chief) => [chief.officerLevel, { id: chief.id, name: chief.name, npcState: chief.npcState }]) + ); const itemName = (code: string | null): string | null => { const normalized = normalizeItemCode(code); return normalized ? (itemNames.get(normalized) ?? sanitizeInternalDisplayCode(normalized)) : null; @@ -406,13 +454,48 @@ export const getGeneralContext = async (ctx: GameApiContext) => { } : null, nation: { - ...nation, + id: nation.id, + name: nation.name, + color: nation.color, + level: nation.level, + gold: nation.gold, + rice: nation.rice, + tech: nation.tech, + typeCode: nation.typeCode, + capitalCityId: nation.capitalCityId, levelName: resolveNationLevelName(nation.level), - typeName: - nation.id === 0 - ? '해당 없음' - : (nationTypeNames.get(nation.typeCode)?.name ?? sanitizeInternalDisplayCode(nation.typeCode)), + typeName: nation.id === 0 ? '-' : (nationType?.name ?? sanitizeInternalDisplayCode(nation.typeCode)), + typePros: nationTypeEffects.pros, + typeCons: nationTypeEffects.cons, capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null), + population: { + cityCount: nationPopulation._count, + current: nationPopulation._sum.population ?? 0, + max: nationPopulation._sum.populationMax ?? 0, + }, + crew: { + generalCount: nationCrew._count, + current: nationCrew._sum.crew ?? 0, + max: (nationCrew._sum.leadership ?? 0) * 100, + }, + power: readNumber(nationMeta.power, 0), + bill: resolveNationBill(nationMeta), + taxRate: resolveNationRate(nation), + strategicCommandLimit: readNumber(nationMeta.strategic_cmd_limit, 0), + diplomaticLimit: readNumber(nationMeta.surlimit, 0), + prohibitScout: resolveNationBlockScout(nationMeta), + prohibitWar: resolveNationBlockWar(nationMeta), + techLevel: nationTech.level, + techLimited: nationTech.limited, + topChiefs, + impossibleStrategicCommands: + nation.id === 0 + ? [] + : resolveImpossibleStrategicCommands( + nationMeta, + worldState?.currentYear ?? 0, + worldState?.currentMonth ?? 1 + ), }, settings, penalties, diff --git a/app/game-api/src/services/mainNationProjection.ts b/app/game-api/src/services/mainNationProjection.ts new file mode 100644 index 00000000..d250b0a2 --- /dev/null +++ b/app/game-api/src/services/mainNationProjection.ts @@ -0,0 +1,75 @@ +import { asRecord } from '@sammo-ts/common'; + +const STRATEGIC_COMMAND_NAMES = [ + '필사즉생', + '백성동원', + '수몰', + '허보', + '의병모집', + '이호경식', + '급습', + '피장파장', +] as const; + +const readFiniteNumber = (value: unknown, fallback = 0): number => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +}; + +const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); + +export const splitNationTraitInfo = (info: string): { pros: string; cons: string } => { + const tokens = info.trim().split(/\s+/u).filter(Boolean); + return { + pros: tokens.filter((token) => token.endsWith('↑')).join(' '), + cons: tokens.filter((token) => token.endsWith('↓')).join(' '), + }; +}; + +export const resolveMainNationTech = (options: { + tech: number; + currentYear: number; + worldConfig: unknown; + worldMeta: unknown; +}): { level: number; limited: boolean } => { + const config = asRecord(options.worldConfig); + const constValues = asRecord(config.const ?? config.consts); + const scenarioMeta = asRecord(asRecord(options.worldMeta).scenarioMeta); + const maxLevel = Math.max(1, Math.floor(readFiniteNumber(constValues.maxTechLevel, 12))); + const initialLevel = Math.max(1, Math.floor(readFiniteNumber(constValues.initialAllowedTechLevel, 1))); + const increaseYears = Math.max(1, Math.floor(readFiniteNumber(constValues.techLevelIncYear, 5))); + const startYear = readFiniteNumber(scenarioMeta.startYear, options.currentYear); + const relativeMaximum = clamp( + Math.floor((options.currentYear - startYear) / increaseYears) + initialLevel, + 1, + maxLevel + ); + const level = clamp(Math.floor(options.tech / 1000), 0, maxLevel); + return { level, limited: level >= relativeMaximum }; +}; + +export const resolveImpossibleStrategicCommands = ( + nationMeta: unknown, + currentYear: number, + currentMonth: number +): Array<{ name: string; remainingTurns: number; availableYear: number; availableMonth: number }> => { + const meta = asRecord(nationMeta); + const currentYearMonth = Math.floor(currentYear) * 12 + Math.floor(currentMonth) - 1; + const result: Array<{ name: string; remainingTurns: number; availableYear: number; availableMonth: number }> = []; + + for (const name of STRATEGIC_COMMAND_NAMES) { + const nextAvailable = Math.floor(readFiniteNumber(meta[`next_execute_${name}`], 0)); + if (nextAvailable <= currentYearMonth) continue; + result.push({ + name, + remainingTurns: nextAvailable - currentYearMonth, + availableYear: Math.floor(nextAvailable / 12), + availableMonth: (nextAvailable % 12) + 1, + }); + } + return result; +}; diff --git a/app/game-api/test/mainNationProjection.test.ts b/app/game-api/test/mainNationProjection.test.ts new file mode 100644 index 00000000..6f2494eb --- /dev/null +++ b/app/game-api/test/mainNationProjection.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveImpossibleStrategicCommands, + resolveMainNationTech, + splitNationTraitInfo, +} from '../src/services/mainNationProjection.js'; + +describe('main nation projection', () => { + it('splits the Ref nation-type advantages and disadvantages without changing their order', () => { + expect(splitNationTraitInfo('농상↑ 민심↑ 쌀수입↓')).toEqual({ + pros: '농상↑ 민심↑', + cons: '쌀수입↓', + }); + }); + + it('uses the scenario-relative Ref technology grade and limit', () => { + expect( + resolveMainNationTech({ + tech: 3_999, + currentYear: 190, + worldConfig: { + const: { maxTechLevel: 12, initialAllowedTechLevel: 1, techLevelIncYear: 5 }, + }, + worldMeta: { scenarioMeta: { startYear: 180 } }, + }) + ).toEqual({ level: 3, limited: true }); + }); + + it('returns only strategic commands whose Ref-compatible cooldown is still active', () => { + expect( + resolveImpossibleStrategicCommands( + { + next_execute_수몰: 190 * 12 + 4, + next_execute_허보: 190 * 12 + 2, + }, + 190, + 4 + ) + ).toEqual([{ name: '수몰', remainingTurns: 1, availableYear: 190, availableMonth: 5 }]); + }); +}); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 90c58cba..fbc3bac8 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -116,7 +116,31 @@ const generalContext = { items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, }, city, - nation: { id: 1, name: '아국', color: '#008000', level: 1 }, + nation: { + id: 1, + name: '아국', + color: '#008000', + level: 1, + gold: 10_000, + rice: 9_000, + tech: 100, + typeName: '유가', + typePros: '농상↑ 민심↑', + typeCons: '쌀수입↓', + population: { cityCount: 1, current: 150_000, max: 620_500 }, + crew: { generalCount: 2, current: 500, max: 7_000 }, + power: 1_234, + bill: 100, + taxRate: 20, + strategicCommandLimit: 0, + diplomaticLimit: 0, + prohibitScout: false, + prohibitWar: false, + techLevel: 0, + techLimited: false, + topChiefs: {}, + impossibleStrategicCommands: [], + }, settings: {}, penalties: {}, }; diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index c74f6f96..3dbcf455 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -145,9 +145,24 @@ const myGeneral = (state: FixtureState) => ({ rice: 0, tech: 0, typeCode: 'None', - typeName: '해당 없음', + typeName: '-', + typePros: '', + typeCons: '', capitalCityId: null, capitalCityName: null, + population: { cityCount: 0, current: 0, max: 0 }, + crew: { generalCount: 0, current: 0, max: 0 }, + power: 0, + bill: 100, + taxRate: 20, + strategicCommandLimit: 0, + diplomaticLimit: 0, + prohibitScout: false, + prohibitWar: false, + techLevel: 0, + techLimited: false, + topChiefs: {}, + impossibleStrategicCommands: [], } : { id: 1, @@ -162,6 +177,21 @@ const myGeneral = (state: FixtureState) => ({ typeName: '법가', capitalCityId: 1, capitalCityName: '업', + typePros: '금수입↑ 치안↑', + typeCons: '인구↓ 민심↓', + population: { cityCount: 1, current: 1_000, max: 2_000 }, + crew: { generalCount: 2, current: 500, max: 7_000 }, + power: 1_234, + bill: 100, + taxRate: 20, + strategicCommandLimit: 0, + diplomaticLimit: 0, + prohibitScout: false, + prohibitWar: false, + techLevel: 0, + techLimited: false, + topChiefs: {}, + impossibleStrategicCommands: [], }, settings: { tnmt: 0, diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 32760799..8d6e45ba 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -261,6 +261,24 @@ const generalContext = (state: NavigationFixture) => ({ bill: 100, capitalCityId: 1, typeCode: 'che_유가', + typeName: '유가', + typePros: '농상↑ 민심↑', + typeCons: '쌀수입↓', + population: { cityCount: 2, current: 150_000, max: 620_500 }, + crew: { generalCount: 2, current: 500, max: 7_000 }, + power: 1_234, + taxRate: state.nationRate ?? 20, + strategicCommandLimit: 2, + diplomaticLimit: 0, + prohibitScout: false, + prohibitWar: true, + techLevel: 0, + techLimited: false, + topChiefs: { + 12: { id: 1, name: '군주', npcState: 0 }, + 11: { id: 2, name: '참모', npcState: 1 }, + }, + impossibleStrategicCommands: [{ name: '수몰', remainingTurns: 2, availableYear: 190, availableMonth: 5 }], }, settings: {}, penalties: {}, @@ -572,18 +590,20 @@ const persistArtifact = async (page: Page, name: string) => { executionStatus: describe('.execution-status'), tournamentStatus: describe('.tournament-status'), voteStatus: describe('.vote-status'), + autoRefresh: describe('[data-bottom-menu="auto-refresh"]'), + manualRefresh: describe('[data-bottom-menu="manual-refresh"]'), commandMenu: describe('.reserved-command-editor details[open] .menu-items'), - commandDividers: [...document.querySelectorAll('.reserved-command-editor details[open] .menu-divider')].map( - (element) => { - const rect = element.getBoundingClientRect(); - const style = getComputedStyle(element); - return { - rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, - borderTop: style.borderTop, - margin: style.margin, - }; - } - ), + commandDividers: [ + ...document.querySelectorAll('.reserved-command-editor details[open] .menu-divider'), + ].map((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + borderTop: style.borderTop, + margin: style.margin, + }; + }), }; }); const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first(); @@ -877,6 +897,31 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(0); await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4); + const nationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]'); + await expect(nationCard.locator('.head')).toHaveCount(17); + await expect(nationCard).toContainText('유가 (농상↑ 민심↑쌀수입↓)'); + await expect(nationCard).toContainText('영주군주참모ⓝ참모'); + await expect(nationCard).toContainText('총 주민150,000 / 620,500'); + await expect(nationCard).toContainText('총 병사500 / 7,000'); + await expect(nationCard).toContainText('지급률100%'); + await expect(nationCard).toContainText('전략2턴'); + await expect(nationCard).toContainText('임관허가'); + await expect(nationCard).toContainText('전쟁금지'); + expect(await nationCard.evaluate((element) => element.getBoundingClientRect().height)).toBe(193); + const nationRowHeights = await nationCard + .locator('.nation-grid') + .evaluate((element) => [...element.children].map((child) => child.getBoundingClientRect().height)); + expect(Math.max(...nationRowHeights) - Math.min(...nationRowHeights)).toBeLessThanOrEqual(0.01); + const strategicCell = nationCard.locator('.strategic'); + const strategicTooltip = strategicCell.getByRole('tooltip'); + await expect(strategicTooltip).toBeHidden(); + await strategicCell.hover(); + await expect(strategicTooltip).toBeVisible(); + await expect(strategicTooltip).toContainText('수몰: 2턴 뒤(190년 5월부터)'); + await strategicCell.focus(); + await expect(strategicCell).toBeFocused(); + await expect(strategicTooltip).toBeVisible(); + expect(await cityBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(9); expect(await statBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(12); expect(await experienceBar.evaluate((element) => element.getBoundingClientRect().height)).toBe(12); @@ -978,8 +1023,9 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn await modeButton.focus(); await expect(modeButton).toBeFocused(); await modeButton.click(); - const advancedControlGeometry = await page.locator('[data-main-target="commands"] .reserved-command-editor').evaluate( - (editor) => { + const advancedControlGeometry = await page + .locator('[data-main-target="commands"] .reserved-command-editor') + .evaluate((editor) => { const range = editor.querySelector('.range-menu'); const recent = [...editor.querySelectorAll('.control-pad summary')].find((element) => element.textContent?.includes('최근 실행') @@ -994,8 +1040,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn advancedBottom: advanced.getBoundingClientRect().bottom, queueTop: queue.getBoundingClientRect().top, }; - } - ); + }); expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop); expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop); expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop); @@ -1171,6 +1216,11 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500); await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8); await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4); + const mobileNationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]'); + expect(await mobileNationCard.evaluate((element) => element.getBoundingClientRect().height)).toBe(193); + expect(await mobileNationCard.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual( + 0 + ); expect( await page .locator('[data-main-target="city"] [role="progressbar"]') @@ -1375,12 +1425,7 @@ test('real mobile devices initially fit the complete 500px game canvas', async ( const routeGeometry: Record = {}; if (deviceWidth === 390) { - for (const target of [ - 'chief-center', - 'battle-center', - 'inherit', - 'nation-betting', - ]) { + for (const target of ['chief-center', 'battle-center', 'inherit', 'nation-betting']) { await mobilePage.goto(target); await expect .poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth)) @@ -1472,6 +1517,7 @@ test('mobile single document refreshes once and preserves tokens on lobby return generalMeCalls: 0, operations: [], }; + await installRealtimeHarness(page); await installFixture(page, state); await page.setViewportSize({ width: 500, height: 900 }); await waitForMain(page); @@ -1494,9 +1540,65 @@ test('mobile single document refreshes once and preserves tokens on lobby return await expect(page.locator(selector)).toBeVisible(); } + const autoRefresh = page.getByRole('button', { name: '자동 갱신 ON' }); + const manualRefresh = page.getByRole('button', { name: '직접 갱신' }); + await expect(autoRefresh).toHaveAttribute('aria-pressed', 'true'); + await expect(autoRefresh.locator('strong')).toHaveCSS('color', 'rgb(158, 240, 184)'); + await expect(manualRefresh).toHaveAttribute('aria-busy', 'false'); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .toBe(true); + + const refreshGeometry = await page.locator('.bottom-refresh-controls').evaluate((controls) => { + const auto = controls.querySelector('[data-bottom-menu="auto-refresh"]'); + const manual = controls.querySelector('[data-bottom-menu="manual-refresh"]'); + if (!auto || !manual) throw new Error('mobile refresh controls are incomplete'); + const controlsRect = controls.getBoundingClientRect(); + const autoRect = auto.getBoundingClientRect(); + const manualRect = manual.getBoundingClientRect(); + return { + controls: { left: controlsRect.left, right: controlsRect.right, width: controlsRect.width }, + auto: { left: autoRect.left, right: autoRect.right, width: autoRect.width }, + manual: { left: manualRect.left, right: manualRect.right, width: manualRect.width }, + overflow: controls.scrollWidth - controls.clientWidth, + }; + }); + expect(refreshGeometry.controls.width).toBe(125); + expect(refreshGeometry.auto.width).toBe(85); + expect(refreshGeometry.manual.width).toBe(40); + expect(refreshGeometry.auto.left).toBe(refreshGeometry.controls.left); + expect(refreshGeometry.auto.right).toBe(refreshGeometry.manual.left); + expect(refreshGeometry.manual.right).toBe(refreshGeometry.controls.right); + expect(refreshGeometry.overflow).toBeLessThanOrEqual(0); + + await autoRefresh.focus(); + await expect(autoRefresh).toBeFocused(); + await autoRefresh.hover(); + await expect(autoRefresh).toHaveCSS('filter', 'brightness(1.14)'); + await autoRefresh.click(); + const disabledAutoRefresh = page.getByRole('button', { name: '자동 갱신 OFF' }); + await expect(disabledAutoRefresh).toHaveAttribute('aria-pressed', 'false'); + await expect(disabledAutoRefresh.locator('strong')).toHaveCSS('color', 'rgb(187, 187, 187)'); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .toBe(false); + await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls-off`); + + state.generalName = '직접갱신된장수'; const callsBeforeRefresh = state.generalMeCalls; - await page.getByRole('button', { name: '갱 신' }).click(); + await manualRefresh.click(); await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh); + await expect(page.locator('.general-title')).toContainText('직접갱신된장수'); + + const callsBeforeEnable = state.generalMeCalls; + await page.getByRole('button', { name: '자동 갱신 OFF' }).click(); + await expect(page.getByRole('button', { name: '자동 갱신 ON' })).toHaveAttribute('aria-pressed', 'true'); + await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeEnable); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .toBe(true); + + await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls`); await page.evaluate(() => { localStorage.setItem('sammo-session-token', 'session_navigation'); @@ -1734,10 +1836,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl { op: 'replace', path: '/general/0/values/1/possible', value: false }, { op: 'replace', path: '/general/0/values/1/status', value: 'blocked' }, ]; - await emitReadModelInvalidation( - page, - readModelInvalidation({ context: true, commands: true, boardAccess: true }) - ); + await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true, boardAccess: true })); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1); const callsBeforeCityState = state.generalMeCalls; @@ -1764,10 +1863,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl state.contextRevision = 'O'.repeat(22); state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }]; state.commandTableOperations = []; - await emitReadModelInvalidation( - page, - readModelInvalidation({ context: true, commands: true, boardAccess: true }) - ); + await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true, boardAccess: true })); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeFallback + 2); expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1); await expect(page.locator('.general-title')).toContainText('snapshot복구장수'); diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index ceb0b626..c4a95cf8 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -171,7 +171,7 @@ test('nation generals restores Ref group, saved view, sort, and Korean search be await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); await page.getByLabel('장수명 필터').fill(''); - await page.getByLabel('통솔 필터').fill('>= 60'); + await page.getByLabel('통솔 필터').fill('70'); await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); await page.getByLabel('통솔 필터').fill(''); @@ -215,6 +215,91 @@ test('nation generals restores Ref group, saved view, sort, and Korean search be .not.toContain('내 보기'); }); +test('nation generals filter buttons open Ref operator menus and apply compound conditions', async ({ + page, +}, testInfo) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/generals'); + const table = page.locator('#nation-general-list'); + + const nameMenuButton = page.getByRole('button', { name: '장수명 상세 필터 열기' }); + await expect(nameMenuButton).toHaveAttribute('title', 'Open Filter Menu'); + await nameMenuButton.hover(); + expect(await nameMenuButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + await nameMenuButton.focus(); + await expect(nameMenuButton).toBeFocused(); + expect(await nameMenuButton.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('solid'); + await nameMenuButton.click(); + + const namePopup = page.getByRole('dialog', { name: '장수명 상세 필터' }); + await expect(namePopup).toBeVisible(); + expect((await namePopup.boundingBox())?.width).toBe(190); + expect(await namePopup.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(45, 52, 54)'); + const nameOperator = page.getByLabel('장수명 첫 번째 필터 연산자'); + expect(await nameOperator.locator('option').allTextContents()).toEqual([ + 'Contains', + 'Not contains', + 'Equals', + 'Not equal', + 'Starts with', + 'Ends with', + 'Blank', + 'Not blank', + ]); + await nameOperator.selectOption('notContains'); + await page.getByLabel('장수명 첫 번째 필터 값').fill('테스트'); + await expect(page.getByRole('searchbox', { name: '장수명 필터', exact: true })).toHaveValue('테스트'); + await expect(table.locator('tr[data-general-id="1"]')).toHaveCount(0); + await expect(table.locator('tr[data-general-id="2"]')).toBeVisible(); + + await nameOperator.selectOption('contains'); + await page.getByLabel('장수명 첫 번째 필터 값').fill('장수'); + await page.getByLabel('장수명 두 번째 필터 연산자').selectOption('notContains'); + await page.getByLabel('장수명 두 번째 필터 값').fill('테스트'); + await expect(table.locator('tr[data-general-id="1"]')).toHaveCount(0); + await expect(table.locator('tr[data-general-id="2"]')).toBeVisible(); + await namePopup.getByLabel('OR').check(); + await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); + await expect(table.locator('tr[data-general-id="2"]')).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('core-text-filter-menu.png'), fullPage: true }); + + await page.getByLabel('장수명 두 번째 필터 값').fill(''); + await page.getByLabel('장수명 첫 번째 필터 값').fill(''); + await page.getByRole('button', { name: '통솔 상세 필터 열기' }).click(); + const numberPopup = page.getByRole('dialog', { name: '통솔 상세 필터' }); + const numberOperator = page.getByLabel('통솔 첫 번째 필터 연산자'); + expect(await numberOperator.locator('option').allTextContents()).toEqual([ + 'Equals', + 'Not equal', + 'Less than', + 'Less than or equals', + 'Greater than', + 'Greater than or equals', + 'In range', + 'Blank', + 'Not blank', + ]); + await numberOperator.selectOption('inRange'); + await page.getByLabel('통솔 첫 번째 필터 값').fill('45'); + await page.getByLabel('통솔 첫 번째 필터 끝값').fill('75'); + await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); + await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath('core-number-filter-menu.png'), fullPage: true }); + await numberOperator.selectOption('blank'); + await expect(table.locator('tr[data-general-id]')).toHaveCount(0); + await expect(numberPopup.getByPlaceholder('Filter...')).toHaveCount(1); + await page.keyboard.press('Escape'); + await expect(numberPopup).toHaveCount(0); + + await page.setViewportSize({ width: 500, height: 900 }); + expect(await page.locator('.general-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(1000); + await nameMenuButton.click(); + await expect(namePopup).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); + await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true }); +}); + test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => { await install(page); await page.setViewportSize({ width: 500, height: 900 }); diff --git a/app/game-frontend/src/components/main/MainMobileBottomBar.vue b/app/game-frontend/src/components/main/MainMobileBottomBar.vue index e607497d..f361704f 100644 --- a/app/game-frontend/src/components/main/MainMobileBottomBar.vue +++ b/app/game-frontend/src/components/main/MainMobileBottomBar.vue @@ -18,10 +18,13 @@ const props = defineProps<{ tournamentStage: number; nationColor: string; npcMode: number; + realtimeEnabled: boolean; + refreshing: boolean; }>(); const emit = defineEmits<{ refresh: []; + toggleRealtime: []; lobby: []; quick: [item: QuickNavigationItem]; }>(); @@ -189,14 +192,31 @@ const onQuick = (item: QuickNavigationItem) => { - +
+ + +
@@ -221,6 +241,17 @@ const onQuick = (item: QuickNavigationItem) => { position: relative; } +.bottom-refresh-controls { + display: grid; + width: 125px; + height: 45px; + grid-template-columns: minmax(0, 1fr) 40px; +} + +.bottom-refresh-controls > .bottom-trigger { + width: auto; +} + .bottom-trigger { box-sizing: border-box; width: 125px; @@ -245,6 +276,43 @@ const onQuick = (item: QuickNavigationItem) => { background: #212529; } +.auto-refresh-trigger { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2px 1px; + font-size: 12px; + line-height: 1.15; +} + +.auto-refresh-trigger strong { + color: #bbb; + font-size: 11px; + line-height: 1; +} + +.auto-refresh-trigger.active { + background-color: #164f2c; +} + +.auto-refresh-trigger.active strong { + color: #9ef0b8; +} + +.manual-refresh-trigger { + padding: 0; + background: #212529; + font-size: 22px; + line-height: 1; +} + +.manual-refresh-trigger:disabled { + cursor: wait; + filter: grayscale(0.6); + opacity: 0.55; +} + .bottom-trigger:hover, .bottom-trigger:focus-visible, .bottom-trigger[aria-expanded='true'] { diff --git a/app/game-frontend/src/components/main/NationBasicCard.vue b/app/game-frontend/src/components/main/NationBasicCard.vue index d94cddcc..6af4b379 100644 --- a/app/game-frontend/src/components/main/NationBasicCard.vue +++ b/app/game-frontend/src/components/main/NationBasicCard.vue @@ -1,86 +1,296 @@