diff --git a/app/game-api/src/maps/worldMap.ts b/app/game-api/src/maps/worldMap.ts index 18289014..9bd65a3a 100644 --- a/app/game-api/src/maps/worldMap.ts +++ b/app/game-api/src/maps/worldMap.ts @@ -10,6 +10,11 @@ export type BaseMapResult = { startYear: number; year: number; month: number; + techLevelLimit: { + maxLevel: number; + initialLevel: number; + increaseYears: number; + }; cityList: MapCityCompact[]; nationList: MapNationCompact[]; }; @@ -64,6 +69,23 @@ const readState = (meta: Record): number => { return 0; }; +const readPositiveInteger = (value: unknown, fallback: number): number => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + const normalized = Math.floor(value); + return normalized > 0 ? normalized : fallback; +}; + +const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLevelLimit'] => { + const constValues = asRecord(asRecord(worldState.config).const); + return { + maxLevel: readPositiveInteger(constValues.maxTechLevel, 12), + initialLevel: readPositiveInteger(constValues.initialAllowedTechLevel, 1), + increaseYears: readPositiveInteger(constValues.techLevelIncYear, 5), + }; +}; + const normalizeNumberRecord = (value: unknown): Record => { if (!isRecord(value)) { return {}; @@ -180,6 +202,7 @@ const loadBaseMap = async ( startYear: resolveStartYear(worldState), year: worldState.currentYear, month: worldState.currentMonth, + techLevelLimit: resolveTechLevelLimit(worldState), cityList, nationList, }; diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index e5633a18..0ca59a00 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -18,7 +18,7 @@ import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTranspor import { resolveAccessWindows } from '../../services/generalAccess.js'; import { adjustAccountIconForUser } from '../../services/accountIconSync.js'; import { getMyGeneral } from '../shared/general.js'; -import { resolveNationNotice } from '../nation/shared.js'; +import { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js'; const zGeneralSettings = z.object({ tnmt: z.number().int().optional(), @@ -34,6 +34,17 @@ const zImmediateActionInput = z }) .optional(); const MAIN_RECORD_LIMIT = 15; +const NEUTRAL_NATION_CONTEXT = { + id: 0, + name: '재야', + color: '#000000', + level: 0, + gold: 0, + rice: 0, + tech: 0, + typeCode: 'None', + capitalCityId: null, +} as const; const resolveImmediateActionRequestId = ( contextRequestId: string | undefined, @@ -135,6 +146,18 @@ const normalizeItemCode = (value: string | null): string | null => { return value; }; +const resolveTraitDisplayName = (code: string, names: TraitNameMap): string => { + if (!code || code === 'None') { + return '-'; + } + const loadedName = names.get(code)?.name; + if (loadedName) { + return loadedName; + } + // Ref는 class getName()을 표시하므로 로더가 모르는 선택적 특기도 raw namespace는 노출하지 않는다. + return code.replace(/^che_(?:event_)?/u, ''); +}; + const resolveUserSettings = (meta: Record) => { // The legacy general columns are persisted at the top level of General.meta. // Keep reading the short-lived nested shape for installations that ran the @@ -265,10 +288,16 @@ export const getGeneralContext = async (ctx: GameApiContext) => { capitalCityId: true, }, }) - : null, + : Promise.resolve(NEUTRAL_NATION_CONTEXT), ctx.db.worldState.findFirst({ select: { config: true } }), ]); + const [personalityNames, domesticNames, warNames] = await Promise.all([ + loadTraitNames([general.personalCode], 'personality'), + loadTraitNames([general.specialCode], 'domestic'), + loadTraitNames([general.special2Code], 'war'), + ]); + const metaRecord = asRecord(general.meta); const worldConfig = asRecord(worldState?.config); const constValues = asRecord(worldConfig.const ?? worldConfig.consts); @@ -303,9 +332,9 @@ export const getGeneralContext = async (ctx: GameApiContext) => { turnTime: general.turnTime.toISOString(), crewTypeId: general.crewTypeId, traits: { - personal: general.personalCode, - specialWar: general.specialCode, - specialDomestic: general.special2Code, + personal: resolveTraitDisplayName(general.personalCode, personalityNames), + specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames), + specialWar: resolveTraitDisplayName(general.special2Code, warNames), }, progression: { experienceLevel: readNumber(metaRecord.explevel, 0), diff --git a/app/game-api/src/turns/reservedTurns.ts b/app/game-api/src/turns/reservedTurns.ts index ffb44394..4487743e 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -19,6 +19,7 @@ export interface ReservedTurnView { export interface ReservedTurnSnapshot { revision: number; turns: ReservedTurnView[]; + autorunLimit?: number | null; } export interface ReservedTurnUpdate { @@ -170,13 +171,19 @@ export const listGeneralTurns = async (db: DatabaseClient, generalId: number): P }; export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise => { - const [turns, revisionRow] = await Promise.all([ + const [turns, revisionRow, general] = await Promise.all([ loadGeneralTurns(db, generalId), db.generalTurnRevision.findUnique({ where: { generalId } }), + db.general.findUnique({ where: { id: generalId }, select: { meta: true } }), ]); + const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined; return { revision: revisionRow?.revision ?? 0, turns: serializeTurnList(turns), + autorunLimit: + typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit) + ? Math.trunc(rawAutorunLimit) + : null, }; }; diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index a8f79a25..77c9dcd6 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -248,6 +248,40 @@ describe('in-game my information ownership', () => { ); }); + it('returns the Ref-style neutral nation frame and trait display names on the main read model', async () => { + const fixture = createContext({ + me: buildGeneral({ + nationId: 0, + officerLevel: 0, + personalCode: 'che_안전', + specialCode: 'che_상재', + special2Code: 'che_신산', + }), + }); + + await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ + general: { + traits: { + personal: '안전', + specialDomestic: '상재', + specialWar: '신산', + }, + }, + nation: { + id: 0, + name: '재야', + color: '#000000', + level: 0, + gold: 0, + rice: 0, + tech: 0, + typeCode: 'None', + capitalCityId: null, + }, + }); + expect(fixture.db.nation.findUnique).not.toHaveBeenCalled(); + }); + it('reads legacy top-level settings and dispatches only the session-owned general', async () => { const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 })); const fixture = createContext({ requestCommand }); diff --git a/app/game-api/test/publicCachedMapHistory.test.ts b/app/game-api/test/publicCachedMapHistory.test.ts index fce501a4..8fc17e45 100644 --- a/app/game-api/test/publicCachedMapHistory.test.ts +++ b/app/game-api/test/publicCachedMapHistory.test.ts @@ -26,7 +26,13 @@ const buildContext = () => { findFirst: vi.fn(async () => ({ currentYear: 190, currentMonth: 3, - config: {}, + config: { + const: { + maxTechLevel: 10, + initialAllowedTechLevel: 2, + techLevelIncYear: 4, + }, + }, meta: { scenarioMeta: { startYear: 184 } }, })), }, @@ -38,12 +44,8 @@ const buildContext = () => { }, $queryRaw: vi .fn() - .mockResolvedValueOnce([ - { id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } }, - ]) - .mockResolvedValueOnce([ - { id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} }, - ]), + .mockResolvedValueOnce([{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } }]) + .mockResolvedValueOnce([{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} }]), }; const context: GameApiContext = { db: db as unknown as DatabaseClient, @@ -70,6 +72,11 @@ describe('public.getCachedMap', () => { expect(result).toMatchObject({ year: 190, month: 3, + techLevelLimit: { + maxLevel: 10, + initialLevel: 2, + increaseYears: 4, + }, history: [ { id: 9, text: '최근 정세' }, { id: 8, text: '이전 정세' }, diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index e0b80bca..32dd49be 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -824,7 +824,7 @@ describe('appRouter', () => { }); it('returns reserved general turns', async () => { - const general = buildGeneralRow({ id: 11 }); + const general = buildGeneralRow({ id: 11, meta: { autorun_limit: 2408 } }); const generalTurns: GeneralTurnRow[] = [ { id: 1, @@ -839,6 +839,7 @@ describe('appRouter', () => { const response = await caller.turns.reserved.getGeneral({ generalId: 11 }); expect(response.revision).toBe(0); + expect(response.autorunLimit).toBe(2408); expect(response.turns[0]?.action).toBe('che_화계'); expect(response.turns[0]?.index).toBe(0); }); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 8d0cfd0c..a4d3dec3 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -71,6 +71,7 @@ const map = { startYear: 180, year: 200, month: 1, + techLevelLimit: { maxLevel: 12, initialLevel: 1, increaseYears: 5 }, cityList: castleFixtures.map(({ id, level }) => [id, level, 0, 1, 1, 1]), nationList: [[1, '아국', '#008000', 1]], spyList: {}, @@ -130,7 +131,13 @@ const emptyMessages = { canRespondDiplomacy: false, }; -const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member', trade: number | null = 100) => { +const install = async ( + page: Page, + mode: 'member' | 'wanderer' | 'admin' = 'member', + trade: number | null = 100, + globalNationCount = 2, + mapFixture = map +) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_info'); localStorage.setItem('sammo-game-profile', profile); @@ -143,13 +150,22 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb body: await readImage(relativePath), }); }); + await page.route('**/game/**', async (route) => { + const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/game/')[1] ?? ''); + const fixturePath = `game/${relativePath}`; + await route.fulfill({ + status: 200, + contentType: imageContentType(fixturePath), + body: await readImage(fixturePath), + }); + }); await page.route(`**${gameBasePath}/api/trpc/**`, 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: '장수' } }); if (operation === 'join.getConfig') return response({}); if (operation === 'general.me') return response(generalContext); - if (operation === 'world.getMap') return response(map); + if (operation === 'world.getMap') return response(mapFixture); if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { return response({ turns: [], revision: 0 }); @@ -250,10 +266,10 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb generalCount: 1, cities: ['허창'], }, - ], + ].slice(0, globalNationCount), diplomacy: { 1: { 1: 2, 2: 0 }, 2: { 1: 0, 2: 2 } }, conflict: [], - map, + map: mapFixture, }); if (operation === 'world.getMapLayout') return response(layout); if (operation === 'world.getCurrentCity') @@ -375,6 +391,86 @@ test('global-info renders the ref nation summary columns beside the map', async await page.setViewportSize({ width: 1200, height: 900 }); await go(page, 'global-info'); + const mapTitle = page.locator('.map-title'); + await expect(mapTitle).toHaveText(/200年 1月/u); + const titleBackground = await page + .locator('.map-top') + .evaluate((element) => getComputedStyle(element).backgroundImage); + expect(titleBackground).toContain('ltitle.jpg'); + expect(titleBackground).toContain('rtitle.jpg'); + const titleTextBackground = await mapTitle.evaluate((element) => getComputedStyle(element).backgroundImage); + expect(titleTextBackground).toContain('ad.gif'); + expect(titleTextBackground).toContain('spring.gif'); + await mapTitle.hover(); + const titleTooltip = page.locator('.map-title-tooltip'); + await expect(titleTooltip).toBeVisible(); + await expect(titleTooltip).toContainText('기술등급 제한 : 5등급 (205년 해제)'); + const titleGeometry = await page.locator('.map-top').evaluate((element) => { + const band = element.getBoundingClientRect(); + const title = element.querySelector('.map-title')?.getBoundingClientRect(); + const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect(); + return { + band: { width: band.width, height: band.height }, + title: title ? { width: title.width, height: title.height } : null, + tooltip: tooltip ? { width: tooltip.width, height: tooltip.height } : null, + }; + }); + expect(titleGeometry).toEqual({ + band: { width: 700, height: 20 }, + title: { width: 160, height: 20 }, + tooltip: { width: 220, height: 28 }, + }); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-title-hover.png'), fullPage: true }); + } + + const hoveredCastle = page.locator('.city-base').first(); + await hoveredCastle.hover(); + const cityTooltip = page.locator('.map-tooltip'); + await expect(cityTooltip).toBeVisible(); + await expect(cityTooltip.locator('.tooltip-title')).toHaveText('【하북|특】업'); + await expect(cityTooltip.locator('.tooltip-body')).toHaveText('아국'); + const cityTooltipStyle = await cityTooltip.evaluate((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { + width: rect.width, + backgroundColor: style.backgroundColor, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }; + }); + expect(cityTooltipStyle.width).toBeGreaterThanOrEqual(120); + expect(cityTooltipStyle).toMatchObject({ + backgroundColor: 'rgb(30, 164, 255)', + fontSize: '14px', + lineHeight: '15px', + }); + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-city-hover.png'), fullPage: true }); + } + + await expect + .poll(async () => { + const [matrixWrapBox, matrixBox] = await Promise.all([ + page.locator('.matrix-wrap').boundingBox(), + page.locator('.matrix').boundingBox(), + ]); + if (!matrixWrapBox || !matrixBox) return null; + return Math.abs(matrixWrapBox.height - matrixBox.height); + }) + .toBeLessThan(1); + + await expect + .poll(() => + page + .locator('.map-area .city-icon') + .evaluateAll((images: HTMLImageElement[]) => + images.every((image) => image.complete && image.naturalWidth > 0) + ) + ) + .toBe(true); const castleGeometry = await page.locator('.map-area').evaluate((mapArea) => { const mapRect = mapArea.getBoundingClientRect(); return Array.from(mapArea.querySelectorAll('.city-icon')).map((image) => { @@ -437,6 +533,10 @@ test('global-info renders the ref nation summary columns beside the map', async resolve(artifactRoot, 'core-global-info-computed-dom.json'), `${JSON.stringify( { + titleBackground, + titleTextBackground, + titleGeometry, + cityTooltipStyle, geometry, castleGeometry, headings: await summary.locator('th').allTextContents(), @@ -489,11 +589,101 @@ test('global-info renders the ref nation summary columns beside the map', async expect(mobileGeometry.map?.width).toBe(500); expect(mobileGeometry.summary?.width).toBe(500); expect(mobileGeometry.summary?.y).toBe(mobileGeometry.map?.bottom); + await mapTitle.hover(); + await expect(titleTooltip).toBeVisible(); + const mobileTitleGeometry = await page.locator('.map-top').evaluate((element) => { + const band = element.getBoundingClientRect(); + const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect(); + return { + band: { width: band.width, height: band.height }, + tooltip: tooltip ? { x: tooltip.x, width: tooltip.width } : null, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(mobileTitleGeometry.band).toEqual({ width: 500, height: 20 }); + expect(mobileTitleGeometry.tooltip?.width).toBe(220); + expect(mobileTitleGeometry.documentWidth).toBe(500); + await hoveredCastle.hover(); + await expect(cityTooltip).toBeVisible(); + await expect(cityTooltip.locator('.tooltip-title')).toHaveText('【하북|특】업'); if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'core-global-info-mobile-city-hover.png'), + fullPage: true, + }); await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-mobile.png'), fullPage: true }); } }); +test('map title keeps the ref early-game restriction boundary and color', async ({ page }) => { + await install(page, 'member', 100, 2, { ...map, startYear: 198 }); + await page.setViewportSize({ width: 1200, height: 900 }); + await go(page, 'global-info'); + + const mapTitle = page.locator('.map-title'); + await expect(mapTitle).toHaveCSS('color', 'rgb(255, 255, 0)'); + await mapTitle.hover(); + await expect(page.locator('.map-title-tooltip')).toHaveText( + '초반제한 기간 : 0년 12개월 (201년)기술등급 제한 : 1등급 (203년 해제)' + ); +}); + +test('global-info diplomacy height follows the active nation count', async ({ page }) => { + await install(page, 'member', 100, 1); + await go(page, 'global-info'); + + const matrixWrap = page.locator('.matrix-wrap'); + const matrix = page.locator('.matrix'); + const mapSection = page.locator('.map-section'); + await expect(matrix.locator('tbody tr')).toHaveCount(1); + + for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 390, height: 844 }, + ]) { + await page.setViewportSize(viewport); + await expect + .poll(async () => { + const [wrapBox, matrixBox] = await Promise.all([matrixWrap.boundingBox(), matrix.boundingBox()]); + if (!wrapBox || !matrixBox) return null; + return Math.abs(wrapBox.height - matrixBox.height); + }) + .toBeLessThan(1); + + const geometry = await page.evaluate(() => { + const rect = (selector: string) => document.querySelector(selector)?.getBoundingClientRect(); + const diplomacy = rect('.section'); + const matrix = rect('.matrix'); + const matrixWrap = rect('.matrix-wrap'); + const mapSection = rect('.map-section'); + return { + diplomacyHeight: diplomacy?.height ?? null, + matrixHeight: matrix?.height ?? null, + matrixWrapHeight: matrixWrap?.height ?? null, + gapToMap: matrixWrap && mapSection ? mapSection.top - matrixWrap.bottom : null, + }; + }); + expect(geometry.matrixHeight).not.toBeNull(); + expect(geometry.matrixWrapHeight).toBeCloseTo(geometry.matrixHeight!, 0); + expect(geometry.diplomacyHeight).toBeLessThan(200); + expect(geometry.gapToMap).toBe(21); + await expect(mapSection).toBeInViewport(); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await writeFile( + resolve(artifactRoot, `core-global-info-one-nation-${viewport.name}.json`), + `${JSON.stringify(geometry, null, 2)}\n`, + 'utf8' + ); + await page.screenshot({ + path: resolve(artifactRoot, `core-global-info-one-nation-${viewport.name}.png`), + fullPage: true, + }); + } + } +}); + test('current-city hides values and general rows for a wandering user', async ({ page }) => { await install(page, 'wanderer'); await go(page, 'current-city'); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 9992ea83..33ceb619 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -45,6 +45,7 @@ type FixtureState = { adjustIconInputs?: Array>; joinConfig?: Record; createGeneralInputs?: Array>; + mainTraits?: { personal: string; specialDomestic: string; specialWar: string }; }; type TrpcRequestPayload = { @@ -75,7 +76,7 @@ const myGeneral = (state: FixtureState) => ({ age: 30, turnTime: '2026-01-01 00:10:00', crewTypeId: 1, - traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' }, + traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' }, progression: { experienceLevel: 1, dedicationLevel: 2, @@ -86,7 +87,19 @@ const myGeneral = (state: FixtureState) => ({ items: { horse: 'che_명마', weapon: null, book: null, item: null }, }, city: { id: 1, name: '업', level: 8, nationId: 1 }, - nation: { id: 1, name: '위', color: '#777777', level: 3 }, + nation: state.buildNationCandidateEnabled + ? { + id: 0, + name: '재야', + color: '#000000', + level: 0, + gold: 0, + rice: 0, + tech: 0, + typeCode: 'None', + capitalCityId: null, + } + : { id: 1, name: '위', color: '#777777', level: 3 }, settings: { tnmt: 0, defence_train: 80, @@ -221,6 +234,25 @@ const install = async (page: Page, state: FixtureState) => { state.createGeneralInputs?.push(jsonInput); return response({ generalId: 9 }); } + if (operation === 'dashboard.getContextBundleDelta') { + return response({ + context: { + kind: 'snapshot', + revision: 'AAAAAAAAAAAAAAAAAAAAAA', + data: myGeneral(state), + }, + commandTable: { + kind: 'snapshot', + revision: 'BBBBBBBBBBBBBBBBBBBBBB', + data: { general: [], nation: [] }, + }, + boardAccess: { + kind: 'snapshot', + revision: 'CCCCCCCCCCCCCCCCCCCCCC', + data: { permission: 4, canMeeting: true, canSecret: true }, + }, + }); + } if (operation === 'general.me') { state.generalMeQueries = (state.generalMeQueries ?? 0) + 1; return response(myGeneral(state)); @@ -437,6 +469,49 @@ test('정화된 국가 방침은 실행 가능한 속성 없이 Chromium에 표 .toBeUndefined(); }); +test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표시한다', async ({ page }) => { + const state: FixtureState = { + permission: 'member', + myset: 0, + buildNationCandidateEnabled: true, + mainTraits: { personal: '안전', specialDomestic: '상재', specialWar: '신산' }, + settingMutations: [], + accessPages: [], + }; + await install(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto(''); + + const nationCard = page.locator('.nation-card'); + await expect(nationCard.locator('.title')).toHaveText('재야'); + await expect(nationCard.locator('.empty')).toHaveCount(0); + await expect(nationCard.locator('.grid strong')).toHaveText(Array.from({ length: 6 }, () => '해당 없음')); + + const generalCard = page.locator('.general-card'); + await expect(generalCard).toContainText('성격안전'); + await expect(generalCard).toContainText('전투특기신산'); + await expect(generalCard).toContainText('내정특기상재'); + await expect(generalCard).not.toContainText('che_'); + + const geometry = await nationCard.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const title = element.querySelector('.title')!; + return { + width: rect.width, + height: rect.height, + titleBackground: getComputedStyle(title).backgroundColor, + placeholderCount: [...element.querySelectorAll('.grid strong')].filter( + (cell) => cell.textContent?.trim() === '해당 없음' + ).length, + }; + }); + expect(geometry.width).toBeGreaterThan(0); + expect(geometry.height).toBeGreaterThan(0); + expect(geometry.titleBackground).toBe('rgb(0, 0, 0)'); + expect(geometry.placeholderCount).toBe(6); + await persistParityArtifact(page, 'main-neutral-trait-display', geometry); +}); + test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => { const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] }; await install(page, state); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index fb10f760..7000a1fa 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -32,6 +32,9 @@ type NavigationFixture = { refreshDelayMs?: number; largeCommandTable?: boolean; reservedTurns?: Array<{ index: number; action: string; args: Record }>; + messages?: unknown; + messageContacts?: unknown; + autorunLimit?: number | null; dashboardResponses?: Array<{ bytes: number; contextKind: string | null; @@ -403,10 +406,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => { } if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { - return response({ turns: state.reservedTurns ?? [], revision: 0 }); + return response({ + turns: state.reservedTurns ?? [], + revision: 0, + autorunLimit: state.autorunLimit ?? null, + }); } - if (operation === 'messages.getRecent') return response(emptyMessages(state.permission)); - if (operation === 'messages.getContacts') return response({ nation: [] }); + if (operation === 'messages.getRecent') { + return response(state.messages ?? emptyMessages(state.permission)); + } + if (operation === 'messages.getContacts') return response(state.messageContacts ?? { nation: [] }); if (operation === 'general.getRecentRecords') { return response({ global: [{ id: 3, text: '장수 동향 기록' }], @@ -617,7 +626,23 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/); const gameInfoButton = global.locator('[data-menu-id="game-info"]'); + const bettingButton = global.locator('[data-navigation-id="nation-betting"]'); + await expect + .poll(() => + bettingButton.evaluate((element) => ({ + backgroundColor: getComputedStyle(element).backgroundColor, + backgroundImage: getComputedStyle(element).backgroundImage, + })) + ) + .toEqual({ backgroundColor: 'rgb(0, 88, 44)', backgroundImage: 'none' }); + await bettingButton.hover(); + await expect + .poll(() => bettingButton.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe('rgb(0, 88, 44)'); await gameInfoButton.focus(); + await expect + .poll(() => gameInfoButton.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe('rgb(0, 88, 44)'); await gameInfoButton.press('Enter'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true'); await expect(global.locator('#global-menu-game-info')).toBeVisible(); @@ -631,6 +656,74 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); +test('pure NPC message senders are not rendered as reply targets', async ({ page }) => { + const target = (generalId: number, generalName: string) => ({ + generalId, + generalName, + nationId: 1, + nationName: '위', + color: '#008000', + icon: '', + }); + const messages = { + ...emptyMessages(0), + public: [ + { + id: 102, + text: 'NPC 메시지', + time: '2026-08-12 12:00:00', + msgType: 'public', + src: target(22, '순수NPC'), + dest: null, + option: {}, + }, + { + id: 101, + text: '유저 메시지', + time: '2026-08-12 11:59:00', + msgType: 'public', + src: target(21, '유저장수'), + dest: null, + option: {}, + }, + ], + }; + const state: NavigationFixture = { + officerLevel: 1, + permission: 0, + nationLevel: 1, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + messages, + messageContacts: { + nation: [ + { + nationId: 1, + mailbox: 9001, + name: '위', + color: '#008000', + general: [[21, '유저장수', 0]], + }, + ], + }, + }; + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + + const npcMessage = page.locator('.desktop-message-panel .msg-plate[data-id="102"]'); + const userMessage = page.locator('.desktop-message-panel .msg-plate[data-id="101"]'); + await expect(npcMessage.locator('.msg-header')).toContainText('순수NPC:위'); + await expect(npcMessage.locator('.msg-header')).not.toContainText('↩'); + await expect(npcMessage.getByRole('button', { name: /순수NPC/ })).toHaveCount(0); + await expect(userMessage.getByRole('button', { name: /유저장수:위.*↩/ })).toBeVisible(); + await userMessage.getByRole('button', { name: /유저장수:위.*↩/ }).click(); + await expect(page.locator('.desktop-message-panel #mailbox_list')).toHaveValue('21'); + await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`); +}); + test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, @@ -641,11 +734,12 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn generalMeCalls: 0, operations: [], largeCommandTable: true, - reservedTurns: Array.from({ length: 14 }, (_, index) => ({ + reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, - action: `command-${index}`, + action: index === 0 ? '휴식' : `command-${index}`, args: {}, })), + autorunLimit: 2224, }; await installFixture(page, state); await page.setViewportSize({ width: 1200, height: 900 }); @@ -728,6 +822,27 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn expect(new Set(desktopGeometry.visibleControlBoxes.map(({ y }) => y)).size).toBe(1); expect(desktopGeometry.bottomActionBoxes).toHaveLength(3); expect(new Set(desktopGeometry.bottomActionBoxes.map(({ y }) => y)).size).toBe(1); + await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(15); + const autonomousRest = page.locator('[data-main-target="commands"] .action-column > div').first(); + await expect(autonomousRest).toContainText('휴식(자율 행동)'); + expect(await autonomousRest.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)'); + + const tenthTurnButton = page.getByRole('button', { name: '10턴 명령 입력' }); + await tenthTurnButton.click(); + const quickPicker = page.getByTestId('command-picker'); + await expect(quickPicker).toBeVisible(); + const quickPickerAlignment = await quickPicker.evaluate((element) => { + const row = element + .closest('.reserved-command-editor') + ?.querySelector('.action-column > div:nth-child(10)'); + if (!row) throw new Error('10th command row missing'); + return { + pickerTop: element.getBoundingClientRect().top, + rowTop: row.getBoundingClientRect().top, + }; + }); + expect(quickPickerAlignment.pickerTop - quickPickerAlignment.rowTop).toBeCloseTo(30, 0); + await quickPicker.getByRole('button', { name: '명령 입력 닫기' }).click(); const modeButton = page.locator('[data-main-target="commands"] .control-pad').getByRole('button', { name: '고급 모드', @@ -736,6 +851,27 @@ 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 range = editor.querySelector('.range-menu'); + const recent = [...editor.querySelectorAll('.control-pad summary')].find((element) => + element.textContent?.includes('최근 실행') + ); + const advanced = editor.querySelector('.advanced-actions'); + const queue = editor.querySelector('.queue-grid'); + if (!range || !recent || !advanced || !queue) throw new Error('advanced command controls missing'); + return { + rangeTop: range.getBoundingClientRect().top, + recentTop: recent.getBoundingClientRect().top, + advancedTop: advanced.getBoundingClientRect().top, + 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); await page.locator('[data-main-target="commands"] .select-command').click(); const picker = page.getByTestId('command-picker'); await expect(picker).toBeVisible(); @@ -765,6 +901,32 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn expect(pickerGeometry.horizontalOverflow).toBeLessThanOrEqual(0); await picker.getByRole('button', { name: '명령 입력 닫기' }).click(); + await page.locator('[data-main-target="commands"] .control-pad').getByRole('button', { name: '일반 모드' }).click(); + const collapsedPanelHeight = await page + .locator('[data-main-target="commands"]') + .evaluate((element) => element.getBoundingClientRect().height); + await page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }).click(); + await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30); + const expandedDesktopGeometry = await page.locator('.layout-desktop').evaluate((layout) => { + const commands = layout.querySelector('[data-main-target="commands"]'); + const city = layout.querySelector('[data-main-target="city"]'); + const nation = layout.querySelector('[data-main-target="nation"]'); + if (!commands || !city || !nation) throw new Error('expanded desktop panels missing'); + return { + commandHeight: commands.getBoundingClientRect().height, + commandBottom: commands.getBoundingClientRect().bottom, + cityBottom: city.getBoundingClientRect().bottom, + nationTop: nation.getBoundingClientRect().top, + verticalOverflow: commands.scrollHeight - commands.clientHeight, + overflowY: getComputedStyle(commands).overflowY, + }; + }); + expect(expandedDesktopGeometry.commandHeight).toBeGreaterThan(collapsedPanelHeight); + expect(expandedDesktopGeometry.verticalOverflow).toBeLessThanOrEqual(0); + expect(expandedDesktopGeometry.overflowY).toBe('visible'); + expect(expandedDesktopGeometry.cityBottom).toBe(expandedDesktopGeometry.commandBottom); + expect(expandedDesktopGeometry.nationTop).toBeGreaterThanOrEqual(expandedDesktopGeometry.commandBottom); + const captureProgress = async (name: string) => { if (!artifactRoot) return; await mkdir(artifactRoot, { recursive: true }); @@ -852,9 +1014,9 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn }; await captureProgress('desktop-1200'); - await page.locator('[data-main-target="commands"] .control-pad').getByRole('button', { name: '일반 모드' }).click(); await page.setViewportSize({ width: 500, height: 900 }); await expect(page.locator('.layout-mobile')).toBeVisible(); + await page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }).click(); 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); @@ -889,7 +1051,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn controlBoxes, }; }); - expect(mobileGeometry.panelHeight).toBe(645); + expect(mobileGeometry.panelHeight).toBeGreaterThan(645); expect(mobileGeometry.editorLeft).toBeGreaterThanOrEqual(mobileGeometry.panelLeft); expect(mobileGeometry.editorRight).toBeLessThanOrEqual(mobileGeometry.panelRight); expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0); @@ -897,6 +1059,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn expect(mobileGeometry.controlColumns.split(' ')).toHaveLength(3); expect(mobileGeometry.controlBoxes).toHaveLength(3); expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1); + await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30); await captureProgress('mobile-500'); }); @@ -925,6 +1088,15 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy await expect(page.locator('.main-mobile-bottom')).toBeVisible(); await page.setViewportSize({ width: 500, height: 900 }); + await expect + .poll(() => + page + .locator('.main-global-menu') + .first() + .locator('[data-navigation-id="nation-betting"]') + .evaluate((element) => getComputedStyle(element).backgroundColor) + ) + .toBe('rgb(0, 88, 44)'); const documentGeometry = await page.locator('.main-page').evaluate((element) => { const rect = element.getBoundingClientRect(); return { diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 54db3422..44f462de 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -50,6 +50,7 @@ const commandArgsValid = ref(false); const expanded = ref(false); const menuRevision = ref(0); const pendingReservation = ref(null); +const collapsedRowCount = 15; const loadStorage = (key: string) => { storage.value = new CommandStorage(key); @@ -108,7 +109,11 @@ const labelMap = computed(() => { for (const group of groups) for (const command of group.values) map.set(command.key, command.name); return map; }); -const displayRows = computed(() => props.rows.slice(0, expanded.value || props.compact ? props.rows.length : 14)); +const displayRows = computed(() => + props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount) +); +const quickPickerTop = computed(() => `${70 + (quickTarget.value ?? 0) * 34.4}px`); +const rowLabel = (row: ReservedCommandRow): string => row.label ?? labelMap.value.get(row.action) ?? row.action; const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length); const pattern = () => extractPattern(props.rows, selectedIndices()); const touchMenus = () => (menuRevision.value += 1); @@ -396,6 +401,87 @@ const clickOutsideMenu = (event: Event) => { +
+
+ 선택한 턴을 + +
+ +
+
{
- {{ row.label ?? labelMap.get(row.action) ?? row.action }} + {{ rowLabel(row) }} + (자율 행동)
@@ -461,87 +549,6 @@ const clickOutsideMenu = (event: Event) => {
-
-
- 선택한 턴을 - -
- -
-
@@ -550,7 +557,12 @@ const clickOutsideMenu = (event: Event) => {
-
+
{{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}턴` }} 명령 입력 @@ -628,6 +640,9 @@ const clickOutsideMenu = (event: Event) => { gap: 4px; padding: 3px 0; } +.queue-area { + order: 2; +} .control-pad > button, .clock, .legacy-menu > summary, @@ -779,6 +794,13 @@ const clickOutsideMenu = (event: Event) => { .action-column > div:nth-child(even) { background: #071638; } +.action-column > div.autonomous { + color: #aaffff; +} +.action-column small { + font-size: 0.72em; + line-height: 1; +} .edit-column button { background: #444; cursor: pointer; @@ -786,6 +808,7 @@ const clickOutsideMenu = (event: Event) => { .advanced-actions { display: grid; grid-template-columns: 5fr 7fr; + order: 1; } .advanced-actions > * { border-radius: 0 !important; @@ -808,6 +831,10 @@ const clickOutsideMenu = (event: Event) => { background: #303030; box-shadow: 0 6px 16px #000; } +.reserved-command-editor:not(.compact) .command-picker { + max-height: none; + overflow: visible; +} .command-picker > header { display: flex; align-items: center; diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index 58bf0e13..d01f78d4 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -49,6 +49,7 @@ export type ReservedCommandRow = { time?: string; year?: number; month?: number; + autonomous?: boolean; }; export type CommandPatternEntry = { diff --git a/app/game-frontend/src/components/main/CommandListPanel.vue b/app/game-frontend/src/components/main/CommandListPanel.vue index 72ac9811..d44b72fe 100644 --- a/app/game-frontend/src/components/main/CommandListPanel.vue +++ b/app/game-frontend/src/components/main/CommandListPanel.vue @@ -12,6 +12,7 @@ const props = defineProps<{ currentYear?: number; currentMonth?: number; turnTermMinutes?: number; + autorunLimit?: number | null; storageKey?: string; }>(); @@ -43,6 +44,7 @@ const rows = computed(() => { label: labelMap.value.get(turn.action) ?? turn.action, year: Math.floor(absoluteMonth / 12), month: (absoluteMonth % 12) + 1, + autonomous: props.autorunLimit != null && absoluteMonth <= props.autorunLimit - 1, time: date ? term >= 5 ? `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}` diff --git a/app/game-frontend/src/components/main/MainGlobalMenu.vue b/app/game-frontend/src/components/main/MainGlobalMenu.vue index 2f37254a..a149751d 100644 --- a/app/game-frontend/src/components/main/MainGlobalMenu.vue +++ b/app/game-frontend/src/components/main/MainGlobalMenu.vue @@ -115,6 +115,33 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props min-width: 0; } +.main-global-menu > :deep(.main-menu-link), +.main-menu-popup > .main-menu-button, +.main-menu-split > :deep(.main-menu-link), +.main-menu-split > .main-menu-split__toggle { + border-color: var(--sammo-button-navigation-border); + background-color: var(--sammo-button-navigation-bg); + background-image: none; +} + +.main-global-menu > :deep(.main-menu-link:hover), +.main-global-menu > :deep(.main-menu-link:focus-visible), +.main-global-menu > :deep(.main-menu-link:active), +.main-menu-popup > .main-menu-button:hover, +.main-menu-popup > .main-menu-button:focus-visible, +.main-menu-popup > .main-menu-button:active, +.main-menu-popup > .main-menu-button[aria-expanded='true'], +.main-menu-split > :deep(.main-menu-link:hover), +.main-menu-split > :deep(.main-menu-link:focus-visible), +.main-menu-split > :deep(.main-menu-link:active), +.main-menu-split > .main-menu-split__toggle:hover, +.main-menu-split > .main-menu-split__toggle:focus-visible, +.main-menu-split > .main-menu-split__toggle:active, +.main-menu-split > .main-menu-split__toggle[aria-expanded='true'] { + border-color: var(--sammo-button-navigation-border); + background-color: var(--sammo-button-navigation-bg); +} + .main-menu-popup > .main-menu-button, .main-menu-split > :deep(.main-menu-link) { width: 100%; diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue index ba8b6ab9..b21cd294 100644 --- a/app/game-frontend/src/components/main/MapViewer.vue +++ b/app/game-frontend/src/components/main/MapViewer.vue @@ -13,6 +13,11 @@ interface MapSummary { year: number; month: number; startYear: number; + techLevelLimit?: { + maxLevel: number; + initialLevel: number; + increaseYears: number; + }; cityList: [number, number, number, number, number, number][]; nationList: [number, string, string, number][]; myCity?: number | null; @@ -198,9 +203,77 @@ const mapSummary = computed(() => { if (!props.mapData) { return ''; } - return `${props.mapData.year}년 ${props.mapData.month}월`; + return `${props.mapData.year}年 ${props.mapData.month}月`; }); +const titleColor = computed(() => { + if (!props.mapData) { + return undefined; + } + const { startYear, year } = props.mapData; + if (year < startYear + 1) { + return 'magenta'; + } + if (year < startYear + 2) { + return 'orange'; + } + if (year < startYear + 3) { + return 'yellow'; + } + return undefined; +}); + +const titleTooltipLines = computed(() => { + if (!props.mapData) { + return []; + } + + const { startYear, year, month } = props.mapData; + const lines: string[] = []; + if (year <= startYear + 3) { + // Ref uses joinYearMonth(startYear + 3, 0) as the limit boundary. + const remainingMonths = (startYear + 3) * 12 - 1 - (year * 12 + month - 1); + const remainYear = Math.trunc(remainingMonths / 12); + const remainMonth = (remainingMonths % 12) + 1; + lines.push( + `초반제한 기간 : ${remainYear}년${remainMonth > 0 ? ` ${remainMonth}개월` : ''} (${startYear + 3}년)` + ); + } + + const limit = props.mapData.techLevelLimit ?? { + maxLevel: 12, + initialLevel: 1, + increaseYears: 5, + }; + const currentLevel = Math.min( + limit.maxLevel, + Math.max(1, Math.floor((year - startYear) / limit.increaseYears) + limit.initialLevel) + ); + if (currentLevel === limit.maxLevel) { + lines.push(`기술등급 제한 : ${currentLevel}등급 (최종)`); + } else { + lines.push(`기술등급 제한 : ${currentLevel}등급 (${currentLevel * limit.increaseYears + startYear}년 해제)`); + } + return lines; +}); + +const titleBandStyle = computed(() => + detailMode.value + ? { + backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`, + } + : {} +); + +const titleTextStyle = computed(() => + detailMode.value + ? { + color: titleColor.value, + backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`, + } + : { color: titleColor.value } +); + const mapThemeClass = computed(() => { return `map-theme-${mapTheme.value}`; }); @@ -269,6 +342,24 @@ const hoveredCity = computed(() => { return cityViews.value.find((city) => city.id === hoveredCityId.value) ?? null; }); +const hoveredCityTitle = computed(() => { + if (!hoveredCity.value) { + return ''; + } + return `【${hoveredCity.value.regionName}|${hoveredCity.value.levelName}】${hoveredCity.value.name}`; +}); + +const tooltipPosition = computed(() => { + const width = 120; + const offset = 10; + const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value; + const left = elementX.value + width + offset > mapPixelWidth ? elementX.value - width - 5 : elementX.value + offset; + return { + left: `${Math.max(0, left)}px`, + top: `${elementY.value + 30}px`, + }; +}); + const setHoveredCity = (cityId: number | null) => { mapStore.setHoveredCity(cityId); }; @@ -280,8 +371,13 @@ const selectCity = (cityId: number) => { + + {{ message.src.generalName }}:{{ message.src.nationName }} + {{ message.src.generalName }} diff --git a/app/game-frontend/src/components/main/NationBasicCard.vue b/app/game-frontend/src/components/main/NationBasicCard.vue index 5efa9097..6470648f 100644 --- a/app/game-frontend/src/components/main/NationBasicCard.vue +++ b/app/game-frontend/src/components/main/NationBasicCard.vue @@ -31,15 +31,19 @@ const props = defineProps<{ class="title" :style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }" > - {{ props.nation.name }} (Lv {{ props.nation.level }}) + {{ props.nation.name }}
- 국고{{ props.nation.gold.toLocaleString() }} 국량{{ props.nation.rice.toLocaleString() }} 기술{{ props.nation.tech.toLocaleString() }} 체제{{ props.nation.typeCode }} 수도{{ props.nation.capitalCityId ?? '-' }} 국가 등급{{ props.nation.level }} + 국고{{ props.nation.id === 0 ? '해당 없음' : props.nation.gold.toLocaleString() }} + 국량{{ props.nation.id === 0 ? '해당 없음' : props.nation.rice.toLocaleString() }} + 기술{{ props.nation.id === 0 ? '해당 없음' : props.nation.tech.toLocaleString() }} + 체제{{ props.nation.id === 0 ? '해당 없음' : props.nation.typeCode }} + 수도{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityId ?? '-') }} + 국가 등급{{ props.nation.id === 0 ? '해당 없음' : props.nation.level }}
diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index c178bd5c..4fd05e72 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -65,6 +65,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { boardAccess?: BoardAccess | null; reservedGeneralTurns?: ReservedTurnView[] | null; reservedGeneralRevision?: number; + reservedGeneralAutorunLimit?: number | null; globalRecords?: RecentRecord[]; generalRecords?: RecentRecord[]; worldHistory?: RecentRecord[]; @@ -94,6 +95,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const boardAccess = ref(null); const reservedGeneralTurns = ref(null); const reservedGeneralRevision = ref(0); + const reservedGeneralAutorunLimit = ref(null); const globalRecords = ref([]); const generalRecords = ref([]); const worldHistory = ref([]); @@ -331,6 +333,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { boardAccess.value = null; reservedGeneralTurns.value = null; reservedGeneralRevision.value = 0; + reservedGeneralAutorunLimit.value = null; resetRecentRecords(null); commandTableRevision = null; boardAccessRevision = null; @@ -351,6 +354,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { boardAccess.value = null; reservedGeneralTurns.value = null; reservedGeneralRevision.value = 0; + reservedGeneralAutorunLimit.value = null; resetRecentRecords(null); contextRevision = null; commandTableRevision = null; @@ -384,6 +388,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (patch.reservedGeneralRevision !== undefined) { reservedGeneralRevision.value = patch.reservedGeneralRevision; } + if (patch.reservedGeneralAutorunLimit !== undefined) { + reservedGeneralAutorunLimit.value = patch.reservedGeneralAutorunLimit; + } if (patch.globalRecords !== undefined) { globalRecords.value = structurallyShare(globalRecords.value, patch.globalRecords); lastGeneralRecordId = Math.max(lastGeneralRecordId, patch.globalRecords[0]?.id ?? 0); @@ -425,6 +432,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { patch.boardAccess = boardAccessSnapshot ?? null; patch.reservedGeneralTurns = toRaw(reservedGeneralTurns.value as unknown) as ReservedTurnView[] | null; patch.reservedGeneralRevision = reservedGeneralRevision.value; + patch.reservedGeneralAutorunLimit = reservedGeneralAutorunLimit.value; patch.globalRecords = toRaw(globalRecords.value); patch.generalRecords = toRaw(generalRecords.value); patch.worldHistory = toRaw(worldHistory.value); @@ -552,6 +560,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { generalTurns.turns ) as ReservedTurnView[]; reservedGeneralRevision.value = generalTurns.revision; + reservedGeneralAutorunLimit.value = generalTurns.autorunLimit ?? null; if (records) { applyRecentRecords(records); } @@ -672,6 +681,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (generalTurns !== undefined) { patch.reservedGeneralTurns = generalTurns.turns; patch.reservedGeneralRevision = generalTurns.revision; + patch.reservedGeneralAutorunLimit = generalTurns.autorunLimit ?? null; } if (records) { const nextGlobalRecords = mergeRecentRecords(globalRecords.value, records.global); @@ -836,12 +846,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }); reservedGeneralTurns.value = result.turns; reservedGeneralRevision.value = result.revision; + reservedGeneralAutorunLimit.value = result.autorunLimit ?? null; } catch (err) { error.value = resolveErrorMessage(err); const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); if (snapshot) { reservedGeneralTurns.value = snapshot.turns; reservedGeneralRevision.value = snapshot.revision; + reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null; } } }; @@ -859,12 +871,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }); reservedGeneralTurns.value = result.turns; reservedGeneralRevision.value = result.revision; + reservedGeneralAutorunLimit.value = result.autorunLimit ?? null; } catch (err) { error.value = resolveErrorMessage(err); const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); if (snapshot) { reservedGeneralTurns.value = snapshot.turns; reservedGeneralRevision.value = snapshot.revision; + reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null; } } }; @@ -882,12 +896,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }); reservedGeneralTurns.value = result.turns; reservedGeneralRevision.value = result.revision; + reservedGeneralAutorunLimit.value = result.autorunLimit ?? null; } catch (err) { error.value = resolveErrorMessage(err); const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); if (snapshot) { reservedGeneralTurns.value = snapshot.turns; reservedGeneralRevision.value = snapshot.revision; + reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null; } } }; @@ -903,12 +919,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }); reservedGeneralTurns.value = result.turns; reservedGeneralRevision.value = result.revision; + reservedGeneralAutorunLimit.value = result.autorunLimit ?? null; } catch (err) { error.value = resolveErrorMessage(err); const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); if (snapshot) { reservedGeneralTurns.value = snapshot.turns; reservedGeneralRevision.value = snapshot.revision; + reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null; } } }; @@ -1192,6 +1210,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { messageContacts, boardAccess, reservedGeneralTurns, + reservedGeneralAutorunLimit, globalRecords, generalRecords, worldHistory, diff --git a/app/game-frontend/src/views/GlobalInfoView.vue b/app/game-frontend/src/views/GlobalInfoView.vue index 67dcbc9b..b9ac9f48 100644 --- a/app/game-frontend/src/views/GlobalInfoView.vue +++ b/app/game-frontend/src/views/GlobalInfoView.vue @@ -1,5 +1,5 @@