merge: 세력 장수 누락 정보를 통합한다
This commit is contained in:
@@ -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<number, string>(), [], []];
|
||||
const turnsByGeneral = new Map<number, Array<{ action: string; args: unknown }>>();
|
||||
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<number, Map<RankDataType, number>>();
|
||||
for (const row of rankRows) {
|
||||
const entries = ranksByGeneral.get(row.generalId) ?? new Map<RankDataType, number>();
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<Record<NationGeneralGroupId, boolean>>): 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<NationGeneralViewMode,
|
||||
expDedLv: true,
|
||||
stat: true,
|
||||
goldRice: true,
|
||||
crewtypeAndCrew: false,
|
||||
trainAtmos: false,
|
||||
specials: false,
|
||||
reservedCommandShort: false,
|
||||
years: false,
|
||||
killturnAndRefresh: true,
|
||||
warResults: false,
|
||||
}),
|
||||
},
|
||||
war: {
|
||||
@@ -163,22 +222,36 @@ export const defaultNationGeneralDisplaySettings: Record<NationGeneralViewMode,
|
||||
troop: { hide: false },
|
||||
city: { hide: false },
|
||||
crew: { hide: false },
|
||||
crewtypeAndCrew_1: { hide: false },
|
||||
crewtype: { hide: false },
|
||||
trainAtmos_1: { hide: false },
|
||||
train: { hide: false },
|
||||
atmos: { hide: false },
|
||||
defence_train: { hide: false },
|
||||
specials_1: { hide: true },
|
||||
personal: { hide: true },
|
||||
specialDomestic: { hide: true },
|
||||
specialWar: { hide: true },
|
||||
years_1: { hide: true },
|
||||
belong: { hide: true },
|
||||
reservedCommandShort_1: { hide: false },
|
||||
reservedCommand: { hide: false },
|
||||
turntime: { hide: false, sort: 'asc', sortIndex: 0 },
|
||||
killturnAndRefresh_1: { hide: true },
|
||||
killturn: { hide: false },
|
||||
refreshScoreTotal: { hide: true },
|
||||
}),
|
||||
columnGroup: groupState({
|
||||
expDedLv: false,
|
||||
stat: false,
|
||||
goldRice: true,
|
||||
crewtypeAndCrew: false,
|
||||
trainAtmos: false,
|
||||
specials: false,
|
||||
reservedCommandShort: true,
|
||||
years: false,
|
||||
killturnAndRefresh: true,
|
||||
warResults: false,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
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<Result | null>(null);
|
||||
const commandTable = ref<CommandTable | null>(null);
|
||||
const router = useRouter();
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
@@ -164,9 +227,13 @@ const groupState = ref<Record<NationGeneralGroupId, boolean>>({
|
||||
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<DetailedGeneralFields> =>
|
||||
general as General & Partial<DetailedGeneralFields>;
|
||||
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<LayoutItem, { type: 'group' }> =>
|
||||
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(() => {
|
||||
}}<span v-if="visibleCrew(general) !== null">명</span>
|
||||
</template>
|
||||
<template v-else-if="column.id === 'belong'">{{ general.belong }}년</template>
|
||||
<template v-else-if="column.id === 'age'">{{ general.age }}세</template>
|
||||
<template v-else-if="column.id === 'name'">
|
||||
<span data-general-name :style="{ color: getNpcColor(general.npcState) }">{{
|
||||
general.name
|
||||
}}</span>
|
||||
}}</span
|
||||
><template v-if="general.ownerName"
|
||||
><br /><small>({{ general.ownerName }})</small></template
|
||||
>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="column.id === 'refreshScoreTotal' || column.id === 'killturnAndRefresh_1'"
|
||||
>
|
||||
<template v-else-if="column.id === 'refreshScoreTotal'">
|
||||
{{ general.refreshScoreTotal.toLocaleString() }}점
|
||||
</template>
|
||||
<template v-else-if="column.id === 'killturn'">
|
||||
{{ general.killTurn.toLocaleString() }}턴
|
||||
</template>
|
||||
<template v-else-if="column.id === 'warnum'">
|
||||
{{ details(general).battleStats?.battles.toLocaleString() ?? '?' }}전
|
||||
</template>
|
||||
<template v-else-if="column.id === 'killnum'">
|
||||
{{ details(general).battleStats?.wins.toLocaleString() ?? '?' }}승
|
||||
</template>
|
||||
<template v-else-if="column.id === 'killcrew'"> {{ killRate(general) ?? '?' }}% </template>
|
||||
<template v-else>{{ cellValue(general, column.id) }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -21,6 +21,8 @@ void describe('nation general Ref-compatible grid state', () => {
|
||||
const normal = defaultNationGeneralDisplaySettings.normal;
|
||||
assert.equal(normal.columnGroup.find((entry) => entry.groupId === 'stat')?.open, true);
|
||||
assert.equal(normal.columnGroup.find((entry) => entry.groupId === 'specials')?.open, false);
|
||||
assert.equal(normal.column.find((entry) => entry.colId === 'age')?.hide, false);
|
||||
assert.equal(normal.column.find((entry) => entry.colId === 'reservedCommand')?.hide, true);
|
||||
assert.deepEqual(
|
||||
normal.column.filter((entry) => entry.sort).map((entry) => [entry.colId, entry.sort, entry.sortIndex]),
|
||||
[['refreshScoreTotal', 'desc', 0]]
|
||||
@@ -29,6 +31,9 @@ void describe('nation general Ref-compatible grid state', () => {
|
||||
assert.equal(war.columnGroup.find((entry) => entry.groupId === 'stat')?.open, false);
|
||||
assert.equal(war.column.find((entry) => entry.colId === 'stat_1')?.hide, false);
|
||||
assert.equal(war.column.find((entry) => entry.colId === 'leadership')?.hide, false);
|
||||
assert.equal(war.column.find((entry) => entry.colId === 'crewtypeAndCrew_1')?.hide, false);
|
||||
assert.equal(war.column.find((entry) => entry.colId === 'reservedCommand')?.hide, false);
|
||||
assert.equal(war.column.find((entry) => entry.colId === 'turntime')?.sort, 'asc');
|
||||
});
|
||||
|
||||
void it('round-trips named settings and rejects invalid versions', () => {
|
||||
|
||||
Reference in New Issue
Block a user