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 2c8fdbd4..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: {}, @@ -575,17 +593,17 @@ const persistArtifact = async (page: Page, name: string) => { 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(); @@ -879,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); @@ -980,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('최근 실행') @@ -996,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); @@ -1173,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"]') @@ -1377,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)) @@ -1793,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; @@ -1823,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/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 @@