merge: 부대편성 장수 패널 재사용을 main에 반영한다
This commit is contained in:
@@ -1,12 +1,43 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic';
|
||||
import { asRecord, type RankDataType, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import {
|
||||
getBillByLevel,
|
||||
isValidTroopNameWidth,
|
||||
normalizeTroopName,
|
||||
resolveTroopSecretPermission,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { accessAuthedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||
import {
|
||||
loadCrewTypeDisplayNames,
|
||||
loadItemDisplayNames,
|
||||
resolveDedicationLevelName,
|
||||
resolveOfficerLevelName,
|
||||
sanitizeInternalDisplayCode,
|
||||
} from '../../services/gameDisplayNames.js';
|
||||
import {
|
||||
resolveGeneralTypeCall,
|
||||
resolveLeadershipBonus,
|
||||
resolveRefreshScoreText,
|
||||
resolveRemainingMinutes,
|
||||
} from '../../services/generalBasicCardProjection.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
|
||||
const TROOP_PANEL_RECORD_TYPES = [
|
||||
'firenum',
|
||||
'warnum',
|
||||
'killnum',
|
||||
'deathnum',
|
||||
'killcrew',
|
||||
'deathcrew',
|
||||
] as const satisfies readonly RankDataType[];
|
||||
|
||||
const readNumber = (value: unknown, fallback = 0): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
const troopNameSchema = z
|
||||
.string()
|
||||
.refine(isValidTroopNameWidth, '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.');
|
||||
@@ -42,7 +73,7 @@ export const troopRouter = router({
|
||||
const [nation, troops, generals, cities, worldState] = await Promise.all([
|
||||
ctx.db.nation.findUnique({
|
||||
where: { id: me.nationId },
|
||||
select: { id: true, name: true, meta: true },
|
||||
select: { id: true, name: true, color: true, level: true, meta: true },
|
||||
}),
|
||||
ctx.db.troop.findMany({
|
||||
where: { nationId: me.nationId },
|
||||
@@ -55,49 +86,140 @@ export const troopRouter = router({
|
||||
name: true,
|
||||
cityId: true,
|
||||
troopId: true,
|
||||
npcState: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
turnTime: true,
|
||||
recentWarTime: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
officerLevel: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
crew: true,
|
||||
train: true,
|
||||
atmos: true,
|
||||
injury: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
age: true,
|
||||
crewTypeId: true,
|
||||
weaponCode: true,
|
||||
bookCode: true,
|
||||
horseCode: true,
|
||||
itemCode: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.city.findMany({
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||
ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }),
|
||||
]);
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const permission = resolveTroopSecretPermission(me, nation.meta, false);
|
||||
const troopLeaderIds = troops.map((troop) => troop.troopLeaderId);
|
||||
const turns =
|
||||
const generalIds = generals.map((general) => general.id);
|
||||
const [turns, rankRows, accessRows] = await Promise.all([
|
||||
troopLeaderIds.length === 0
|
||||
? []
|
||||
: await ctx.db.generalTurn.findMany({
|
||||
: ctx.db.generalTurn.findMany({
|
||||
where: { generalId: { in: troopLeaderIds }, turnIdx: { lt: 5 } },
|
||||
select: { generalId: true, turnIdx: true, actionCode: true },
|
||||
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||
});
|
||||
}),
|
||||
permission < 1 || generalIds.length === 0
|
||||
? []
|
||||
: ctx.db.rankData.findMany({
|
||||
where: {
|
||||
generalId: { in: generalIds },
|
||||
type: { in: [...TROOP_PANEL_RECORD_TYPES] },
|
||||
},
|
||||
select: { generalId: true, type: true, value: true },
|
||||
}),
|
||||
permission < 1 || generalIds.length === 0
|
||||
? []
|
||||
: ctx.db.generalAccessLog.findMany({
|
||||
where: { generalId: { in: generalIds } },
|
||||
select: { generalId: true, refreshScore: true, refreshScoreTotal: true },
|
||||
}),
|
||||
]);
|
||||
const cityNames = new Map(cities.map((city) => [city.id, city.name]));
|
||||
const generalMap = new Map(generals.map((general) => [general.id, general]));
|
||||
const reservedByLeader = new Map<number, string[]>();
|
||||
const firstActionByLeader = new Map<number, string>();
|
||||
const rankValueMap = new Map<number, Map<RankDataType, number>>();
|
||||
const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row]));
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||
const scenarioStat = asRecord(worldConfig.stat);
|
||||
const chiefStatMin = readNumber(scenarioStat.chiefMin, 70);
|
||||
const statGradeLevel = readNumber(constValues.statGradeLevel, 5);
|
||||
const retirementYear = readNumber(constValues.retirementYear, 70);
|
||||
const maxDedicationLevel = Math.max(0, Math.trunc(readNumber(constValues.maxDedLevel, 30)));
|
||||
const statUpgradeLimit =
|
||||
typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit)
|
||||
? constValues.upgradeLimit
|
||||
: 30;
|
||||
for (const turn of turns) {
|
||||
if (!firstActionByLeader.has(turn.generalId)) {
|
||||
firstActionByLeader.set(turn.generalId, turn.actionCode);
|
||||
}
|
||||
const list = reservedByLeader.get(turn.generalId) ?? [];
|
||||
// Ref 부대 편성은 앞쪽 슬롯이 집합인지 여부만 공개하고 다른 명령은 가립니다.
|
||||
list.push(turn.actionCode === 'che_집합' ? '집합' : '-');
|
||||
reservedByLeader.set(turn.generalId, list);
|
||||
}
|
||||
for (const row of rankRows) {
|
||||
const values = rankValueMap.get(row.generalId) ?? new Map<RankDataType, number>();
|
||||
values.set(row.type as (typeof TROOP_PANEL_RECORD_TYPES)[number], row.value);
|
||||
rankValueMap.set(row.generalId, values);
|
||||
}
|
||||
|
||||
const [personalityNames, domesticNames, warNames, crewTypeNames, itemNames] =
|
||||
permission < 1
|
||||
? [new Map(), new Map(), new Map(), new Map(), new Map()]
|
||||
: await Promise.all([
|
||||
loadTraitNames(
|
||||
generals.map((general) => general.personalCode),
|
||||
'personality'
|
||||
),
|
||||
loadTraitNames(
|
||||
generals.map((general) => general.specialCode),
|
||||
'domestic'
|
||||
),
|
||||
loadTraitNames(
|
||||
generals.map((general) => general.special2Code),
|
||||
'war'
|
||||
),
|
||||
loadCrewTypeDisplayNames(worldState, ctx.profile.id),
|
||||
loadItemDisplayNames(
|
||||
generals.flatMap((general) => [
|
||||
general.weaponCode,
|
||||
general.bookCode,
|
||||
general.horseCode,
|
||||
general.itemCode,
|
||||
])
|
||||
),
|
||||
]);
|
||||
const traitName = (code: string, names: Map<string, { name: string }>): string =>
|
||||
names.get(code)?.name ?? sanitizeInternalDisplayCode(code);
|
||||
const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code);
|
||||
const worldMeta = asRecord(worldState?.meta);
|
||||
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
|
||||
const lastExecuted =
|
||||
rawLastExecuted instanceof Date
|
||||
? rawLastExecuted
|
||||
: typeof rawLastExecuted === 'string'
|
||||
? new Date(rawLastExecuted)
|
||||
: null;
|
||||
|
||||
const mappedTroops = troops
|
||||
.map((troop) => {
|
||||
@@ -123,19 +245,52 @@ export const troopRouter = router({
|
||||
.map((general) => {
|
||||
const meta = asRecord(general.meta);
|
||||
const metaNumber = (key: string): number => {
|
||||
const value = meta[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
return readNumber(meta[key]);
|
||||
};
|
||||
const stats = {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
};
|
||||
const storedDedicationLevel = metaNumber('dedlevel');
|
||||
const dedicationLevel =
|
||||
storedDedicationLevel > 0
|
||||
? storedDedicationLevel
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(Math.ceil(Math.sqrt(general.dedication) / 10), maxDedicationLevel)
|
||||
);
|
||||
const rankValue = (
|
||||
type: (typeof TROOP_PANEL_RECORD_TYPES)[number],
|
||||
fallbackKeys: string[] = []
|
||||
): number => {
|
||||
const stored = rankValueMap.get(general.id)?.get(type);
|
||||
if (stored !== undefined) return stored;
|
||||
for (const key of fallbackKeys) {
|
||||
const fallback = meta[key];
|
||||
if (typeof fallback === 'number' && Number.isFinite(fallback)) return fallback;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
const officerCityId = readNumber(
|
||||
meta.officerCity ?? meta.officer_city ?? meta.officerCityId
|
||||
);
|
||||
const access = accessByGeneral.get(general.id);
|
||||
const refreshScore = access?.refreshScore ?? 0;
|
||||
const refreshScoreTotal = access?.refreshScoreTotal ?? 0;
|
||||
const firstAction = firstActionByLeader.get(troop.troopLeaderId);
|
||||
const troopStatus: 'inactive' | 'present' | 'away' =
|
||||
firstAction !== undefined && firstAction !== 'che_집합'
|
||||
? 'inactive'
|
||||
: leader?.cityId === general.cityId
|
||||
? 'present'
|
||||
: 'away';
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
cityId: general.cityId,
|
||||
cityName: cityNames.get(general.cityId) ?? '알 수 없음',
|
||||
stats: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
},
|
||||
stats,
|
||||
experience: general.experience,
|
||||
progression: {
|
||||
experienceLevel: metaNumber('explevel'),
|
||||
@@ -147,6 +302,114 @@ export const troopRouter = router({
|
||||
statUpgradeLimit,
|
||||
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
|
||||
},
|
||||
panel:
|
||||
permission < 1
|
||||
? null
|
||||
: {
|
||||
general: {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
picture: general.picture,
|
||||
imageServer: general.imageServer,
|
||||
npcState: general.npcState,
|
||||
officerLevel: general.officerLevel,
|
||||
officerLevelText: resolveOfficerLevelName(
|
||||
general.officerLevel,
|
||||
nation.level
|
||||
),
|
||||
officerCityName:
|
||||
general.officerLevel >= 2 && general.officerLevel <= 4
|
||||
? (cityNames.get(officerCityId) ?? null)
|
||||
: null,
|
||||
generalType: resolveGeneralTypeCall(
|
||||
stats,
|
||||
chiefStatMin,
|
||||
statGradeLevel
|
||||
),
|
||||
leadershipBonus: resolveLeadershipBonus(
|
||||
general.officerLevel,
|
||||
nation.level
|
||||
),
|
||||
stats,
|
||||
gold: general.gold,
|
||||
rice: general.rice,
|
||||
crew: general.crew,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
injury: general.injury,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
age: general.age,
|
||||
retirementYear,
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
defenceTrain: readNumber(meta.defence_train, 80),
|
||||
killTurn: readNumber(meta.killturn ?? meta.killTurn),
|
||||
remainingMinutes: resolveRemainingMinutes(
|
||||
general.turnTime,
|
||||
lastExecuted,
|
||||
worldState?.tickSeconds ?? 0
|
||||
),
|
||||
troopId: general.troopId,
|
||||
troop: {
|
||||
name: troop.name,
|
||||
status: troopStatus,
|
||||
leaderCityName:
|
||||
leader && leader.cityId !== general.cityId
|
||||
? (cityNames.get(leader.cityId) ?? null)
|
||||
: null,
|
||||
},
|
||||
refreshScore: {
|
||||
current: refreshScore,
|
||||
total: refreshScoreTotal,
|
||||
text: resolveRefreshScoreText(refreshScoreTotal),
|
||||
},
|
||||
crewTypeId: general.crewTypeId,
|
||||
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
|
||||
traits: {
|
||||
personal: traitName(general.personalCode, personalityNames),
|
||||
specialDomestic: traitName(general.specialCode, domesticNames),
|
||||
specialWar: traitName(general.special2Code, warNames),
|
||||
},
|
||||
progression: {
|
||||
experienceLevel: metaNumber('explevel'),
|
||||
dedicationLevel,
|
||||
dedicationText: resolveDedicationLevelName(
|
||||
dedicationLevel,
|
||||
maxDedicationLevel
|
||||
),
|
||||
statExperience: {
|
||||
leadership: metaNumber('leadership_exp'),
|
||||
strength: metaNumber('strength_exp'),
|
||||
intelligence: metaNumber('intel_exp'),
|
||||
},
|
||||
statUpgradeLimit,
|
||||
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
|
||||
},
|
||||
itemNames: {
|
||||
horse: itemName(general.horseCode),
|
||||
weapon: itemName(general.weaponCode),
|
||||
book: itemName(general.bookCode),
|
||||
item: itemName(general.itemCode),
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
available: true,
|
||||
experience: general.experience,
|
||||
dedicationText: resolveDedicationLevelName(
|
||||
dedicationLevel,
|
||||
maxDedicationLevel
|
||||
),
|
||||
bill: getBillByLevel(dedicationLevel),
|
||||
warnum: rankValue('warnum', ['rank_warnum', 'warnum']),
|
||||
wins: rankValue('killnum', ['rank_killnum', 'killnum']),
|
||||
losses: rankValue('deathnum', ['rank_deathnum', 'deathnum']),
|
||||
strategies: rankValue('firenum', ['rank_firenum', 'firenum']),
|
||||
serviceYears: metaNumber('belong'),
|
||||
killCrew: rankValue('killcrew', ['rank_killcrew', 'killcrew']),
|
||||
deathCrew: rankValue('deathcrew', ['rank_deathcrew', 'deathcrew']),
|
||||
recentWar: general.recentWarTime?.toISOString() ?? null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -157,9 +420,9 @@ export const troopRouter = router({
|
||||
});
|
||||
|
||||
return {
|
||||
nation: { id: nation.id, name: nation.name },
|
||||
nation: { id: nation.id, name: nation.name, color: nation.color },
|
||||
me: { id: me.id, troopId: me.troopId },
|
||||
permission: resolveTroopSecretPermission(me, nation.meta, false),
|
||||
permission,
|
||||
troops: mappedTroops,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { RankDataType } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
@@ -78,11 +79,15 @@ const buildContext = (options: {
|
||||
requestId?: string;
|
||||
transaction?: ReturnType<typeof vi.fn>;
|
||||
turns?: Array<{ generalId: number; turnIdx: number; actionCode: string }>;
|
||||
rankRows?: Array<{ generalId: number; type: RankDataType; value: number }>;
|
||||
accessRows?: Array<{ generalId: number; refreshScore: number; refreshScoreTotal: number }>;
|
||||
result: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
|
||||
}) => {
|
||||
const me = options.me ?? buildGeneral();
|
||||
const requestCommand = vi.fn(async () => options.result);
|
||||
const generalTurnFindMany = vi.fn(async () => options.turns ?? []);
|
||||
const rankDataFindMany = vi.fn(async () => options.rankRows ?? []);
|
||||
const generalAccessLogFindMany = vi.fn(async () => options.accessRows ?? []);
|
||||
const db = {
|
||||
...(options.transaction ? { $transaction: options.transaction } : {}),
|
||||
general: {
|
||||
@@ -99,7 +104,9 @@ const buildContext = (options: {
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === me.nationId ? { id: me.nationId, name: '테스트국', meta: options.nationMeta ?? {} } : null
|
||||
where.id === me.nationId
|
||||
? { id: me.nationId, name: '테스트국', color: '#123456', level: 4, meta: options.nationMeta ?? {} }
|
||||
: null
|
||||
),
|
||||
},
|
||||
troop: {
|
||||
@@ -111,8 +118,19 @@ const buildContext = (options: {
|
||||
),
|
||||
},
|
||||
city: { findMany: vi.fn(async () => [{ id: 1, name: '북평' }]) },
|
||||
worldState: { findFirst: vi.fn(async () => ({ config: { const: { upgradeLimit: 20 } } })) },
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
tickSeconds: 300,
|
||||
config: {
|
||||
stat: { chiefMin: 70 },
|
||||
const: { upgradeLimit: 20, statGradeLevel: 5, retirementYear: 70, maxDedLevel: 30 },
|
||||
},
|
||||
meta: { lastTurnTime: '2026-01-01T00:00:00.000Z' },
|
||||
})),
|
||||
},
|
||||
generalTurn: { findMany: generalTurnFindMany },
|
||||
rankData: { findMany: rankDataFindMany },
|
||||
generalAccessLog: { findMany: generalAccessLogFindMany },
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
@@ -136,15 +154,24 @@ const buildContext = (options: {
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
return { context, requestCommand, generalTurnFindMany };
|
||||
return { context, requestCommand, generalTurnFindMany, rankDataFindMany, generalAccessLogFindMany };
|
||||
};
|
||||
|
||||
describe('troop router permissions and mutations', () => {
|
||||
it('returns the Ref general progress inputs for same-nation troop popups', async () => {
|
||||
it('returns the shared general information panel source for authorized same-nation troop popups', async () => {
|
||||
const me = buildGeneral({
|
||||
troopId: 1,
|
||||
officerLevel: 2,
|
||||
dedication: 900,
|
||||
crewTypeId: 1100,
|
||||
recentWarTime: new Date('2026-01-01T00:12:34.000Z'),
|
||||
meta: {
|
||||
explevel: 4,
|
||||
dedlevel: 3,
|
||||
officerCity: 1,
|
||||
defence_train: 90,
|
||||
killturn: 7,
|
||||
belong: 11,
|
||||
leadership_exp: 7,
|
||||
strength_exp: 8,
|
||||
intel_exp: 9,
|
||||
@@ -155,9 +182,23 @@ describe('troop router permissions and mutations', () => {
|
||||
dex5: 12_650,
|
||||
},
|
||||
});
|
||||
const fixture = buildContext({ me, result: null });
|
||||
const fixture = buildContext({
|
||||
me,
|
||||
turns: [{ generalId: 1, turnIdx: 0, actionCode: 'che_집합' }],
|
||||
rankRows: [
|
||||
{ generalId: 1, type: 'warnum', value: 17 },
|
||||
{ generalId: 1, type: 'killnum', value: 11 },
|
||||
{ generalId: 1, type: 'deathnum', value: 6 },
|
||||
{ generalId: 1, type: 'firenum', value: 5 },
|
||||
{ generalId: 1, type: 'killcrew', value: 1234 },
|
||||
{ generalId: 1, type: 'deathcrew', value: 432 },
|
||||
],
|
||||
accessRows: [{ generalId: 1, refreshScore: 13, refreshScoreTotal: 800 }],
|
||||
result: null,
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).troop.getList()).resolves.toMatchObject({
|
||||
nation: { id: 1, name: '테스트국', color: '#123456' },
|
||||
troops: [
|
||||
{
|
||||
members: [
|
||||
@@ -170,6 +211,35 @@ describe('troop router permissions and mutations', () => {
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 12_650],
|
||||
},
|
||||
panel: {
|
||||
general: {
|
||||
name: '부대장',
|
||||
officerLevelText: '종사',
|
||||
officerCityName: '북평',
|
||||
generalType: '평범',
|
||||
defenceTrain: 90,
|
||||
killTurn: 7,
|
||||
troop: { name: '백마대', status: 'present' },
|
||||
refreshScore: { current: 13, total: 800, text: '열심' },
|
||||
progression: {
|
||||
dedicationLevel: 3,
|
||||
dedicationText: '28품관',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
experience: 0,
|
||||
dedicationText: '28품관',
|
||||
bill: 1_000,
|
||||
warnum: 17,
|
||||
wins: 11,
|
||||
losses: 6,
|
||||
strategies: 5,
|
||||
serviceYears: 11,
|
||||
killCrew: 1_234,
|
||||
deathCrew: 432,
|
||||
recentWar: '2026-01-01T00:12:34.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -177,6 +247,17 @@ describe('troop router permissions and mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps detailed panel data out of the permission-zero troop response', async () => {
|
||||
const fixture = buildContext({ me: buildGeneral({ troopId: 1, officerLevel: 1 }), result: null });
|
||||
|
||||
const response = await appRouter.createCaller(fixture.context).troop.getList();
|
||||
|
||||
expect(response.permission).toBe(0);
|
||||
expect(response.troops[0]?.members[0]?.panel).toBeNull();
|
||||
expect(fixture.rankDataFindMany).not.toHaveBeenCalled();
|
||||
expect(fixture.generalAccessLogFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns only the first five Ref-redacted troop command labels without exposing action codes', async () => {
|
||||
const fixture = buildContext({
|
||||
me: buildGeneral({ troopId: 1 }),
|
||||
|
||||
@@ -22,6 +22,17 @@ const readReferenceImage = async (filename: string): Promise<Buffer> => {
|
||||
throw new Error(`Reference image not found: ${filename}`);
|
||||
};
|
||||
|
||||
const readReferenceIcon = async (filename: string): Promise<Buffer> => {
|
||||
for (const gameImageRoot of imageRoots) {
|
||||
try {
|
||||
return await readFile(resolve(gameImageRoot, '..', 'icons', filename));
|
||||
} catch {
|
||||
// The main checkout and nested feature worktrees have different parents.
|
||||
}
|
||||
}
|
||||
throw new Error(`Reference icon not found: ${filename}`);
|
||||
};
|
||||
|
||||
type Member = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -35,22 +46,137 @@ type Member = {
|
||||
statUpgradeLimit: number;
|
||||
dex: number[];
|
||||
};
|
||||
panel: {
|
||||
general: {
|
||||
id: number;
|
||||
name: string;
|
||||
picture: string | null;
|
||||
imageServer: number;
|
||||
npcState: number;
|
||||
officerLevel: number;
|
||||
officerLevelText: string;
|
||||
officerCityName: string | null;
|
||||
generalType: string;
|
||||
leadershipBonus: number;
|
||||
stats: { leadership: number; strength: number; intelligence: number };
|
||||
gold: number;
|
||||
rice: number;
|
||||
crew: number;
|
||||
train: number;
|
||||
atmos: number;
|
||||
injury: number;
|
||||
experience: number;
|
||||
dedication: number;
|
||||
age: number;
|
||||
retirementYear: number;
|
||||
turnTime: string;
|
||||
defenceTrain: number;
|
||||
killTurn: number;
|
||||
remainingMinutes: number;
|
||||
troopId: number;
|
||||
troop: { name: string; status: 'present' };
|
||||
refreshScore: { current: number; total: number; text: string };
|
||||
crewTypeId: number;
|
||||
crewTypeName: string;
|
||||
traits: { personal: string; specialDomestic: string; specialWar: string };
|
||||
progression: {
|
||||
experienceLevel: number;
|
||||
dedicationLevel: number;
|
||||
dedicationText: string;
|
||||
statExperience: { leadership: number; strength: number; intelligence: number };
|
||||
statUpgradeLimit: number;
|
||||
dex: number[];
|
||||
};
|
||||
itemNames: { horse: string; weapon: string; book: string; item: string };
|
||||
};
|
||||
summary: {
|
||||
available: true;
|
||||
experience: number;
|
||||
dedicationText: string;
|
||||
bill: number;
|
||||
warnum: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
strategies: number;
|
||||
serviceYears: number;
|
||||
killCrew: number;
|
||||
deathCrew: number;
|
||||
recentWar: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const member = (id: number, name: string, cityId: number, cityName: string): Member => ({
|
||||
id,
|
||||
name,
|
||||
cityId,
|
||||
cityName,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
experience: 450,
|
||||
progression: {
|
||||
const member = (id: number, name: string, cityId: number, cityName: string, troopName = '백마대'): Member => {
|
||||
const stats = { leadership: 70, strength: 60, intelligence: 50 };
|
||||
const progression = {
|
||||
experienceLevel: 4,
|
||||
dedicationLevel: 3,
|
||||
dedicationText: '28품관',
|
||||
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
cityId,
|
||||
cityName,
|
||||
stats,
|
||||
experience: 450,
|
||||
progression,
|
||||
panel: {
|
||||
general: {
|
||||
id,
|
||||
name,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
npcState: 0,
|
||||
officerLevel: 1,
|
||||
officerLevelText: '일반',
|
||||
officerCityName: null,
|
||||
generalType: '용장',
|
||||
leadershipBonus: 0,
|
||||
stats,
|
||||
gold: 1_234,
|
||||
rice: 4_321,
|
||||
crew: 987,
|
||||
train: 88,
|
||||
atmos: 77,
|
||||
injury: 0,
|
||||
experience: 450,
|
||||
dedication: 900,
|
||||
age: 31,
|
||||
retirementYear: 70,
|
||||
turnTime: '2026-07-25T08:22:33.000Z',
|
||||
defenceTrain: 90,
|
||||
killTurn: 7,
|
||||
remainingMinutes: 3,
|
||||
troopId: 1,
|
||||
troop: { name: troopName, status: 'present' },
|
||||
refreshScore: { current: 13, total: 800, text: '열심' },
|
||||
crewTypeId: 1100,
|
||||
crewTypeName: '보병',
|
||||
traits: { personal: '대담', specialDomestic: '농업', specialWar: '맹장' },
|
||||
progression,
|
||||
itemNames: { horse: '명마', weapon: '명검', book: '병서', item: '도구' },
|
||||
},
|
||||
summary: {
|
||||
available: true,
|
||||
experience: 450,
|
||||
dedicationText: '28품관',
|
||||
bill: 1_000,
|
||||
warnum: 17,
|
||||
wins: 11,
|
||||
losses: 6,
|
||||
strategies: 5,
|
||||
serviceYears: 11,
|
||||
killCrew: 1_234,
|
||||
deathCrew: 432,
|
||||
recentWar: '2026-07-25T08:12:34.000Z',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
type TroopFixture = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -105,7 +231,7 @@ const baseTroops = (): TroopFixture[] => [
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
members: [member(2, '관우', 2, '계')],
|
||||
members: [member(2, '관우', 2, '계', '청룡대')],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -157,14 +283,18 @@ const installApiFixture = async (page: Page, state: FixtureState) => {
|
||||
});
|
||||
});
|
||||
}
|
||||
await page.route('**/image/icons/**', async (route) => {
|
||||
await page.route('**/game/crewtype1100.png', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
),
|
||||
body: await readReferenceImage('crewtype1100.png'),
|
||||
});
|
||||
});
|
||||
await page.route('**/icons/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
body: await readReferenceIcon('default.jpg'),
|
||||
});
|
||||
});
|
||||
await page.route(gameTrpcRoute, async (route) => {
|
||||
@@ -179,7 +309,7 @@ const installApiFixture = async (page: Page, state: FixtureState) => {
|
||||
}
|
||||
if (operation === 'troop.getList') {
|
||||
return response({
|
||||
nation: { id: 1, name: '테스트국' },
|
||||
nation: { id: 1, name: '테스트국', color: '#123456' },
|
||||
me: state.me,
|
||||
permission: state.permission,
|
||||
troops: state.troops,
|
||||
@@ -206,7 +336,7 @@ const installApiFixture = async (page: Page, state: FixtureState) => {
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
members: [member(createdId, '유비', 1, '북평')],
|
||||
members: [member(createdId, '유비', 1, '북평', '신규대')],
|
||||
});
|
||||
return response({ ok: true, troopId: createdId, troopName: '신규대' });
|
||||
}
|
||||
@@ -301,7 +431,7 @@ test('renders the legacy desktop grid with matching computed geometry and states
|
||||
paddingTop: '7px',
|
||||
paddingLeft: '9.8px',
|
||||
textAlign: 'left',
|
||||
fontFamily: 'Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic"',
|
||||
fontFamily: 'Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", sans-serif',
|
||||
fontSize: '14px',
|
||||
lineHeight: '21px',
|
||||
});
|
||||
@@ -317,9 +447,16 @@ test('renders the legacy desktop grid with matching computed geometry and states
|
||||
expect(hoverStyle.borderBottomWidth).toBe('3px');
|
||||
|
||||
await page.locator('.troopMember').nth(1).hover();
|
||||
await expect(page.getByRole('tooltip')).toContainText('조운');
|
||||
await expect(page.getByRole('tooltip').locator('[role="progressbar"]')).toHaveCount(14);
|
||||
await expect(page.getByRole('tooltip').locator('[aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||
const popup = page.getByRole('tooltip');
|
||||
await expect(popup).toContainText('조운');
|
||||
await expect(popup.locator('[data-general-information-panel]')).toHaveCount(1);
|
||||
await expect(popup.locator('[data-general-basic-card]')).toHaveCount(1);
|
||||
await expect(popup.locator('[data-general-battle-summary]')).toHaveCount(1);
|
||||
await expect(popup).toContainText('봉급1,000');
|
||||
await expect(popup).toContainText('승률64.71%');
|
||||
await expect(popup).toContainText('살상률285.65%');
|
||||
await expect(popup.locator('[role="progressbar"]')).toHaveCount(14);
|
||||
await expect(popup.locator('[aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||
expect(
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
@@ -327,10 +464,21 @@ test('renders the legacy desktop grid with matching computed geometry and states
|
||||
.first()
|
||||
.evaluate((bar) => getComputedStyle(bar).backgroundImage)
|
||||
).toContain('/game/pr8.gif');
|
||||
expect(await page.getByRole('tooltip').evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo(
|
||||
500,
|
||||
0
|
||||
);
|
||||
const popupGeometry = await popup.evaluate((tooltip) => {
|
||||
const rect = tooltip.getBoundingClientRect();
|
||||
const generalIcon = tooltip.querySelector<HTMLElement>('.general-icon')!.getBoundingClientRect();
|
||||
const crewIcon = tooltip.querySelector<HTMLElement>('.general-crew-type-icon')!.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
generalIcon: { width: generalIcon.width, height: generalIcon.height },
|
||||
crewIcon: { width: crewIcon.width, height: crewIcon.height },
|
||||
backgroundImage: getComputedStyle(tooltip.querySelector<HTMLElement>('.general-icon')!).backgroundImage,
|
||||
};
|
||||
});
|
||||
expect(popupGeometry.width).toBeCloseTo(500, 0);
|
||||
expect(popupGeometry.generalIcon).toEqual({ width: 64, height: 64 });
|
||||
expect(popupGeometry.crewIcon).toEqual({ width: 64, height: 64 });
|
||||
expect(popupGeometry.backgroundImage).toContain('/icons/default.jpg');
|
||||
await page.screenshot({ path: 'test-results/troop/desktop-leader.png', fullPage: true });
|
||||
});
|
||||
|
||||
@@ -371,6 +519,12 @@ test('matches the legacy 500px responsive placement', async ({ page }) => {
|
||||
expect(geometry.reserved).toMatchObject({ x: 260, y: 0, width: 100 });
|
||||
expect(geometry.action).toMatchObject({ x: 360, y: 0, width: 140 });
|
||||
expect(geometry.members).toMatchObject({ x: 130, y: 93, width: 370 });
|
||||
await page.locator('.troopMember').nth(1).hover();
|
||||
const mobilePopup = page.getByRole('tooltip');
|
||||
await expect(mobilePopup.locator('[data-general-information-panel]')).toHaveCount(1);
|
||||
await expect(mobilePopup.locator('[role="progressbar"]')).toHaveCount(14);
|
||||
expect(await mobilePopup.evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo(500, 0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(500);
|
||||
await page.screenshot({ path: 'test-results/troop/mobile-leader.png', fullPage: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from 'vue-router';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
|
||||
type TroopList = Awaited<ReturnType<typeof trpc.troop.getList.query>>;
|
||||
@@ -340,11 +341,20 @@ onMounted(() => {
|
||||
<div></div>
|
||||
</footer>
|
||||
<div v-if="popupMember" id="generalPopup" :style="{ top: `${popupTop}px` }" role="tooltip">
|
||||
<div class="popup-title">
|
||||
<strong>{{ popupMember.name }}</strong>
|
||||
<span>{{ popupMember.cityName }}</span>
|
||||
</div>
|
||||
<LegacyGeneralProgress :general="popupMember" />
|
||||
<GeneralInformationPanel
|
||||
v-if="popupMember.panel"
|
||||
:general="popupMember.panel.general"
|
||||
:summary="popupMember.panel.summary"
|
||||
:loading="false"
|
||||
:nation-color="data?.nation.color"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="popup-title">
|
||||
<strong>{{ popupMember.name }}</strong>
|
||||
<span>{{ popupMember.cityName }}</span>
|
||||
</div>
|
||||
<LegacyGeneralProgress :general="popupMember" />
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user