diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 05457ba9..d70174c1 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -7,6 +7,7 @@ import type { GeneralItemSlots, GeneralActionDefinition, GeneralTurnCommandSpec, + GeneralTurnCommandKey, MapDefinition, Nation, NationTurnCommandSpec, @@ -72,6 +73,83 @@ interface CommandEntry { evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore; } +const REF_GENERAL_COMMAND_GROUPS = [ + { + category: '개인', + commands: [ + '휴식', + 'che_요양', + 'che_단련', + 'che_숙련전환', + 'che_견문', + 'che_은퇴', + 'che_장비매매', + 'che_군량매매', + 'che_내정특기초기화', + 'che_전투특기초기화', + ], + }, + { + category: '내정', + commands: [ + 'che_농지개간', + 'che_상업투자', + 'che_기술연구', + 'che_수비강화', + 'che_성벽보수', + 'che_치안강화', + 'che_정착장려', + 'che_주민선정', + ], + }, + { + category: '군사', + commands: [ + 'che_징병', + 'che_모병', + 'che_훈련', + 'che_사기진작', + 'che_출병', + 'che_집합', + 'che_소집해제', + 'che_첩보', + ], + }, + { + category: '인사', + commands: [ + 'che_이동', + 'che_강행', + 'che_인재탐색', + 'che_등용', + 'che_귀환', + 'che_임관', + 'che_랜덤임관', + 'che_장수대상임관', + ], + }, + { + category: '계략', + commands: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'], + }, + { + category: '국가', + commands: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], + }, +] as const satisfies ReadonlyArray<{ + category: string; + commands: ReadonlyArray; +}>; + +const REF_GENERAL_CATEGORY_ORDER = new Map( + REF_GENERAL_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const) +); +const REF_GENERAL_COMMAND_POSITION = new Map( + REF_GENERAL_COMMAND_GROUPS.flatMap(({ category, commands }) => + commands.map((command, index) => [command, { category, index }] as const) + ) +); + const INPUT_REQUIREMENT_KINDS = new Set([ 'destGeneral', 'destCity', @@ -567,6 +645,26 @@ const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: Stat })); }; +const projectRefGeneralCommandGroups = (entries: CommandEntry[]): CommandEntry[] => + entries + .map((entry, profileIndex) => { + const refPosition = REF_GENERAL_COMMAND_POSITION.get(entry.definition.key as GeneralTurnCommandKey); + return { + entry: refPosition ? { ...entry, category: refPosition.category } : entry, + categoryIndex: + REF_GENERAL_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER, + commandIndex: refPosition?.index ?? profileIndex, + profileIndex, + }; + }) + .sort( + (left, right) => + left.categoryIndex - right.categoryIndex || + left.commandIndex - right.commandIndex || + left.profileIndex - right.profileIndex + ) + .map(({ entry }) => entry); + export const buildTurnCommandTable = async (options: { worldState: WorldStateRow; general: GeneralRow; @@ -598,7 +696,7 @@ export const buildTurnCommandTable = async (options: { const nationEntries = buildEntries(env, nationSpecs); return { - general: buildGroups(generalEntries, ctx, view), + general: buildGroups(projectRefGeneralCommandGroups(generalEntries), ctx, view), nation: buildGroups(nationEntries, ctx, view), inputOptions: options.inputOptions ?? { cities: [], diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 185e1bed..9741acc8 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -100,6 +100,47 @@ const buildNation = (): NationRow => }) as unknown as NationRow; describe('buildTurnCommandTable', () => { + it('projects the main reserved-turn categories and command order from Ref', async () => { + const table = await buildTurnCommandTable({ + worldState: buildWorldState(), + general: buildGeneral(), + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + + expect(table.general.map(({ category }) => category)).toEqual(['개인', '내정', '군사', '인사', '계략', '국가']); + expect( + Object.fromEntries(table.general.map(({ category, values }) => [category, values.map(({ key }) => key)])) + ).toEqual({ + 개인: [ + '휴식', + 'che_요양', + 'che_단련', + 'che_숙련전환', + 'che_견문', + 'che_장비매매', + 'che_군량매매', + 'che_내정특기초기화', + 'che_전투특기초기화', + ], + 내정: [ + 'che_농지개간', + 'che_상업투자', + 'che_기술연구', + 'che_수비강화', + 'che_성벽보수', + 'che_치안강화', + 'che_정착장려', + 'che_주민선정', + ], + 군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'], + 인사: ['che_이동', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'], + 계략: ['che_화계'], + 국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], + }); + }); + it('uses min-condition constraints for availability', async () => { const table = await buildTurnCommandTable({ worldState: buildWorldState(), diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index fbc3bac8..31171634 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -160,7 +160,8 @@ const install = async ( mode: 'member' | 'wanderer' | 'admin' = 'member', trade: number | null = 100, globalNationCount = 2, - mapFixture = map + mapFixture = map, + denseCurrentCity = false ) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_info'); @@ -372,8 +373,32 @@ const install = async ( crew: 500, train: 90, atmos: 90, - turns: ['징병'], + turns: denseCurrentCity ? ['징병', '훈련'] : ['징병'], }, + ...(denseCurrentCity + ? Array.from({ length: 12 }, (_, index) => ({ + id: index + 2, + name: `NPC장수이름이긴${index + 1}`, + npcState: 2, + picture: null, + imageServer: 0, + nationId: 1, + nationName: '아국', + leadership: 60, + strength: 60, + intelligence: 60, + injury: 0, + officerLevel: 1, + leadershipBonus: 0, + defenceTrain: 80, + crewTypeId: 1, + crewTypeName: '보병', + crew: 500, + train: 90, + atmos: 90, + turns: [], + })) + : []), ], forceSummary: { enemyCrew: 0, @@ -407,7 +432,7 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p ['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'], ['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'], ['global-info', '.global-page', '14px', 'Pretendard', 'collapse'], - ['current-city', '.city-page', '16px', 'Times New Roman', 'separate'], + ['current-city', '.city-page', '14px', 'Pretendard', 'separate'], ] as const) { await go(page, path); await expect(page.locator(selector)).toBeVisible(); @@ -774,8 +799,8 @@ test('current-city exposes own general details to a member and admin fixture', a }; }); expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 }); - expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 }); - expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 }); + expect(legacyGeometry.stats).toEqual({ x: 100, y: 165.375, width: 1000, height: 106.9375 }); + expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 290.3125, width: 1024 }); expect(legacyGeometry.titleAlign).toBe('start'); expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 }); if (artifactRoot) { @@ -834,6 +859,67 @@ test('current-city exposes own general details to a member and admin fixture', a } }); +test('current-city wraps dense general names and only shrinks reserved turns', async ({ page }) => { + await install(page, 'member', 100, 2, map, true); + for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await go(page, 'current-city'); + + const pageStyle = await page.locator('.city-page').evaluate((element) => { + const style = getComputedStyle(element); + return { fontFamily: style.fontFamily, fontSize: style.fontSize }; + }); + expect(pageStyle.fontFamily).toContain('Pretendard'); + expect(pageStyle.fontSize).toBe('14px'); + + const names = page.locator('.general-names'); + await expect(names).toHaveCSS('white-space', 'normal'); + const nameLineCount = await names.locator('span').evaluateAll((elements) => { + const tops = elements.map((element) => Math.round(element.getBoundingClientRect().top)); + return new Set(tops).size; + }); + expect(nameLineCount).toBeGreaterThan(1); + + const rows = page.locator('.generals tbody tr'); + const reservedTurns = rows.nth(0).locator('.turns'); + const npcTurns = rows.nth(1).locator('.turns'); + await expect(reservedTurns).toContainText('1 : 징병'); + await expect(reservedTurns).toContainText('2 : 훈련'); + await expect(reservedTurns).toHaveClass(/turns--reserved/); + await expect(npcTurns).toHaveText('NPC 장수'); + await expect(npcTurns).not.toHaveClass(/turns--reserved/); + + const turnFontSizes = await Promise.all([ + reservedTurns.evaluate((element) => getComputedStyle(element).fontSize), + npcTurns.evaluate((element) => getComputedStyle(element).fontSize), + ]); + expect(Number.parseFloat(turnFontSizes[0])).toBeLessThan(Number.parseFloat(pageStyle.fontSize)); + expect(turnFontSizes[1]).toBe(pageStyle.fontSize); + + const reservedLineTops = await reservedTurns + .locator('.turn-line') + .evaluateAll((elements) => elements.map((element) => Math.round(element.getBoundingClientRect().top))); + expect(new Set(reservedLineTops).size).toBe(2); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await writeFile( + resolve(artifactRoot, `core-current-city-dense-${viewport.name}.json`), + `${JSON.stringify({ pageStyle, nameLineCount, turnFontSizes, reservedLineTops }, null, 2)}\n`, + 'utf8' + ); + await page.screenshot({ + path: resolve(artifactRoot, `core-current-city-dense-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + } + } +}); + test('current-city renders a missing merchant rate with the legacy dash and percent text', async ({ page }) => { await install(page, 'member', null); await page.setViewportSize({ width: 1200, height: 900 }); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 1a241beb..05503f87 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -656,17 +656,32 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명 await expect(page.locator('.main-page')).not.toContainText('che_'); }); -test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄에 표시한다', async ({ page }) => { +test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을 유지한다', async ({ page }) => { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [], recentRecords: { - global: [], - general: [ + global: [ + { + id: 18611, + text: + '●9월:Administrator가 낙양으로 ' + + '진격합니다.(전투시드: 0123456789abcdef)', + }, + { + id: 18610, + text: '●9월:Administrator가 임관했습니다.', + }, { id: 18609, + text: '●9월:뇌동의 기병이 퇴각했습니다.', + }, + ], + general: [ + { + id: 18608, text: '◆186년 9월:
' + '귀병 ' + @@ -686,6 +701,31 @@ test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄 await page.setViewportSize({ width: 1200, height: 900 }); await page.goto(''); + const inspectGlobalRhythm = async (selector: string) => { + const lines = page.locator(selector); + await expect(lines).toHaveCount(3); + return lines.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + height: rect.height, + lineHeight: getComputedStyle(element).lineHeight, + }; + }) + ); + }; + const assertUniformGlobalRhythm = (geometry: Awaited>) => { + expect(geometry.map((line) => line.height)).toEqual([21, 21, 21]); + expect(geometry.map((line) => line.lineHeight)).toEqual(['21px', '21px', '21px']); + expect(geometry[1]!.top - geometry[0]!.top).toBe(21); + expect(geometry[2]!.top - geometry[1]!.top).toBe(21); + }; + + const desktopGlobalGeometry = await inspectGlobalRhythm('.record-zone [data-record-bucket="global"] .record-line'); + assertUniformGlobalRhythm(desktopGlobalGeometry); + await persistParityArtifact(page, 'core-main-trend-log-rhythm-desktop', desktopGlobalGeometry); + const expectedText = '◆186년 9월:귀병 【Administrator】 0(-2209) ← 1361(-5539) 기병 【ⓝ뇌동】 12:54'; const inspect = async (line: Locator) => { await expect(line).toContainText(expectedText); @@ -723,6 +763,11 @@ test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄 page.locator('.record-zone-mobile [data-record-bucket="general"] .record-line').first() ); assertSingleLine(mobileGeometry); + const mobileGlobalGeometry = await inspectGlobalRhythm( + '.record-zone-mobile [data-record-bucket="global"] .record-line' + ); + assertUniformGlobalRhythm(mobileGlobalGeometry); + await persistParityArtifact(page, 'core-main-trend-log-rhythm-mobile', mobileGlobalGeometry); await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry); }); diff --git a/app/game-frontend/e2e/joinLayout.spec.ts b/app/game-frontend/e2e/joinLayout.spec.ts index 337fe9b9..0df9167b 100644 --- a/app/game-frontend/e2e/joinLayout.spec.ts +++ b/app/game-frontend/e2e/joinLayout.spec.ts @@ -179,26 +179,54 @@ test('prioritizes core general fields and keeps context and inheritance progress const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { + top: rect.top, height: rect.height, backgroundColor: style.backgroundColor, color: style.color, + borderTopWidth: style.borderTopWidth, + borderRightWidth: style.borderRightWidth, + borderBottomWidth: style.borderBottomWidth, + borderBottomColor: style.borderBottomColor, + marginTop: style.marginTop, fontSize: style.fontSize, fontWeight: style.fontWeight, cursor: style.cursor, }; }); - expect(defaultButtonStyle).toEqual({ + expect(defaultButtonStyle).toMatchObject({ height: 40, backgroundColor: 'rgb(0, 88, 44)', color: 'rgb(255, 255, 255)', + borderTopWidth: '0px', + borderRightWidth: '1px', + borderBottomWidth: '4px', + borderBottomColor: 'rgb(0, 79, 40)', + marginTop: '0px', fontSize: '14px', fontWeight: '700', cursor: 'pointer', }); await randomButton.hover(); - await expect - .poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor)) - .toBe('rgb(0, 109, 55)'); + const hoverButtonStyle = await randomButton.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + top: rect.top, + height: rect.height, + backgroundColor: style.backgroundColor, + borderBottomWidth: style.borderBottomWidth, + borderBottomColor: style.borderBottomColor, + marginTop: style.marginTop, + }; + }); + expect(hoverButtonStyle).toMatchObject({ + top: defaultButtonStyle.top + 1, + height: 39, + backgroundColor: 'rgb(0, 88, 44)', + borderBottomWidth: '3px', + borderBottomColor: 'rgb(0, 79, 40)', + marginTop: '1px', + }); await page.screenshot({ path: testInfo.outputPath('join-stat-actions-hover-desktop.png'), fullPage: true }); await randomButton.focus(); await expect(randomButton).toBeFocused(); @@ -210,9 +238,26 @@ test('prioritizes core general fields and keeps context and inheritance progress randomButtonBox!.y + randomButtonBox!.height / 2 ); await page.mouse.down(); - await expect - .poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor)) - .toBe('rgb(0, 69, 35)'); + const activeButtonStyle = await randomButton.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + top: rect.top, + height: rect.height, + backgroundColor: style.backgroundColor, + borderBottomWidth: style.borderBottomWidth, + borderBottomColor: style.borderBottomColor, + marginTop: style.marginTop, + }; + }); + expect(activeButtonStyle).toMatchObject({ + top: defaultButtonStyle.top + 2, + height: 38, + backgroundColor: 'rgb(0, 88, 44)', + borderBottomWidth: '2px', + borderBottomColor: 'rgb(0, 79, 40)', + marginTop: '2px', + }); await page.screenshot({ path: testInfo.outputPath('join-stat-actions-active-desktop.png'), fullPage: true }); await page.mouse.up(); const setRandomValues = async (values: number[]) => { diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index c36fdc16..7cd39e9a 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -34,6 +34,7 @@ type NavigationFixture = { forceSnapshotCalls?: number; refreshDelayMs?: number; largeCommandTable?: boolean; + refCommandCategories?: boolean; currentYear?: number; currentMonth?: number; scenarioTitle?: string; @@ -110,32 +111,48 @@ const emitReadModelInvalidation = (page: Page, invalidation: ReturnType ({ - general: large - ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ - category, - values: Array.from({ length: 16 }, (_, localIndex) => { - const index = categoryIndex * 16 + localIndex; - return { - key: `command-${index}`, - name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, - reqArg: index % 2 === 0, - possible: index >= blockedCount, - status: index >= blockedCount ? 'available' : 'blocked', - inputFields: [ - { - key: 'amount', - label: '수량', - kind: 'number', - required: true, - min: 1, - max: 10_000, - }, - ], - }; - }), - })) - : [], +const refCommandCategoryFixture = ['개인', '내정', '군사', '인사', '계략', '국가'].map((category, index) => ({ + category, + values: [ + { + key: `ref-command-${index}`, + name: category === '계략' ? '화계' : `${category} 명령`, + reqArg: false, + possible: true, + status: 'available' as const, + inputFields: [], + }, + ], +})); + +const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false) => ({ + general: refCategories + ? refCommandCategoryFixture + : large + ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ + category, + values: Array.from({ length: 16 }, (_, localIndex) => { + const index = categoryIndex * 16 + localIndex; + return { + key: `command-${index}`, + name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, + reqArg: index % 2 === 0, + possible: index >= blockedCount, + status: index >= blockedCount ? 'available' : 'blocked', + inputFields: [ + { + key: 'amount', + label: '수량', + kind: 'number', + required: true, + min: 1, + max: 10_000, + }, + ], + }; + }), + })) + : [], nation: [], inputOptions: { cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })), @@ -375,7 +392,11 @@ const installFixture = async (page: Page, state: NavigationFixture) => { ? { kind: 'snapshot' as const, revision: currentCommandTableRevision, - data: commandTableFixture(state.largeCommandTable === true, state.commandBlockedCount), + data: commandTableFixture( + state.largeCommandTable === true, + state.commandBlockedCount, + state.refCommandCategories === true + ), } : input.known.commandTable === currentCommandTableRevision ? { kind: 'unchanged' as const, revision: currentCommandTableRevision } @@ -972,6 +993,57 @@ test('pure NPC message senders are not rendered as reply targets', async ({ page await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`); }); +test('main reserved-turn picker renders the Ref general category order', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 1, + permission: 0, + nationLevel: 1, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + refCommandCategories: true, + reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })), + }; + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + const categoryButtons = picker.locator('.category-btn'); + await expect(categoryButtons).toHaveText(['개인', '내정', '군사', '인사', '계략', '국가']); + + const desktopGeometry = await picker.evaluate((element) => { + const categories = element.querySelector('.category-list'); + if (!categories) throw new Error('command category list is missing'); + const buttons = [...categories.querySelectorAll('.category-btn')]; + return { + columns: getComputedStyle(categories).gridTemplateColumns, + rows: new Set(buttons.map((button) => button.getBoundingClientRect().y)).size, + horizontalOverflow: element.scrollWidth - element.clientWidth, + }; + }); + expect(desktopGeometry.columns.split(' ')).toHaveLength(3); + expect(desktopGeometry.rows).toBe(2); + expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0); + + const strategyCategory = picker.getByRole('button', { name: '계략', exact: true }); + await strategyCategory.hover(); + await strategyCategory.focus(); + await expect(strategyCategory).toBeFocused(); + await strategyCategory.click(); + await expect(strategyCategory).toHaveClass(/active/); + await expect(picker.locator('.command-item')).toHaveText(['화계']); + + await page.setViewportSize({ width: 500, height: 900 }); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const mobilePicker = page.getByTestId('command-picker'); + await expect(mobilePicker.locator('.category-btn')).toHaveText(['개인', '내정', '군사', '인사', '계략', '국가']); + expect(await mobilePicker.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0); + await persistArtifact(page, `${basePath.slice(1)}-main-reserved-ref-categories-mobile-500`); +}); + test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, diff --git a/app/game-frontend/src/views/CurrentCityView.vue b/app/game-frontend/src/views/CurrentCityView.vue index f87f0f67..e75b1d28 100644 --- a/app/game-frontend/src/views/CurrentCityView.vue +++ b/app/game-frontend/src/views/CurrentCityView.vue @@ -299,7 +299,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general {{ general.crew ?? '?' }} {{ general.train ?? '?' }} {{ general.atmos ?? '?' }} - +