feat: 세력 장수 누락 정보를 복원한다
Ref 권한 경계에 맞춰 연령, 삭턴, 병력, 훈련, 예약 명령, 턴 시각과 전과를 투영한다. 열 선택과 기본/전투 보기에서 새 그룹을 사용할 수 있게 하고 기존 저장 설정을 보존한다.
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 },
|
||||
|
||||
Reference in New Issue
Block a user