diff --git a/app/game-api/src/router/world/directory.ts b/app/game-api/src/router/world/directory.ts index d3976975..feb0e271 100644 --- a/app/game-api/src/router/world/directory.ts +++ b/app/game-api/src/router/world/directory.ts @@ -10,6 +10,9 @@ import { resolveSecretPermission } from '../shared/secretPermission.js'; const zDirectorySort = z.number().int().min(1).max(15).default(9); +const directoryGeneralTypes = ['만능', '통', '무', '지', '평범', '무지', '무능'] as const; +type DirectoryGeneralType = (typeof directoryGeneralTypes)[number]; + const readNumber = (value: unknown, fallback = 0): number => typeof value === 'number' && Number.isFinite(value) ? value : fallback; @@ -34,6 +37,28 @@ const compareString = (left: string, right: string): number => { return left < right ? -1 : 1; }; +const resolveDirectoryGeneralType = (leadership: number, strength: number, intel: number): DirectoryGeneralType => { + if (leadership < 40) { + return strength + intel < 40 ? '무능' : '무지'; + } + + const maxStat = Math.max(leadership, strength, intel); + const lowestPairSum = Math.min(leadership + strength, strength + intel, intel + leadership); + if (maxStat >= 70 && lowestPairSum >= maxStat * 1.7) { + return '만능'; + } + if (strength >= 60 && intel < strength * 0.8) { + return '무'; + } + if (intel >= 60 && strength < intel * 0.8) { + return '지'; + } + if (leadership >= 60 && strength + intel < leadership) { + return '통'; + } + return '평범'; +}; + const resolveExperienceLevel = (experience: number, maxLevel: number): number => { const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10)); return Math.max(0, Math.min(level, maxLevel)); @@ -77,7 +102,7 @@ const resolveRefreshText = (score: number): string => { export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { await getMyGeneral(ctx); - const [nations, generals, cities] = await Promise.all([ + const [nations, generals, cities, accessLogs, worldState] = await Promise.all([ ctx.db.nation.findMany({ select: { id: true, @@ -96,7 +121,9 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { npcState: true, nationId: true, cityId: true, - crew: true, + leadership: true, + strength: true, + intel: true, dedication: true, officerLevel: true, meta: true, @@ -108,6 +135,12 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { select: { id: true, name: true, nationId: true }, orderBy: { id: 'asc' }, }), + ctx.db.generalAccessLog.findMany({ + select: { generalId: true, refreshScoreTotal: true }, + }), + ctx.db.worldState.findFirst({ + select: { tickSeconds: true, meta: true }, + }), ]); const directoryNations = nations.some((nation) => nation.id === 0) @@ -136,6 +169,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { } const citiesByNation = new Map(); const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const)); + const accessScoreByGeneralId = new Map( + accessLogs.map((accessLog) => [accessLog.generalId, accessLog.refreshScoreTotal] as const) + ); + const worldMeta = asRecord(worldState?.meta); + const autorunUser = asRecord(worldMeta.autorun_user); + const worldKillturn = readNumber(worldMeta.killturn); + const turnMinutes = readNumber(worldState?.tickSeconds) / 60; + const autorunLimitTurns = turnMinutes > 0 ? readNumber(autorunUser.limit_minutes) / turnMinutes : 0; + const activeKillturnThreshold = worldKillturn - autorunLimitTurns; for (const city of cities) { const list = citiesByNation.get(city.nationId) ?? []; list.push(city); @@ -169,6 +211,40 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { false ), })); + const analyzedGenerals = nationGenerals.map((general) => { + const killturn = readMetaNumber(general.meta, 'killturn'); + const inactive = general.npcState < 2 && killturn < activeKillturnThreshold; + const accessScore = accessScoreByGeneralId.get(general.id) ?? 0; + return { + id: general.id, + name: general.name, + npcState: general.npcState, + leadership: general.leadership, + type: resolveDirectoryGeneralType(general.leadership, general.strength, general.intel), + inactive, + accessGrade: accessScore >= 1_500 ? 'high' : accessScore >= 200 ? 'medium' : 'normal', + killturn, + } as const; + }); + const combatGenerals = analyzedGenerals.filter( + (general) => general.type !== '무능' && general.type !== '무지' + ); + const userCombatGenerals = combatGenerals.filter((general) => general.npcState < 2 && !general.inactive); + const npcCombatGenerals = combatGenerals.filter((general) => general.npcState >= 2 && general.killturn > 5); + const generalGroups = directoryGeneralTypes + .map((type) => ({ + type, + label: `${type}장`, + generals: analyzedGenerals + .filter((general) => general.type === type) + .sort((left, right) => { + const leftScore = accessScoreByGeneralId.get(left.id) ?? 0; + const rightScore = accessScoreByGeneralId.get(right.id) ?? 0; + return rightScore - leftScore || compareString(left.name, right.name) || left.id - right.id; + }) + .map(({ leadership: _leadership, killturn: _killturn, type: _type, ...general }) => general), + })) + .filter((group) => group.generals.length > 0); return { id: nation.id, @@ -183,8 +259,16 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { capitalCityId: nation.capitalCityId ?? 0, rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null, generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length), - totalCrew: nationGenerals.reduce((sum, general) => sum + general.crew, 0), cityCount: nationCities.length, + forceEstimate: { + totalGeneralCount: analyzedGenerals.length, + userCombatGeneralCount: userCombatGenerals.length, + userTroops: userCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0), + npcCombatGeneralCount: npcCombatGenerals.length, + npcTroops: npcCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0), + inactiveGeneralCount: analyzedGenerals.filter((general) => general.inactive).length, + }, + generalGroups, officers, ambassadorNames: secretPermissions .filter(({ permission }) => permission === 4) diff --git a/app/game-api/test/directoryRouter.test.ts b/app/game-api/test/directoryRouter.test.ts index 28749e35..26566501 100644 --- a/app/game-api/test/directoryRouter.test.ts +++ b/app/game-api/test/directoryRouter.test.ts @@ -125,6 +125,9 @@ const globalGenerals = [ name: '군주', dedication: 900, officerLevel: 12, + leadership: 80, + strength: 80, + intel: 80, meta: { killturn: 4, owner_name: '통일유저' }, experience: 10_000, }), @@ -133,6 +136,9 @@ const globalGenerals = [ name: '외교관', dedication: 800, officerLevel: 1, + leadership: 70, + strength: 80, + intel: 40, meta: { killturn: 2, permission: 'ambassador' }, experience: 5_000, }), @@ -141,6 +147,9 @@ const globalGenerals = [ name: '제재외교관', dedication: 700, officerLevel: 1, + leadership: 70, + strength: 40, + intel: 80, meta: { killturn: 1, permission: 'ambassador' }, penalty: { noTopSecret: true }, experience: 2_000, @@ -150,6 +159,9 @@ const globalGenerals = [ name: '조언자', dedication: 600, officerLevel: 1, + leadership: 30, + strength: 70, + intel: 70, meta: { killturn: 5, permission: 'auditor' }, experience: 1_000, }), @@ -162,6 +174,8 @@ const globalGenerals = [ dedication: 500, officerLevel: 12, leadership: 80, + strength: 70, + intel: 65, meta: { killturn: 0 }, experience: 20_000, }), @@ -222,7 +236,12 @@ const createContext = ( worldState: { findFirst: vi.fn(async () => ({ config: { const: { maxLevel: 100, maxDedLevel: 30 } }, - meta: { isUnited: options.isUnited ?? 0 }, + meta: { + isUnited: options.isUnited ?? 0, + killturn: 4, + autorun_user: { limit_minutes: 60 }, + }, + tickSeconds: 3_600, })), }, }; @@ -280,6 +299,20 @@ describe('legacy global nation/general directories', () => { ); expect(results.slice(1)).toEqual([results[0], results[0], results[0]]); for (const result of results) { + const serializedNationRows = JSON.stringify(result.nations); + for (const privateField of [ + 'totalCrew', + 'crew', + 'leadership', + 'strength', + 'intel', + 'killturn', + 'refreshScoreTotal', + 'meta', + 'penalty', + ]) { + expect(serializedNationRows).not.toContain(`"${privateField}"`); + } const serializedGeneralRows = JSON.stringify(result.generals); for (const privateField of [ 'userId', @@ -304,17 +337,75 @@ describe('legacy global nation/general directories', () => { expect(result[1]).toMatchObject({ id: 1, generalCount: 4, - totalCrew: 400, cityCount: 1, + forceEstimate: { + totalGeneralCount: 4, + userCombatGeneralCount: 1, + userTroops: 8_000, + npcCombatGeneralCount: 0, + npcTroops: 0, + inactiveGeneralCount: 2, + }, ambassadorNames: ['군주', '외교관'], auditorCount: 1, }); + expect(result[1]?.generalGroups.map((group) => [group.label, group.generals.map(({ name }) => name)])).toEqual([ + ['만능장', ['군주']], + ['무장', ['외교관']], + ['지장', ['제재외교관']], + ['무지장', ['조언자']], + ]); expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']); expect(result[1]?.officers[0]).toMatchObject({ officerLevel: 12, general: { id: 10, name: '군주' }, }); - expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, totalCrew: 100, cityCount: 1 }); + expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 }); + expect(JSON.stringify(result)).not.toContain('totalCrew'); + }); + + it('matches every Ref general-category boundary and estimates only combat-capable groups', async () => { + const categoryStats = [ + ['만능장', 80, 80, 80], + ['통장', 70, 20, 20], + ['무장', 70, 80, 40], + ['지장', 70, 40, 80], + ['평범장', 60, 50, 50], + ['무지장', 30, 50, 50], + ['무능장', 30, 10, 10], + ] as const; + const categoryGenerals = categoryStats.map(([name, leadership, strength, intel], index) => + directoryGeneral({ + id: 101 + index, + name, + nationId: 1, + leadership, + strength, + intel, + meta: { killturn: 4 }, + }) + ); + const [nation] = await appRouter + .createCaller( + createContext({ + directory: { + nations: [nationRows[0]!], + generals: categoryGenerals, + cities: [{ id: 1, name: '허창', nationId: 1 }], + }, + }).context + ) + .world.getNationDirectory(); + + expect(nation?.generalGroups.map((group) => group.label)).toEqual(categoryStats.map(([name]) => name)); + expect(nation?.forceEstimate).toEqual({ + totalGeneralCount: 7, + userCombatGeneralCount: 5, + userTroops: 35_000, + npcCombatGeneralCount: 0, + npcTroops: 0, + inactiveGeneralCount: 0, + }); }); it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => { diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index 1d6e4643..1587e273 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -19,7 +19,21 @@ const nationDirectory = [ power: 2000, capitalCityId: 2, generalCount: 1, - totalCrew: 1_200, + forceEstimate: { + totalGeneralCount: 1, + userCombatGeneralCount: 1, + userTroops: 8_000, + npcCombatGeneralCount: 0, + npcTroops: 0, + inactiveGeneralCount: 0, + }, + generalGroups: [ + { + type: '만능', + label: '만능장', + generals: [{ id: 20, name: '유비', npcState: 0, inactive: false, accessGrade: 'normal' }], + }, + ], cityCount: 1, officers: Array.from({ length: 8 }, (_, index) => ({ officerLevel: 12 - index, @@ -39,7 +53,26 @@ const nationDirectory = [ power: 1000, capitalCityId: 1, generalCount: 2, - totalCrew: 2_500, + forceEstimate: { + totalGeneralCount: 2, + userCombatGeneralCount: 1, + userTroops: 9_000, + npcCombatGeneralCount: 0, + npcTroops: 0, + inactiveGeneralCount: 1, + }, + generalGroups: [ + { + type: '무', + label: '무장', + generals: [{ id: 10, name: '조조', npcState: 0, inactive: false, accessGrade: 'high' }], + }, + { + type: '지', + label: '지장', + generals: [{ id: 11, name: '순욱', npcState: 1, inactive: true, accessGrade: 'medium' }], + }, + ], cityCount: 1, officers: Array.from({ length: 8 }, (_, index) => ({ officerLevel: 12 - index, @@ -62,7 +95,15 @@ const nationDirectory = [ power: 0, capitalCityId: 0, generalCount: 1, - totalCrew: 0, + forceEstimate: { + totalGeneralCount: 1, + userCombatGeneralCount: 0, + userTroops: 0, + npcCombatGeneralCount: 1, + npcTroops: 7_000, + inactiveGeneralCount: 0, + }, + generalGroups: [], cityCount: 1, officers: Array.from({ length: 8 }, (_, index) => ({ officerLevel: 12 - index, general: null })), ambassadorNames: [], @@ -351,10 +392,9 @@ test('a level-zero nation shows the ruler current city even without territory th await expect(table.locator('.roaming-city')).toHaveCSS('color', 'rgb(255, 255, 0)'); }); -test('PYA-scale general directory mounts only the active responsive layout and defers portraits', async ( - { page }, - testInfo -) => { +test('PYA-scale general directory mounts only the active responsive layout and defers portraits', async ({ + page, +}, testInfo) => { const requestedPortraits = new Set(); page.on('request', (request) => { if (request.url().includes('/performance/general-')) { @@ -700,8 +740,16 @@ test('nation directory reuses only the public general-directory row on hover and await page.waitForLoadState('networkidle'); await expect(page.getByRole('button', { name: '장수 일람 연동' })).toHaveCount(0); await expect(page.locator('[data-general-preview-trigger]')).toHaveCount(4); - await expect(page.locator('[data-nation-id="1"]')).toContainText('총 병사'); - await expect(page.locator('[data-nation-id="1"]')).toContainText('2,500'); + const nationAnalysis = page.locator('[data-nation-id="1"] .force-analysis'); + await expect(nationAnalysis).not.toContainText('총 병사'); + await expect(nationAnalysis).toContainText('무장(1): 조조'); + await expect(nationAnalysis).toContainText('지장(1): ⓝ순욱'); + await expect(nationAnalysis).toContainText('예상 병력 약 9,000명'); + await expect(nationAnalysis).toContainText('삭턴장(1)'); + await expect(page.locator('[data-general-preview-trigger="11"]')).toHaveCSS( + 'text-decoration-line', + 'line-through' + ); expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength( viewport.name === 'desktop' ? 0 : 1 ); @@ -865,9 +913,7 @@ test('nation and general directories rearrange for mobile and keep the tapped pr await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2); await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재'); await expect(page.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모'); - await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText( - '81' - ); + await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText('81'); await expect(page.locator('[data-general-card-id="10"] .leadership-bonus')).toHaveText('+6'); const mobileInjury = page.locator('[data-directory-tooltip="card-injury-leadership-10"]'); await mobileInjury.focus(); diff --git a/app/game-frontend/src/views/NationListView.vue b/app/game-frontend/src/views/NationListView.vue index 47713bdf..51d57317 100644 --- a/app/game-frontend/src/views/NationListView.vue +++ b/app/game-frontend/src/views/NationListView.vue @@ -46,6 +46,11 @@ const officerName = (nation: Nation, officerLevel: number) => nation.officers.find((officer) => officer.officerLevel === officerLevel)?.general; const displayGeneralName = (general: { name: string; npcState: number }) => general.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(general.name) ? `ⓝ${general.name}` : general.name; +const displayAnalyzedGeneralColor = (general: { npcState: number; accessGrade: 'normal' | 'medium' | 'high' }) => { + if (general.accessGrade === 'high') return 'yellow'; + if (general.accessGrade === 'medium') return 'lightgreen'; + return getNpcColor(general.npcState); +}; const displayAmbassadorName = (nation: Nation, name: string) => { const general = nation.generals.find((candidate) => candidate.name === name); return general ? displayGeneralName(general) : name; @@ -203,16 +208,6 @@ onBeforeUnmount(() => { 장수 / 속령 {{ nation.generalCount }} / {{ nation.cityCount }} - - 총 병사 - {{ nation.totalCrew.toLocaleString('ko-KR') }} - - - - 총 병사 - {{ nation.totalCrew.toLocaleString('ko-KR') }} - -