From b01e5751d3095079b1de947a24e8d034e5372824 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 25 Aug 2026 04:36:36 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=B8=EB=A0=A5=20=EC=9E=A5=EC=88=98?= =?UTF-8?q?=20=EB=88=84=EB=9D=BD=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref 권한 경계에 맞춰 연령, 삭턴, 병력, 훈련, 예약 명령, 턴 시각과 전과를 투영한다. 열 선택과 기본/전투 보기에서 새 그룹을 사용할 수 있게 하고 기존 저장 설정을 보존한다. --- .../router/nation/endpoints/getGeneralList.ts | 97 +++++++- .../test/nationGeneralSecretRouter.test.ts | 21 ++ .../e2e/nationGeneralSecret.spec.ts | 94 +++++++- .../src/utils/nationGeneralGrid.ts | 79 ++++++- .../src/views/NationGeneralsView.vue | 209 ++++++++++++++++-- .../test/nationGeneralGrid.test.ts | 5 + 6 files changed, 481 insertions(+), 24 deletions(-) diff --git a/app/game-api/src/router/nation/endpoints/getGeneralList.ts b/app/game-api/src/router/nation/endpoints/getGeneralList.ts index 8f8fa718..31904f06 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralList.ts @@ -1,9 +1,13 @@ import { TRPCError } from '@trpc/server'; -import { asNumber, asRecord } from '@sammo-ts/common'; +import { asNumber, asRecord, type RankDataType } from '@sammo-ts/common'; import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic'; import { accessAuthedProcedure } from '../../../trpc.js'; -import { resolveDedicationLevelName, sanitizeInternalDisplayCode } from '../../../services/gameDisplayNames.js'; +import { + loadCrewTypeDisplayNames, + resolveDedicationLevelName, + sanitizeInternalDisplayCode, +} from '../../../services/gameDisplayNames.js'; import { getMyGeneral } from '../../shared/general.js'; import { assertNationAccess, @@ -20,6 +24,25 @@ const experienceLevel = (experience: number, maxLevel: number): number => ); const dedicationLevel = (dedication: number, maxLevel: number): number => Math.max(0, Math.min(maxLevel, Math.ceil(Math.sqrt(dedication) / 10))); +const BATTLE_RECORD_TYPES = ['warnum', 'killnum', 'killcrew', 'deathcrew'] as const satisfies readonly RankDataType[]; +const defenceTrainText = (value: number): string => + value >= 999 ? '×' : value >= 90 ? '☆' : value >= 80 ? '◎' : value >= 60 ? '○' : '△'; +const honorText = (experience: number): string => { + if (experience < 640) return '전무'; + if (experience < 2_560) return '무명'; + if (experience < 5_760) return '신동'; + if (experience < 10_240) return '약간'; + if (experience < 16_000) return '평범'; + if (experience < 23_040) return '지역적'; + if (experience < 31_360) return '전국적'; + if (experience < 40_960) return '세계적'; + if (experience < 45_000) return '유명'; + if (experience < 51_840) return '명사'; + if (experience < 55_000) return '호걸'; + if (experience < 64_000) return '효웅'; + if (experience < 77_440) return '영웅'; + return '구세주'; +}; export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => { const general = await getMyGeneral(ctx); @@ -61,6 +84,12 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => { gold: true, rice: true, crew: true, + crewTypeId: true, + train: true, + atmos: true, + turnTime: true, + recentWarTime: true, + age: true, personalCode: true, specialCode: true, special2Code: true, @@ -86,26 +115,87 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => { }) : []; const accessByGeneral = new Map(accessRows.map((entry) => [entry.generalId, entry.refreshScoreTotal])); + const sourceByGeneral = new Map(generalRows.map((entry) => [entry.id, entry])); const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode); const permission = resolveNationPermission(general, nation.meta, true); + const generalIds = generalRows.map((entry) => entry.id); + const [crewTypeNames, turns, rankRows] = + permission >= 1 + ? await Promise.all([ + loadCrewTypeDisplayNames(worldState, ctx.profile.id), + generalIds.length + ? ctx.db.generalTurn.findMany({ + where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, + select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }) + : [], + generalIds.length + ? ctx.db.rankData.findMany({ + where: { generalId: { in: generalIds }, type: { in: [...BATTLE_RECORD_TYPES] } }, + select: { generalId: true, type: true, value: true }, + }) + : [], + ]) + : [new Map(), [], []]; + const turnsByGeneral = new Map>(); + for (const turn of turns) { + const entries = turnsByGeneral.get(turn.generalId) ?? []; + entries[turn.turnIdx] = { action: turn.actionCode, args: turn.arg }; + turnsByGeneral.set(turn.generalId, entries); + } + const ranksByGeneral = new Map>(); + for (const row of rankRows) { + const entries = ranksByGeneral.get(row.generalId) ?? new Map(); + entries.set(row.type as RankDataType, row.value); + ranksByGeneral.set(row.generalId, entries); + } const config = asRecord(worldState?.config); const constValues = asRecord(config.const); const maxExperienceLevel = Math.max(0, Math.trunc(asNumber(constValues.maxLevel, LEGACY_DEFAULT_MAX_LEVEL))); const maxDedicationLevel = Math.max(0, Math.trunc(asNumber(constValues.maxDedLevel, 30))); const visibleList = list.map((entry) => { + const source = sourceByGeneral.get(entry.id); + const meta = asRecord(source?.meta); + const ownerNameRaw = meta.ownerName ?? meta.owner_name; const entryDedicationLevel = dedicationLevel(entry.dedication, maxDedicationLevel); const dedicationDisplay = { dedicationLevel: entryDedicationLevel, dedicationText: resolveDedicationLevelName(entryDedicationLevel, maxDedicationLevel), bill: entryDedicationLevel * 200 + 400, }; - const { permission: _targetPermission, ...safeEntry } = entry; + const { permission: _targetPermission, ...mappedEntry } = entry; + const safeEntry = { + ...mappedEntry, + ownerName: entry.npcState === 1 && typeof ownerNameRaw === 'string' ? ownerNameRaw : null, + age: source?.age ?? 0, + killTurn: asNumber(meta.killturn ?? meta.killTurn, 0), + }; if (permission >= 1) { + const defenceTrain = asNumber(meta.defence_train ?? meta.defenceTrain, 80); + const rankValue = (type: (typeof BATTLE_RECORD_TYPES)[number]): number => + ranksByGeneral.get(entry.id)?.get(type) ?? asNumber(meta[`rank_${type}`] ?? meta[type], 0); return { ...safeEntry, refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0, experienceLevel: experienceLevel(entry.experience, maxExperienceLevel), + honorText: honorText(entry.experience), ...dedicationDisplay, + crewTypeId: source?.crewTypeId ?? 0, + crewTypeName: crewTypeNames.get(source?.crewTypeId ?? 0) ?? '-', + train: source?.train ?? 0, + atmos: source?.atmos ?? 0, + turnTime: source?.turnTime.toISOString() ?? null, + recentWar: source?.recentWarTime?.toISOString() ?? null, + defenceTrain, + defenceTrainText: defenceTrainText(defenceTrain), + reservedCommands: entry.npcState < 2 ? (turnsByGeneral.get(entry.id) ?? []) : [], + battleStats: { + battles: rankValue('warnum'), + wins: rankValue('killnum'), + killCrew: rankValue('killcrew'), + deathCrew: rankValue('deathcrew'), + }, }; } const { crew: _crew, experience: _experience, dedication: _dedication, ...visible } = safeEntry; @@ -118,6 +208,7 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => { officerCity: 0, officerCityName: null, experienceLevel: experienceLevel(entry.experience, maxExperienceLevel), + honorText: honorText(entry.experience), ...dedicationDisplay, }; }); diff --git a/app/game-api/test/nationGeneralSecretRouter.test.ts b/app/game-api/test/nationGeneralSecretRouter.test.ts index 84c2b333..9773377c 100644 --- a/app/game-api/test/nationGeneralSecretRouter.test.ts +++ b/app/game-api/test/nationGeneralSecretRouter.test.ts @@ -99,6 +99,14 @@ const fixture = (generals: GeneralRow[], userId = 'u1', maxLevel?: number) => { generalAccessLog: { findMany: vi.fn(async () => generals.map((g) => ({ generalId: g.id, refreshScoreTotal: g.id * 10 }))), }, + rankData: { + findMany: vi.fn(async () => [ + { generalId: 1, type: 'warnum', value: 12 }, + { generalId: 1, type: 'killnum', value: 7 }, + { generalId: 1, type: 'killcrew', value: 2400 }, + { generalId: 1, type: 'deathcrew', value: 1200 }, + ]), + }, }; const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; const context: GameApiContext = { @@ -132,8 +140,12 @@ describe('nation general and secret office permissions', () => { dedicationText: '30품관', bill: 600, experienceLevel: 120, + honorText: '구세주', + age: 20, + killTurn: 7, }); expect(result.generals[0]).not.toHaveProperty('crew'); + expect(result.generals[0]).not.toHaveProperty('reservedCommands'); await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' }); }); it('uses the session-owned general and scopes secret rows to that nation', async () => { @@ -158,6 +170,15 @@ describe('nation general and secret office permissions', () => { expect(result.generals[0]?.reservedCommands).toEqual([ { action: 'che_징병', args: { crewType: 1, amount: 300 } }, ]); + const directory = await caller.nation.getGeneralList(); + expect(directory.generals[0]).toMatchObject({ + crewTypeName: expect.any(String), + train: 90, + atmos: 90, + defenceTrain: 80, + reservedCommands: [{ action: 'che_징병', args: { crewType: 1, amount: 300 } }], + battleStats: { battles: 12, wins: 7, killCrew: 2400, deathCrew: 1200 }, + }); expect(db.generalTurn.findMany).toHaveBeenCalledWith( expect.objectContaining({ select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index 4accc26e..600f5ce8 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -49,6 +49,7 @@ const general = { dedicationLevel: 1, dedicationText: '30품관', bill: 600, + honorText: '무명', injury: 0, gold: 1000, rice: 2000, @@ -56,6 +57,9 @@ const general = { specialDomestic: null, specialWar: null, belong: 1, + age: 20, + killTurn: 7, + ownerName: null, refreshScoreTotal: 10, permission: 'normal', }; @@ -76,7 +80,48 @@ const otherGeneral = { specialWar: { key: '돌격', name: '돌격', info: '전투 특기' }, belong: 4, refreshScoreTotal: 20, + honorText: '신동', + age: 24, + killTurn: 3, + ownerName: '소유자', }; +const nationDetailedGenerals = [ + { + ...general, + cityName: '업', + troopName: null, + crew: 300, + crewTypeId: 1, + crewTypeName: '보병', + train: 90, + atmos: 80, + defenceTrain: 90, + defenceTrainText: '☆', + turnTime: '2026-01-01T01:02:00.000Z', + recentWar: '2026-01-01T00:59:00.000Z', + reservedCommands: [ + { action: 'che_이동', args: { destCityId: 1 } }, + { action: 'che_징병', args: { crewType: 1, amount: 300 } }, + ], + battleStats: { battles: 12, wins: 7, killCrew: 2400, deathCrew: 1200 }, + }, + { + ...otherGeneral, + cityName: '낙양', + troopName: '제1부대', + crew: 100, + crewTypeId: 2, + crewTypeName: '기병', + train: 80, + atmos: 70, + defenceTrain: 80, + defenceTrainText: '◎', + turnTime: '2026-01-01T02:02:00.000Z', + recentWar: null, + reservedCommands: [], + battleStats: { battles: 5, wins: 2, killCrew: 500, deathCrew: 1000 }, + }, +]; const npcColorStates = [0, 1, 2, 4, 5, 6] as const; const npcColorGenerals = npcColorStates.map((npcState) => ({ ...general, @@ -108,7 +153,7 @@ const npcColorSecretGenerals = npcColorStates.map((npcState) => ({ turnTime: `2026-01-01T01:0${npcState}:00.000Z`, reservedCommands: [], })); -const install = async (page: Page, secretAllowed = true, npcColorFixture = false) => { +const install = async (page: Page, secretAllowed = true, npcColorFixture = false, nationPermission = 0) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_general'); localStorage.setItem('sammo-game-profile', profile); @@ -121,8 +166,12 @@ const install = async (page: Page, secretAllowed = true, npcColorFixture = false if (operation === 'nation.getGeneralList') return response({ nation: { id: 1, name: '위', color: '#008000', level: 3 }, - viewer: { generalId: 1, permission: 0 }, - generals: npcColorFixture ? npcColorGenerals : [general, otherGeneral], + viewer: { generalId: 1, permission: nationPermission }, + generals: npcColorFixture + ? npcColorGenerals + : nationPermission >= 1 + ? nationDetailedGenerals + : [general, otherGeneral], }); if (operation === 'nation.getSecretGeneralList') { if (!secretAllowed) @@ -276,6 +325,45 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns', await expect(page.locator('#nation-general-list')).toContainText('?'); }); +test('nation generals restores Ref private columns and keeps them reachable through column selection', async ({ + page, +}, testInfo) => { + await install(page, true, false, 1); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/generals'); + const table = page.locator('#nation-general-list'); + await expect(table).toContainText('20세'); + await expect(table).toContainText('무명'); + + await page.getByRole('button', { name: '보기 모드⌄' }).click(); + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(table).toContainText('보병'); + await expect(table).toContainText('300명'); + await expect(table).toContainText('【업】으로 이동'); + await expect(table).toContainText('90'); + await expect(table).toContainText('80'); + await expect(table).toContainText('7턴'); + + await page.getByRole('button', { name: '열 선택⌄' }).click(); + await page.getByLabel('최근전투', { exact: true }).check(); + await page.getByLabel('전투', { exact: true }).check(); + await page.getByLabel('승리', { exact: true }).check(); + await page.getByLabel('살상률', { exact: true }).check(); + await page.getByRole('button', { name: '열 선택⌄' }).click(); + await expect(page.getByRole('button', { name: '전과 펼치기' })).toBeVisible(); + await page.getByRole('button', { name: '전과 펼치기' }).click(); + await expect(table).toContainText('12전'); + await expect(table).toContainText('7승'); + await expect(table).toContainText('200%'); + await expect(table).toContainText('59:00'); + + await page.screenshot({ path: testInfo.outputPath('nation-general-restored-columns-desktop.png'), fullPage: true }); + await page.setViewportSize({ width: 500, height: 900 }); + await expect(table).toContainText('【보병】 300명 징병'); + expect((await table.boundingBox())?.width).toBeGreaterThan(1000); + await page.screenshot({ path: testInfo.outputPath('nation-general-restored-columns-mobile.png'), fullPage: true }); +}); + test('nation generals top controls share fixed Lumen state geometry on desktop and mobile', async ({ page, }, testInfo) => { diff --git a/app/game-frontend/src/utils/nationGeneralGrid.ts b/app/game-frontend/src/utils/nationGeneralGrid.ts index c204d4f7..1e48c4cb 100644 --- a/app/game-frontend/src/utils/nationGeneralGrid.ts +++ b/app/game-frontend/src/utils/nationGeneralGrid.ts @@ -14,17 +14,43 @@ export type NationGeneralColumnId = | 'gold' | 'rice' | 'city' + | 'crewtypeAndCrew_1' + | 'crewtype' | 'crew' + | 'trainAtmos_1' + | 'train' + | 'atmos' + | 'defence_train' | 'specials_1' | 'personal' | 'specialDomestic' | 'specialWar' + | 'reservedCommandShort_1' + | 'reservedCommand' + | 'turntime' + | 'recent_war' | 'years_1' + | 'age' | 'belong' | 'killturnAndRefresh_1' - | 'refreshScoreTotal'; + | 'killturn' + | 'refreshScoreTotal' + | 'warResults_1' + | 'warnum' + | 'killnum' + | 'killcrew'; -export type NationGeneralGroupId = 'expDedLv' | 'stat' | 'goldRice' | 'specials' | 'years' | 'killturnAndRefresh'; +export type NationGeneralGroupId = + | 'expDedLv' + | 'stat' + | 'goldRice' + | 'crewtypeAndCrew' + | 'trainAtmos' + | 'specials' + | 'reservedCommandShort' + | 'years' + | 'killturnAndRefresh' + | 'warResults'; export type SortDirection = 'asc' | 'desc'; export type NationGeneralViewMode = 'normal' | 'war'; @@ -110,19 +136,48 @@ const baseColumns = (): NationGeneralColumnState[] => [ { colId: 'gold', width: 70, hide: false, sort: null }, { colId: 'rice', width: 70, hide: false, sort: null }, { colId: 'city', width: 60, hide: true, sort: null }, + { colId: 'crewtypeAndCrew_1', width: 80, hide: true, sort: null }, + { colId: 'crewtype', width: 80, hide: true, sort: null }, { colId: 'crew', width: 70, hide: true, sort: null }, + { colId: 'trainAtmos_1', width: 60, hide: true, sort: null }, + { colId: 'train', width: 70, hide: true, sort: null }, + { colId: 'atmos', width: 70, hide: true, sort: null }, + { colId: 'defence_train', width: 50, hide: true, sort: null }, { colId: 'specials_1', width: 80, hide: false, sort: null }, { colId: 'personal', width: 60, hide: false, sort: null }, { colId: 'specialDomestic', width: 60, hide: false, sort: null }, { colId: 'specialWar', width: 60, hide: false, sort: null }, + { colId: 'reservedCommandShort_1', width: 70, hide: true, sort: null }, + { colId: 'reservedCommand', width: 120, hide: true, sort: null }, + { colId: 'turntime', width: 60, hide: true, sort: null }, + { colId: 'recent_war', width: 60, hide: true, sort: null }, { colId: 'years_1', width: 60, hide: false, sort: null }, + { colId: 'age', width: 60, hide: false, sort: null }, { colId: 'belong', width: 60, hide: false, sort: null }, { colId: 'killturnAndRefresh_1', width: 70, hide: false, sort: null }, + { colId: 'killturn', width: 70, hide: true, sort: null }, { colId: 'refreshScoreTotal', width: 70, hide: false, sort: null }, + { colId: 'warResults_1', width: 90, hide: true, sort: null }, + { colId: 'warnum', width: 60, hide: true, sort: null }, + { colId: 'killnum', width: 60, hide: true, sort: null }, + { colId: 'killcrew', width: 60, hide: true, sort: null }, ]; const groupState = (overrides: Partial>): NationGeneralGroupState[] => - (['expDedLv', 'stat', 'goldRice', 'specials', 'years', 'killturnAndRefresh'] as const).map((groupId) => ({ + ( + [ + 'expDedLv', + 'stat', + 'goldRice', + 'crewtypeAndCrew', + 'trainAtmos', + 'specials', + 'reservedCommandShort', + 'years', + 'killturnAndRefresh', + 'warResults', + ] as const + ).map((groupId) => ({ groupId, open: overrides[groupId] ?? false, })); @@ -148,9 +203,13 @@ export const defaultNationGeneralDisplaySettings: Record +import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { useRouter } from 'vue-router'; +import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief'; +import type { CommandTable } from '../components/command/types'; import { formatOfficerLevelText } from '../utils/nationFormat'; import { getNpcColor } from '../utils/npcColor'; import { resolveGeneralIconUrl } from '../utils/generalIcon'; @@ -80,7 +83,20 @@ const columns: ColumnDefinition[] = [ { id: 'gold', label: '금', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' }, { id: 'rice', label: '쌀', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' }, { id: 'city', label: '도시', width: 60, sortable: true, searchable: 'text' }, - { id: 'crew', label: '병력', width: 70, sortable: true, searchable: 'number' }, + { id: 'crewtypeAndCrew_1', label: '병종', width: 80, groupId: 'crewtypeAndCrew', summary: true }, + { id: 'crewtype', label: '병종', width: 80, groupId: 'crewtypeAndCrew', sortable: true, searchable: 'text' }, + { id: 'crew', label: '병력', width: 70, groupId: 'crewtypeAndCrew', sortable: true, searchable: 'number' }, + { id: 'trainAtmos_1', label: '훈/사', width: 60, groupId: 'trainAtmos', summary: true }, + { id: 'train', label: '훈련', width: 70, groupId: 'trainAtmos', sortable: true, searchable: 'number' }, + { id: 'atmos', label: '사기', width: 70, groupId: 'trainAtmos', sortable: true, searchable: 'number' }, + { + id: 'defence_train', + label: '수비', + width: 50, + groupId: 'trainAtmos', + sortable: true, + searchable: 'number', + }, { id: 'specials_1', label: '요약', width: 80, groupId: 'specials', summary: true }, { id: 'personal', label: '성격', width: 60, groupId: 'specials', sortable: true, searchable: 'text' }, { @@ -92,9 +108,22 @@ const columns: ColumnDefinition[] = [ searchable: 'text', }, { id: 'specialWar', label: '전특', width: 60, groupId: 'specials', sortable: true, searchable: 'text' }, + { id: 'reservedCommandShort_1', label: '단축', width: 70, groupId: 'reservedCommandShort', summary: true }, + { id: 'reservedCommand', label: '전체', width: 120, groupId: 'reservedCommandShort' }, + { id: 'turntime', label: '턴', width: 60, sortable: true }, + { id: 'recent_war', label: '최근전투', width: 60, sortable: true }, { id: 'years_1', label: '요약', width: 60, groupId: 'years', summary: true }, + { id: 'age', label: '연령', width: 60, groupId: 'years', sortable: true, searchable: 'number' }, { id: 'belong', label: '사관', width: 60, groupId: 'years', sortable: true, searchable: 'number' }, - { id: 'killturnAndRefresh_1', label: '벌점', width: 70, groupId: 'killturnAndRefresh', summary: true }, + { id: 'killturnAndRefresh_1', label: '삭/벌', width: 70, groupId: 'killturnAndRefresh', summary: true }, + { + id: 'killturn', + label: '삭턴', + width: 70, + groupId: 'killturnAndRefresh', + sortable: true, + searchable: 'number', + }, { id: 'refreshScoreTotal', label: '벌점', @@ -103,6 +132,10 @@ const columns: ColumnDefinition[] = [ sortable: true, searchable: 'number', }, + { id: 'warResults_1', label: '요약', width: 90, groupId: 'warResults', summary: true }, + { id: 'warnum', label: '전투', width: 60, groupId: 'warResults', sortable: true, searchable: 'number' }, + { id: 'killnum', label: '승리', width: 60, groupId: 'warResults', sortable: true, searchable: 'number' }, + { id: 'killcrew', label: '살상률', width: 60, groupId: 'warResults', sortable: true, searchable: 'number' }, ]; const layout: LayoutItem[] = [ @@ -132,7 +165,20 @@ const layout: LayoutItem[] = [ children: ['gold', 'rice'], }, { type: 'column', columnId: 'city' }, - { type: 'column', columnId: 'crew' }, + { + type: 'group', + groupId: 'crewtypeAndCrew', + label: '보유 병력', + summaryId: 'crewtypeAndCrew_1', + children: ['crewtype', 'crew'], + }, + { + type: 'group', + groupId: 'trainAtmos', + label: '훈/사', + summaryId: 'trainAtmos_1', + children: ['train', 'atmos', 'defence_train'], + }, { type: 'group', groupId: 'specials', @@ -140,18 +186,35 @@ const layout: LayoutItem[] = [ summaryId: 'specials_1', children: ['personal', 'specialDomestic', 'specialWar'], }, - { type: 'group', groupId: 'years', label: '연도', summaryId: 'years_1', children: ['belong'] }, + { + type: 'group', + groupId: 'reservedCommandShort', + label: '명령', + summaryId: 'reservedCommandShort_1', + children: ['reservedCommand'], + }, + { type: 'column', columnId: 'turntime' }, + { type: 'column', columnId: 'recent_war' }, + { type: 'group', groupId: 'years', label: '연도', summaryId: 'years_1', children: ['age', 'belong'] }, { type: 'group', groupId: 'killturnAndRefresh', label: '기타', summaryId: 'killturnAndRefresh_1', - children: ['refreshScoreTotal'], + children: ['killturn', 'refreshScoreTotal'], + }, + { + type: 'group', + groupId: 'warResults', + label: '전과', + summaryId: 'warResults_1', + children: ['warnum', 'killnum', 'killcrew'], }, ]; const columnById = new Map(columns.map((column) => [column.id, column])); const data = ref(null); +const commandTable = ref(null); const router = useRouter(); const error = ref(''); const loading = ref(false); @@ -164,9 +227,13 @@ const groupState = ref>({ expDedLv: true, stat: true, goldRice: true, + crewtypeAndCrew: false, + trainAtmos: false, specials: false, + reservedCommandShort: false, years: false, killturnAndRefresh: true, + warResults: false, }); const createFilterCondition = (searchable?: 'text' | 'number'): NationGeneralFilterCondition => ({ operator: searchable === 'number' ? 'equals' : 'contains', @@ -250,7 +317,16 @@ const load = async () => { loading.value = true; error.value = ''; try { - data.value = await trpc.nation.getGeneralList.query(); + const result = await trpc.nation.getGeneralList.query(); + data.value = result; + commandTable.value = null; + if (result.viewer.permission >= 1) { + try { + commandTable.value = await trpc.turns.getCommandTable.query({ generalId: result.viewer.generalId }); + } catch { + commandTable.value = null; + } + } } catch (cause) { error.value = cause instanceof Error ? cause.message : '세력 장수를 불러오지 못했습니다.'; } finally { @@ -323,6 +399,37 @@ const officerText = (general: General): string => { : title; }; const protectedText = (value: string | null): string => value ?? (data.value?.viewer.permission ? '-' : '?'); +type DetailedGeneralFields = { + crewTypeName: string; + train: number; + atmos: number; + defenceTrain: number; + defenceTrainText: string; + turnTime: string | null; + recentWar: string | null; + reservedCommands: Array<{ action: string; args: unknown }>; + battleStats: { battles: number; wins: number; killCrew: number; deathCrew: number }; +}; +const details = (general: General): Partial => + general as General & Partial; +const commandShortName = (action: string): string => action.replace(/^(?:che_|cr_|event_)/u, '') || action; +const commandText = (general: General, short: boolean): string => { + const commands = details(general).reservedCommands; + if (!commands) return '?'; + if (general.npcState >= 2) return 'NPC 장수'; + if (!commands.length) return '-'; + return commands + .map((command) => + short + ? commandShortName(command.action) + : formatReservedCommandBrief('general', command.action, command.args, commandTable.value) + ) + .join('\n'); +}; +const killRate = (general: General): number | null => { + const stats = details(general).battleStats; + return stats ? Math.round((stats.killCrew / Math.max(1, stats.deathCrew)) * 100) : null; +}; const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue => { switch (columnId) { @@ -335,7 +442,7 @@ const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue case 'dedlevel': return `${general.dedicationText}\n(${general.bill.toLocaleString()})`; case 'explevel': - return `Lv ${general.experienceLevel}\n(${general.personality?.name ?? '-'})`; + return `Lv ${general.experienceLevel}\n(${general.honorText})`; case 'stat_1': return `${general.stats.leadership}|${general.stats.strength}|${general.stats.intelligence}`; case 'leadership': @@ -354,8 +461,26 @@ const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue return general.rice; case 'city': return protectedText(general.cityName); + case 'crewtypeAndCrew_1': { + const crewTypeName = details(general).crewTypeName; + return crewTypeName === undefined + ? '?' + : `${crewTypeName}\n${visibleCrew(general)?.toLocaleString() ?? 0}명`; + } + case 'crewtype': + return details(general).crewTypeName ?? '?'; case 'crew': return visibleCrew(general); + case 'trainAtmos_1': { + const detail = details(general); + return detail.train === undefined ? '?' : `${detail.train}\n${detail.atmos}`; + } + case 'train': + return details(general).train ?? null; + case 'atmos': + return details(general).atmos ?? null; + case 'defence_train': + return details(general).defenceTrainText ?? '?'; case 'specials_1': return `${general.personality?.name ?? '-'}\n${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`; case 'personal': @@ -364,13 +489,38 @@ const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue return general.specialDomestic?.name ?? '-'; case 'specialWar': return general.specialWar?.name ?? '-'; + case 'reservedCommandShort_1': + return commandText(general, true); + case 'reservedCommand': + return commandText(general, false); + case 'turntime': + return formatServerDateTime(details(general).turnTime, { format: 'minuteSecond', fallback: '?' }); + case 'recent_war': + return formatServerDateTime(details(general).recentWar, { format: 'minuteSecond', fallback: '-' }); case 'years_1': - return `${general.belong}년`; + return `${general.age}세\n${general.belong}년`; + case 'age': + return general.age; case 'belong': return general.belong; case 'killturnAndRefresh_1': + return `${general.killTurn.toLocaleString()}턴\n${general.refreshScoreTotal.toLocaleString()}점`; + case 'killturn': + return general.killTurn; case 'refreshScoreTotal': return Number(general.refreshScoreTotal); + case 'warResults_1': { + const stats = details(general).battleStats; + return stats + ? `${stats.battles.toLocaleString()}전 ${stats.wins.toLocaleString()}승\n살상: ${killRate(general)}%` + : '?'; + } + case 'warnum': + return details(general).battleStats?.battles ?? null; + case 'killnum': + return details(general).battleStats?.wins ?? null; + case 'killcrew': + return killRate(general); case 'icon': return null; } @@ -401,6 +551,12 @@ const sortValue = (general: General, columnId: NationGeneralColumnId): CellValue return general.experienceLevel; case 'goldRice_1': return general.gold + general.rice; + case 'defence_train': + return details(general).defenceTrain ?? null; + case 'turntime': + return details(general).turnTime ?? null; + case 'recent_war': + return details(general).recentWar ?? null; default: return cellValue(general, columnId); } @@ -471,9 +627,20 @@ const toggleGroup = (groupId: NationGeneralGroupId) => { }; const toggleColumn = (columnId: NationGeneralColumnId) => { - columnState.value = columnState.value.map((column) => - column.colId === columnId ? { ...column, hide: !column.hide } : column + const nextVisible = stateById.value.get(columnId)?.hide ?? true; + const parent = layout.find( + (item): item is Extract => + item.type === 'group' && item.children.includes(columnId) ); + columnState.value = columnState.value.map((column) => { + if (column.colId === columnId) return { ...column, hide: !nextVisible }; + if (!parent || column.colId !== parent.summaryId) return column; + if (nextVisible) return { ...column, hide: false }; + const hasOtherVisibleChild = parent.children.some( + (childId) => childId !== columnId && isColumnVisible(childId) + ); + return hasOtherVisibleChild ? column : { ...column, hide: true }; + }); }; const nextSort = (columnId: NationGeneralColumnId, current: 'asc' | 'desc' | null): 'asc' | 'desc' | null => { @@ -785,7 +952,7 @@ onBeforeUnmount(() => { 'name-cell': column.id === 'name', 'numeric-cell': column.searchable === 'number' || - ['goldRice_1', 'killturnAndRefresh_1'].includes(column.id), + ['goldRice_1', 'killturnAndRefresh_1', 'warResults_1'].includes(column.id), }" :title="cellTitle(general, column.id)" > @@ -804,16 +971,28 @@ onBeforeUnmount(() => { }} + -