feat: restore global nation and general directories
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedProcedure } from '../../trpc.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
|
||||
const zDirectorySort = z.number().int().min(1).max(15).default(9);
|
||||
|
||||
const readNumber = (value: unknown, fallback = 0): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
const readMetaNumber = (value: unknown, key: string, fallback = 0): number =>
|
||||
readNumber(asRecord(value)[key], fallback);
|
||||
|
||||
const readMetaString = (value: unknown, ...keys: string[]): string | null => {
|
||||
const record = asRecord(value);
|
||||
for (const key of keys) {
|
||||
const candidate = record[key];
|
||||
if (typeof candidate === 'string' && candidate.length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const compareString = (left: string, right: string): number => {
|
||||
if (left === right) {
|
||||
return 0;
|
||||
}
|
||||
return left < right ? -1 : 1;
|
||||
};
|
||||
|
||||
const resolveExperienceLevel = (experience: number, maxLevel: number): number => {
|
||||
const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10));
|
||||
return Math.max(0, Math.min(level, maxLevel));
|
||||
};
|
||||
|
||||
const resolveHonorText = (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 '구세주';
|
||||
};
|
||||
|
||||
const resolveDedicationText = (dedication: number, maxLevel: number): string => {
|
||||
const level = Math.max(0, Math.min(Math.ceil(Math.sqrt(dedication) / 10), maxLevel));
|
||||
return level === 0 ? '무품관' : `${maxLevel - level + 1}품관`;
|
||||
};
|
||||
|
||||
const resolveRefreshText = (score: number): string => {
|
||||
if (score < 50) return '안함';
|
||||
if (score < 100) return '무관심';
|
||||
if (score < 200) return '가끔';
|
||||
if (score < 400) return '보통';
|
||||
if (score < 800) return '자주';
|
||||
if (score < 1_600) return '열심';
|
||||
if (score < 3_200) return '중독';
|
||||
if (score < 6_400) return '폐인';
|
||||
if (score < 12_800) return '경고';
|
||||
return '헐...';
|
||||
};
|
||||
|
||||
export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
|
||||
const [nations, generals, cities] = await Promise.all([
|
||||
ctx.db.nation.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
capitalCityId: true,
|
||||
level: true,
|
||||
typeCode: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.general.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
dedication: true,
|
||||
officerLevel: true,
|
||||
meta: true,
|
||||
penalty: true,
|
||||
},
|
||||
orderBy: [{ dedication: 'desc' }, { id: 'asc' }],
|
||||
}),
|
||||
ctx.db.city.findMany({
|
||||
select: { id: true, name: true, nationId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const directoryNations = nations.some((nation) => nation.id === 0)
|
||||
? nations
|
||||
: [
|
||||
...nations,
|
||||
{
|
||||
id: 0,
|
||||
name: '재 야',
|
||||
color: '#000000',
|
||||
capitalCityId: null,
|
||||
level: 0,
|
||||
typeCode: 'None',
|
||||
meta: {},
|
||||
},
|
||||
];
|
||||
const nationTypeNames = await loadTraitNames(
|
||||
directoryNations.map((nation) => nation.typeCode),
|
||||
'nation'
|
||||
);
|
||||
const generalsByNation = new Map<number, typeof generals>();
|
||||
for (const general of generals) {
|
||||
const list = generalsByNation.get(general.nationId) ?? [];
|
||||
list.push(general);
|
||||
generalsByNation.set(general.nationId, list);
|
||||
}
|
||||
const citiesByNation = new Map<number, typeof cities>();
|
||||
for (const city of cities) {
|
||||
const list = citiesByNation.get(city.nationId) ?? [];
|
||||
list.push(city);
|
||||
citiesByNation.set(city.nationId, list);
|
||||
}
|
||||
|
||||
return directoryNations
|
||||
.map((nation) => {
|
||||
const nationGenerals = generalsByNation.get(nation.id) ?? [];
|
||||
const nationCities = citiesByNation.get(nation.id) ?? [];
|
||||
const officers = Array.from({ length: 8 }, (_, index) => 12 - index).map((officerLevel) => {
|
||||
const general = nationGenerals
|
||||
.filter((candidate) => candidate.officerLevel === officerLevel)
|
||||
.at(-1);
|
||||
return {
|
||||
officerLevel,
|
||||
general: general
|
||||
? { id: general.id, name: general.name, npcState: general.npcState, cityId: general.cityId }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
const secretPermissions = nationGenerals.map((general) => ({
|
||||
general,
|
||||
permission: resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta,
|
||||
false
|
||||
),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: nation.id,
|
||||
name: nation.id === 0 ? '재 야' : nation.name,
|
||||
color: nation.color,
|
||||
level: nation.level,
|
||||
type: {
|
||||
key: nation.typeCode,
|
||||
name: nationTypeNames.get(nation.typeCode)?.name ?? nation.typeCode,
|
||||
},
|
||||
power: readMetaNumber(nation.meta, 'power'),
|
||||
capitalCityId: nation.capitalCityId ?? 0,
|
||||
generalCount: nationGenerals.length,
|
||||
cityCount: nationCities.length,
|
||||
officers,
|
||||
ambassadorNames: secretPermissions
|
||||
.filter(({ permission }) => permission === 4)
|
||||
.map(({ general }) => general.name),
|
||||
auditorCount: secretPermissions.filter(({ permission }) => permission === 3).length,
|
||||
cities: nationCities.map((city) => ({
|
||||
id: city.id,
|
||||
name: city.name,
|
||||
capital: city.id === nation.capitalCityId,
|
||||
})),
|
||||
generals: nationGenerals.map((general) => ({
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.id === 0) return 1;
|
||||
if (right.id === 0) return -1;
|
||||
return right.power - left.power || left.id - right.id;
|
||||
});
|
||||
});
|
||||
|
||||
export const getGeneralDirectory = authedProcedure
|
||||
.input(z.object({ sort: zDirectorySort }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const sort = input?.sort ?? 9;
|
||||
const [generals, nations, accessLogs, worldState] = await Promise.all([
|
||||
ctx.db.general.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
npcState: true,
|
||||
age: true,
|
||||
nationId: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
injury: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
officerLevel: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.nation.findMany({
|
||||
select: { id: true, name: true, level: true },
|
||||
}),
|
||||
ctx.db.generalAccessLog.findMany({
|
||||
select: { generalId: true, refreshScoreTotal: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({
|
||||
select: { config: true, meta: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const personalityKeys = generals.map((general) => general.personalCode);
|
||||
const domesticKeys = generals.map((general) => general.specialCode);
|
||||
const warKeys = generals.map((general) => general.special2Code);
|
||||
const [personalities, domesticTraits, warTraits] = await Promise.all([
|
||||
loadTraitNames(personalityKeys, 'personality'),
|
||||
loadTraitNames(domesticKeys, 'domestic'),
|
||||
loadTraitNames(warKeys, 'war'),
|
||||
]);
|
||||
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
|
||||
const accessMap = new Map(accessLogs.map((row) => [row.generalId, row.refreshScoreTotal]));
|
||||
const config = asRecord(worldState?.config);
|
||||
const constValues = asRecord(config.const);
|
||||
const maxLevel = readNumber(constValues.maxLevel, 255);
|
||||
const maxDedLevel = readNumber(constValues.maxDedLevel, 30);
|
||||
const worldMeta = asRecord(worldState?.meta);
|
||||
const isUnited = readNumber(worldMeta.isUnited ?? worldMeta.isunited) > 0;
|
||||
|
||||
const rows = generals.map((general) => {
|
||||
const nation = nationMap.get(general.nationId);
|
||||
const refreshScoreTotal = Math.round((accessMap.get(general.id) ?? 0) / 10) * 10;
|
||||
const leadershipBonus =
|
||||
general.officerLevel === 12
|
||||
? (nation?.level ?? 0) * 2
|
||||
: general.officerLevel >= 5
|
||||
? (nation?.level ?? 0)
|
||||
: 0;
|
||||
const ownerName = isUnited ? readMetaString(general.meta, 'ownerName', 'owner_name') : null;
|
||||
const killturn = readMetaNumber(general.meta, 'killturn');
|
||||
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
ownerName,
|
||||
picture: general.picture,
|
||||
imageServer: general.imageServer,
|
||||
npcState: general.npcState,
|
||||
age: general.age,
|
||||
nationId: general.nationId,
|
||||
nationName: nation?.name ?? '-',
|
||||
nationLevel: nation?.level ?? 0,
|
||||
personality: {
|
||||
key: general.personalCode,
|
||||
name: personalities.get(general.personalCode)?.name ?? '-',
|
||||
info: personalities.get(general.personalCode)?.info ?? '',
|
||||
},
|
||||
specialDomestic: {
|
||||
key: general.specialCode,
|
||||
name: domesticTraits.get(general.specialCode)?.name ?? '-',
|
||||
info: domesticTraits.get(general.specialCode)?.info ?? '',
|
||||
},
|
||||
specialWar: {
|
||||
key: general.special2Code,
|
||||
name: warTraits.get(general.special2Code)?.name ?? '-',
|
||||
info: warTraits.get(general.special2Code)?.info ?? '',
|
||||
},
|
||||
injury: general.injury,
|
||||
leadership: general.leadership,
|
||||
leadershipBonus,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
experience: general.experience,
|
||||
experienceLevel: resolveExperienceLevel(general.experience, maxLevel),
|
||||
honorText: resolveHonorText(general.experience),
|
||||
dedication: general.dedication,
|
||||
dedicationText: resolveDedicationText(general.dedication, maxDedLevel),
|
||||
officerLevel: general.officerLevel,
|
||||
killturn,
|
||||
refreshScoreTotal,
|
||||
refreshText: resolveRefreshText(refreshScoreTotal),
|
||||
};
|
||||
});
|
||||
|
||||
rows.sort((left, right) => {
|
||||
let result = 0;
|
||||
switch (sort) {
|
||||
case 1:
|
||||
result = left.nationId - right.nationId;
|
||||
break;
|
||||
case 2:
|
||||
result = right.leadership - left.leadership;
|
||||
break;
|
||||
case 3:
|
||||
result = right.strength - left.strength;
|
||||
break;
|
||||
case 4:
|
||||
result = right.intelligence - left.intelligence;
|
||||
break;
|
||||
case 5:
|
||||
case 10:
|
||||
result = right.experience - left.experience;
|
||||
break;
|
||||
case 6:
|
||||
result = right.dedication - left.dedication;
|
||||
break;
|
||||
case 7:
|
||||
result = right.officerLevel - left.officerLevel;
|
||||
break;
|
||||
case 8:
|
||||
result = left.killturn - right.killturn;
|
||||
break;
|
||||
case 9:
|
||||
result = right.refreshScoreTotal - left.refreshScoreTotal;
|
||||
break;
|
||||
case 11:
|
||||
result = -compareString(left.personality.key, right.personality.key);
|
||||
break;
|
||||
case 12:
|
||||
result = -compareString(left.specialDomestic.key, right.specialDomestic.key);
|
||||
break;
|
||||
case 13:
|
||||
result = -compareString(left.specialWar.key, right.specialWar.key);
|
||||
break;
|
||||
case 14:
|
||||
result = right.age - left.age;
|
||||
break;
|
||||
case 15:
|
||||
result = right.npcState - left.npcState;
|
||||
break;
|
||||
}
|
||||
return result || left.id - right.id;
|
||||
});
|
||||
|
||||
return { sort, generals: rows };
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { loadWorldMap } from '../../maps/worldMap.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
import { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
|
||||
import { getGeneralDirectory, getNationDirectory } from './directory.js';
|
||||
|
||||
const isWorldAdmin = (roles: readonly string[]): boolean =>
|
||||
roles.some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser');
|
||||
@@ -44,6 +45,8 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({
|
||||
});
|
||||
|
||||
export const worldRouter = router({
|
||||
getNationDirectory,
|
||||
getGeneralDirectory,
|
||||
getGlobalInfo: authedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
const [nations, cities, diplomacy, map] = await Promise.all([
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const now = new Date('2026-01-01T00:00:00.000Z');
|
||||
const actor = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
id: 1,
|
||||
userId: 'user-1',
|
||||
name: '조회자',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
affinity: null,
|
||||
bornYear: 180,
|
||||
deadYear: 300,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 50,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
weaponCode: 'None',
|
||||
bookCode: 'None',
|
||||
horseCode: 'None',
|
||||
itemCode: 'None',
|
||||
turnTime: now,
|
||||
recentWarTime: null,
|
||||
age: 20,
|
||||
startAge: 20,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
lastTurn: {},
|
||||
meta: {},
|
||||
penalty: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const auth = (userId = 'user-1', roles: string[] = []): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
|
||||
sessionId: `session-${userId}`,
|
||||
user: { id: userId, username: userId, displayName: userId, roles },
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const nationRows = [
|
||||
{
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#008000',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: 10,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
tech: 0,
|
||||
level: 3,
|
||||
typeCode: 'None',
|
||||
meta: { power: 1_000 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '촉',
|
||||
color: '#800000',
|
||||
capitalCityId: 2,
|
||||
chiefGeneralId: 20,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
tech: 0,
|
||||
level: 2,
|
||||
typeCode: 'None',
|
||||
meta: { power: 2_000 },
|
||||
},
|
||||
];
|
||||
|
||||
const directoryGeneral = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
name: '조회자',
|
||||
userId: 'user-1',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
npcState: 0,
|
||||
age: 20,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
dedication: 100,
|
||||
officerLevel: 1,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
injury: 0,
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 50,
|
||||
experience: 1_000,
|
||||
meta: { killturn: 3 },
|
||||
penalty: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const globalGenerals = [
|
||||
directoryGeneral({
|
||||
id: 10,
|
||||
name: '군주',
|
||||
dedication: 900,
|
||||
officerLevel: 12,
|
||||
meta: { killturn: 4, owner_name: '통일유저' },
|
||||
experience: 10_000,
|
||||
}),
|
||||
directoryGeneral({
|
||||
id: 11,
|
||||
name: '외교관',
|
||||
dedication: 800,
|
||||
officerLevel: 1,
|
||||
meta: { killturn: 2, permission: 'ambassador' },
|
||||
experience: 5_000,
|
||||
}),
|
||||
directoryGeneral({
|
||||
id: 12,
|
||||
name: '제재외교관',
|
||||
dedication: 700,
|
||||
officerLevel: 1,
|
||||
meta: { killturn: 1, permission: 'ambassador' },
|
||||
penalty: { noTopSecret: true },
|
||||
experience: 2_000,
|
||||
}),
|
||||
directoryGeneral({
|
||||
id: 13,
|
||||
name: '조언자',
|
||||
dedication: 600,
|
||||
officerLevel: 1,
|
||||
meta: { killturn: 5, permission: 'auditor' },
|
||||
experience: 1_000,
|
||||
}),
|
||||
directoryGeneral({
|
||||
id: 20,
|
||||
name: '촉장',
|
||||
userId: 'user-2',
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
dedication: 500,
|
||||
officerLevel: 12,
|
||||
leadership: 80,
|
||||
meta: { killturn: 0 },
|
||||
experience: 20_000,
|
||||
}),
|
||||
directoryGeneral({
|
||||
id: 30,
|
||||
name: '재야장',
|
||||
userId: 'user-3',
|
||||
nationId: 0,
|
||||
cityId: 3,
|
||||
dedication: 400,
|
||||
officerLevel: 0,
|
||||
npcState: 2,
|
||||
meta: { killturn: 9 },
|
||||
experience: 500,
|
||||
}),
|
||||
];
|
||||
|
||||
const createContext = (options: {
|
||||
auth?: GameSessionTokenPayload | null;
|
||||
me?: GeneralRow | null;
|
||||
isUnited?: number;
|
||||
} = {}): { context: GameApiContext; requestCommand: ReturnType<typeof vi.fn> } => {
|
||||
const token = options.auth === undefined ? auth() : options.auth;
|
||||
const me = options.me === undefined ? actor({ userId: token?.user.id ?? 'user-1' }) : options.me;
|
||||
const requestCommand = vi.fn();
|
||||
const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client'];
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => me),
|
||||
findMany: vi.fn(async () => globalGenerals),
|
||||
},
|
||||
nation: {
|
||||
findMany: vi.fn(async () => nationRows),
|
||||
},
|
||||
city: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 1, name: '허창', nationId: 1 },
|
||||
{ id: 2, name: '성도', nationId: 2 },
|
||||
{ id: 3, name: '낙양', nationId: 0 },
|
||||
]),
|
||||
},
|
||||
generalAccessLog: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ generalId: 10, refreshScoreTotal: 104 },
|
||||
{ generalId: 20, refreshScoreTotal: 206 },
|
||||
]),
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
config: { const: { maxLevel: 100, maxDedLevel: 30 } },
|
||||
meta: { isUnited: options.isUnited ?? 0 },
|
||||
})),
|
||||
},
|
||||
};
|
||||
const context: GameApiContext = {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis,
|
||||
turnDaemon: { requestCommand } as unknown as GameApiContext['turnDaemon'],
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth: token,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'secret',
|
||||
};
|
||||
return { context, requestCommand };
|
||||
};
|
||||
|
||||
describe('legacy global nation/general directories', () => {
|
||||
it('requires an authenticated user who owns an in-game general', async () => {
|
||||
const unauthenticated = appRouter.createCaller(createContext({ auth: null, me: null }).context);
|
||||
await expect(unauthenticated.world.getNationDirectory()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(unauthenticated.world.getGeneralDirectory()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
|
||||
const noGeneral = appRouter.createCaller(createContext({ me: null }).context);
|
||||
await expect(noGeneral.world.getNationDirectory()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
await expect(noGeneral.world.getGeneralDirectory()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
|
||||
const adminWithoutGeneral = appRouter.createCaller(
|
||||
createContext({ auth: auth('admin', ['admin']), me: null }).context
|
||||
);
|
||||
await expect(adminWithoutGeneral.world.getNationDirectory()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('keeps the same read-only result for wandering, ordinary, chief, and possessed actors', async () => {
|
||||
const viewers = [
|
||||
actor({ nationId: 0, officerLevel: 0 }),
|
||||
actor({ nationId: 1, officerLevel: 1 }),
|
||||
actor({ nationId: 1, officerLevel: 12 }),
|
||||
actor({ nationId: 1, officerLevel: 1, npcState: 1 }),
|
||||
];
|
||||
const results = await Promise.all(
|
||||
viewers.map(async (me) => {
|
||||
const { context, requestCommand } = createContext({ me });
|
||||
const caller = appRouter.createCaller(context);
|
||||
const result = {
|
||||
nations: await caller.world.getNationDirectory(),
|
||||
generals: await caller.world.getGeneralDirectory({ sort: 2 }),
|
||||
};
|
||||
expect(requestCommand).not.toHaveBeenCalled();
|
||||
return result;
|
||||
})
|
||||
);
|
||||
expect(results.slice(1)).toEqual([results[0], results[0], results[0]]);
|
||||
});
|
||||
|
||||
it('preserves power/dedication order, synthesizes neutral, and applies target-general permission penalties', async () => {
|
||||
const result = await appRouter.createCaller(createContext().context).world.getNationDirectory();
|
||||
expect(result.map((nation) => nation.id)).toEqual([2, 1, 0]);
|
||||
expect(result[1]).toMatchObject({
|
||||
id: 1,
|
||||
generalCount: 4,
|
||||
cityCount: 1,
|
||||
ambassadorNames: ['군주', '외교관'],
|
||||
auditorCount: 1,
|
||||
});
|
||||
expect(result[1]?.generals.map((general) => general.name)).toEqual([
|
||||
'군주',
|
||||
'외교관',
|
||||
'제재외교관',
|
||||
'조언자',
|
||||
]);
|
||||
expect(result[1]?.officers[0]).toMatchObject({
|
||||
officerLevel: 12,
|
||||
general: { id: 10, name: '군주' },
|
||||
});
|
||||
expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 });
|
||||
});
|
||||
|
||||
it('implements all legacy sort boundaries and never exposes user ids or raw metadata', async () => {
|
||||
const caller = appRouter.createCaller(createContext().context);
|
||||
const defaultResult = await caller.world.getGeneralDirectory();
|
||||
expect(defaultResult.sort).toBe(9);
|
||||
expect(defaultResult.generals.slice(0, 2).map((general) => general.id)).toEqual([20, 10]);
|
||||
|
||||
const killturnResult = await caller.world.getGeneralDirectory({ sort: 8 });
|
||||
expect(killturnResult.generals.slice(0, 3).map((general) => general.id)).toEqual([20, 12, 11]);
|
||||
const npcResult = await caller.world.getGeneralDirectory({ sort: 15 });
|
||||
expect(npcResult.generals[0]?.id).toBe(30);
|
||||
|
||||
const serialized = JSON.stringify(defaultResult);
|
||||
expect(serialized).not.toContain('user-');
|
||||
expect(serialized).not.toContain('"meta"');
|
||||
expect(serialized).not.toContain('"penalty"');
|
||||
expect(defaultResult.generals.find((general) => general.id === 10)?.ownerName).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals only the legacy owner display name after unification', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(createContext({ isUnited: 1 }).context)
|
||||
.world.getGeneralDirectory({ sort: 1 });
|
||||
expect(result.generals.find((general) => general.id === 10)?.ownerName).toBe('통일유저');
|
||||
expect(JSON.stringify(result)).not.toContain('user-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
const nationDirectory = [
|
||||
{
|
||||
id: 2,
|
||||
name: '촉',
|
||||
color: '#800000',
|
||||
level: 2,
|
||||
type: { key: 'che_법가', name: '법가' },
|
||||
power: 2000,
|
||||
capitalCityId: 2,
|
||||
generalCount: 1,
|
||||
cityCount: 1,
|
||||
officers: Array.from({ length: 8 }, (_, index) => ({
|
||||
officerLevel: 12 - index,
|
||||
general: index === 0 ? { id: 20, name: '유비', npcState: 0, cityId: 2 } : null,
|
||||
})),
|
||||
ambassadorNames: ['유비'],
|
||||
auditorCount: 0,
|
||||
cities: [{ id: 2, name: '성도', capital: true }],
|
||||
generals: [{ id: 20, name: '유비', npcState: 0 }],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#008000',
|
||||
level: 3,
|
||||
type: { key: 'che_명가', name: '명가' },
|
||||
power: 1000,
|
||||
capitalCityId: 1,
|
||||
generalCount: 2,
|
||||
cityCount: 1,
|
||||
officers: Array.from({ length: 8 }, (_, index) => ({
|
||||
officerLevel: 12 - index,
|
||||
general: index === 0 ? { id: 10, name: '조조', npcState: 0, cityId: 1 } : null,
|
||||
})),
|
||||
ambassadorNames: ['조조', '순욱'],
|
||||
auditorCount: 1,
|
||||
cities: [{ id: 1, name: '허창', capital: true }],
|
||||
generals: [
|
||||
{ id: 10, name: '조조', npcState: 0 },
|
||||
{ id: 11, name: '순욱', npcState: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
name: '재 야',
|
||||
color: '#000000',
|
||||
level: 0,
|
||||
type: { key: 'None', name: '-' },
|
||||
power: 0,
|
||||
capitalCityId: 0,
|
||||
generalCount: 1,
|
||||
cityCount: 1,
|
||||
officers: Array.from({ length: 8 }, (_, index) => ({ officerLevel: 12 - index, general: null })),
|
||||
ambassadorNames: [],
|
||||
auditorCount: 0,
|
||||
cities: [{ id: 3, name: '낙양', capital: false }],
|
||||
generals: [{ id: 30, name: '재야장', npcState: 2 }],
|
||||
},
|
||||
];
|
||||
|
||||
const generals = [
|
||||
{
|
||||
id: 10,
|
||||
name: '조조',
|
||||
ownerName: null,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
npcState: 0,
|
||||
age: 35,
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
nationLevel: 3,
|
||||
personality: { key: 'che_대담', name: '대담', info: '대담한 성격' },
|
||||
specialDomestic: { key: 'che_상재', name: '상재', info: '상업 특기' },
|
||||
specialWar: { key: 'che_귀모', name: '귀모', info: '전투 특기' },
|
||||
injury: 10,
|
||||
leadership: 90,
|
||||
leadershipBonus: 6,
|
||||
strength: 70,
|
||||
intelligence: 95,
|
||||
experience: 10_000,
|
||||
experienceLevel: 31,
|
||||
honorText: '약간',
|
||||
dedication: 900,
|
||||
dedicationText: '27품관',
|
||||
officerLevel: 12,
|
||||
killturn: 3,
|
||||
refreshScoreTotal: 120,
|
||||
refreshText: '가끔',
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
name: '유비',
|
||||
ownerName: '통일유저',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
npcState: 0,
|
||||
age: 34,
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
nationLevel: 2,
|
||||
personality: { key: 'che_인덕', name: '인덕', info: '인덕 있는 성격' },
|
||||
specialDomestic: { key: 'None', name: '-', info: '' },
|
||||
specialWar: { key: 'None', name: '-', info: '' },
|
||||
injury: 0,
|
||||
leadership: 80,
|
||||
leadershipBonus: 4,
|
||||
strength: 75,
|
||||
intelligence: 70,
|
||||
experience: 20_000,
|
||||
experienceLevel: 44,
|
||||
honorText: '지역적',
|
||||
dedication: 800,
|
||||
dedicationText: '27품관',
|
||||
officerLevel: 12,
|
||||
killturn: 1,
|
||||
refreshScoreTotal: 210,
|
||||
refreshText: '보통',
|
||||
},
|
||||
];
|
||||
|
||||
const parseSort = (route: Route): number => {
|
||||
const raw = new URL(route.request().url()).searchParams.get('input');
|
||||
if (!raw) return 9;
|
||||
try {
|
||||
const input = JSON.parse(raw) as { 0?: { sort?: number }; json?: { sort?: number } };
|
||||
return input[0]?.sort ?? input.json?.sort ?? 9;
|
||||
} catch {
|
||||
return 9;
|
||||
}
|
||||
};
|
||||
|
||||
const install = async (
|
||||
page: Page,
|
||||
mode: 'general' | 'no-general' | 'error-after-load' = 'general'
|
||||
) => {
|
||||
let generalDirectoryCalls = 0;
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_directory');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
await page.route('**/image/general/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
||||
})
|
||||
);
|
||||
await page.route('**/image/game/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
||||
);
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } });
|
||||
}
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'world.getNationDirectory') return response(nationDirectory);
|
||||
if (operation === 'world.getGeneralDirectory') {
|
||||
generalDirectoryCalls += 1;
|
||||
if (mode === 'error-after-load' && generalDirectoryCalls > 1) {
|
||||
return {
|
||||
error: {
|
||||
message: '권한 확인 실패',
|
||||
code: -32000,
|
||||
data: {
|
||||
code: 'FORBIDDEN',
|
||||
httpStatus: 403,
|
||||
path: 'world.getGeneralDirectory',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const sort = parseSort(route);
|
||||
const rows = sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals;
|
||||
return response({ sort, generals: rows });
|
||||
}
|
||||
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||
});
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
});
|
||||
};
|
||||
|
||||
test('nation and general directories preserve the fixed legacy Chromium geometry', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
|
||||
const measurements: Record<string, unknown> = {};
|
||||
for (const path of ['nation-list', 'general-list']) {
|
||||
await page.goto(path);
|
||||
const root = page.locator('.directory-page');
|
||||
await expect(root).toBeVisible();
|
||||
const geometry = await root.evaluate((element, currentPath) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
const tableStyle = getComputedStyle(element.querySelector('table')!);
|
||||
const cellStyle = getComputedStyle(element.querySelector('td')!);
|
||||
const titleRect = element.querySelector('.title-table')!.getBoundingClientRect();
|
||||
const contentTable =
|
||||
currentPath === 'nation-list'
|
||||
? element.querySelector('.nation-table')!
|
||||
: element.querySelector('.general-table')!;
|
||||
const contentRect = contentTable.getBoundingClientRect();
|
||||
const contentFirstRow = contentTable.querySelector('tr')!.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.x,
|
||||
width: rect.width,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
fontFamily: style.fontFamily,
|
||||
borderCollapse: tableStyle.borderCollapse,
|
||||
cellBorderWidth: cellStyle.borderTopWidth,
|
||||
cellPadding: cellStyle.padding,
|
||||
bodyBackgroundColor: getComputedStyle(document.body).backgroundColor,
|
||||
title: { x: titleRect.x, y: titleRect.y, width: titleRect.width, height: titleRect.height },
|
||||
content: {
|
||||
x: contentRect.x,
|
||||
y: contentRect.y,
|
||||
width: contentRect.width,
|
||||
firstRowHeight: contentFirstRow.height,
|
||||
},
|
||||
};
|
||||
}, path);
|
||||
measurements[path] = geometry;
|
||||
expect(geometry).toMatchObject({
|
||||
x: 100,
|
||||
width: 1000,
|
||||
fontSize: '14px',
|
||||
lineHeight: '18.2px',
|
||||
borderCollapse: 'collapse',
|
||||
cellBorderWidth: '1px',
|
||||
bodyBackgroundColor: 'rgb(0, 0, 0)',
|
||||
});
|
||||
expect(geometry.fontFamily).toContain('Pretendard');
|
||||
|
||||
if (path === 'nation-list') {
|
||||
expect(geometry.title).toEqual({ x: 100, y: 0, width: 1000, height: 55.6875 });
|
||||
expect(geometry.content).toEqual({
|
||||
x: 100,
|
||||
y: 55.6875,
|
||||
width: 1000,
|
||||
firstRowHeight: 19.1875,
|
||||
});
|
||||
expect(
|
||||
await page
|
||||
.locator('.nation-table .label-cell')
|
||||
.first()
|
||||
.evaluate((element) => element.getBoundingClientRect().width)
|
||||
).toBe(79.921875);
|
||||
} else {
|
||||
expect(geometry.title).toEqual({ x: 100, y: 0, width: 1000, height: 80.875 });
|
||||
expect(geometry.content).toEqual({
|
||||
x: 100,
|
||||
y: 80.875,
|
||||
width: 1000,
|
||||
firstRowHeight: 19.1875,
|
||||
});
|
||||
expect(
|
||||
await page
|
||||
.locator('.general-table tbody tr[data-general-id]')
|
||||
.first()
|
||||
.evaluate((element) => element.getBoundingClientRect().height)
|
||||
).toBe(65);
|
||||
}
|
||||
}
|
||||
|
||||
const header = page.locator('.general-table thead td').first();
|
||||
expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg');
|
||||
const icon = page.locator('.general-icon').first();
|
||||
await expect(icon).toBeVisible();
|
||||
expect(
|
||||
await icon.evaluate((element) => {
|
||||
const image = element as HTMLImageElement;
|
||||
const rect = image.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
naturalWidth: image.naturalWidth,
|
||||
naturalHeight: image.naturalHeight,
|
||||
objectFit: getComputedStyle(image).objectFit,
|
||||
};
|
||||
})
|
||||
).toEqual({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64, objectFit: 'fill' });
|
||||
|
||||
const artifactRoot = process.env.DIRECTORY_PARITY_ARTIFACT_DIR;
|
||||
if (artifactRoot) {
|
||||
const output = resolve(artifactRoot);
|
||||
await mkdir(output, { recursive: true });
|
||||
await writeFile(resolve(output, 'computed-dom.json'), `${JSON.stringify(measurements, null, 2)}\n`);
|
||||
await page.screenshot({ path: resolve(output, 'general-directory.png'), fullPage: true });
|
||||
await page.goto('nation-list');
|
||||
await page.screenshot({ path: resolve(output, 'nation-directory.png'), fullPage: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('general directory submits the legacy sort selector and keeps wounded/bonus rendering', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.goto('general-list');
|
||||
await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2);
|
||||
await page.selectOption('#viewType', '8');
|
||||
await page.getByRole('button', { name: '정렬하기' }).click();
|
||||
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20');
|
||||
await expect(page.locator('tbody tr[data-general-id="10"] .wounded').first()).toHaveText('81');
|
||||
await expect(page.locator('tbody tr[data-general-id="10"] .leadership-bonus')).toHaveText('+6');
|
||||
|
||||
await page.locator('#viewType').focus();
|
||||
expect(await page.locator('#viewType').evaluate((element) => document.activeElement === element)).toBe(true);
|
||||
});
|
||||
|
||||
test('a failed resort retains the selected value and existing rows', async ({ page }) => {
|
||||
await install(page, 'error-after-load');
|
||||
await page.goto('general-list');
|
||||
await page.selectOption('#viewType', '8');
|
||||
await page.getByRole('button', { name: '정렬하기' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('권한 확인 실패');
|
||||
await expect(page.locator('#viewType')).toHaveValue('8');
|
||||
await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('an authenticated account without a general is redirected away from both directories', async ({ page }) => {
|
||||
await install(page, 'no-general');
|
||||
for (const path of ['nation-list', 'general-list']) {
|
||||
await page.goto(path);
|
||||
await expect(page).toHaveURL(/\/che\/join$/);
|
||||
}
|
||||
});
|
||||
@@ -8,7 +8,13 @@ const baseURL = `http://127.0.0.1:${port}/che/`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'],
|
||||
testMatch: [
|
||||
'troop.spec.ts',
|
||||
'board.spec.ts',
|
||||
'inGameInfo.spec.ts',
|
||||
'nationOffices.spec.ts',
|
||||
'directoryLists.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node -e \"console.log('test not configured')\"",
|
||||
|
||||
@@ -32,6 +32,8 @@ import TroopView from '../views/TroopView.vue';
|
||||
import YearbookView from '../views/YearbookView.vue';
|
||||
import NationBettingView from '../views/NationBettingView.vue';
|
||||
import NpcListView from '../views/NpcListView.vue';
|
||||
import NationListView from '../views/NationListView.vue';
|
||||
import GeneralListView from '../views/GeneralListView.vue';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
|
||||
const routes = [
|
||||
@@ -106,6 +108,18 @@ const routes = [
|
||||
component: GlobalInfoView,
|
||||
meta: { requiresAuth: true, requiresGeneral: true },
|
||||
},
|
||||
{
|
||||
path: '/nation-list',
|
||||
name: 'nation-list',
|
||||
component: NationListView,
|
||||
meta: { requiresAuth: true, requiresGeneral: true },
|
||||
},
|
||||
{
|
||||
path: '/general-list',
|
||||
name: 'general-list',
|
||||
component: GeneralListView,
|
||||
meta: { requiresAuth: true, requiresGeneral: true },
|
||||
},
|
||||
{
|
||||
path: '/current-city',
|
||||
name: 'current-city',
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Directory = Awaited<ReturnType<typeof trpc.world.getGeneralDirectory.query>>;
|
||||
type General = Directory['generals'][number];
|
||||
type SortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
|
||||
|
||||
const sortOptions: Array<{ value: SortKey; label: string }> = [
|
||||
{ value: 1, label: '국가' },
|
||||
{ value: 2, label: '통솔' },
|
||||
{ value: 3, label: '무력' },
|
||||
{ value: 4, label: '지력' },
|
||||
{ value: 5, label: '명성' },
|
||||
{ value: 6, label: '계급' },
|
||||
{ value: 7, label: '관직' },
|
||||
{ value: 8, label: '삭턴' },
|
||||
{ value: 9, label: '벌점' },
|
||||
{ value: 10, label: 'Lv' },
|
||||
{ value: 11, label: '성격' },
|
||||
{ value: 12, label: '내특' },
|
||||
{ value: 13, label: '전특' },
|
||||
{ value: 14, label: '연령' },
|
||||
{ value: 15, label: 'NPC' },
|
||||
];
|
||||
|
||||
const sort = ref<SortKey>(9);
|
||||
const generals = ref<General[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const loadDirectory = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const result = await trpc.world.getGeneralDirectory.query({ sort: sort.value });
|
||||
generals.value = result.generals;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '장수일람을 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const imageUrl = (general: General): string => {
|
||||
const picture = general.picture ?? 'default.jpg';
|
||||
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/general/${picture}`;
|
||||
};
|
||||
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100);
|
||||
|
||||
onMounted(() => {
|
||||
void loadDirectory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="directory-page" data-page="general-directory">
|
||||
<table class="directory-table title-table legacy-bg0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>장 수 일 람<br /><RouterLink class="legacy-button" to="/">창 닫기</RouterLink></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<form class="sort-form" @submit.prevent="loadDirectory">
|
||||
<label for="viewType">정렬순서 : </label>
|
||||
<select id="viewType" v-model.number="sort" name="type" size="1">
|
||||
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="submit">정렬하기</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p v-if="error" class="directory-error" role="alert">{{ error }}</p>
|
||||
<table class="directory-table general-table legacy-bg0">
|
||||
<colgroup>
|
||||
<col style="width: 64px" />
|
||||
<col style="width: 140px" />
|
||||
<col style="width: 45px" />
|
||||
<col style="width: 45px" />
|
||||
<col style="width: 80px" />
|
||||
<col style="width: 45px" />
|
||||
<col style="width: 140px" />
|
||||
<col style="width: 55px" />
|
||||
<col style="width: 55px" />
|
||||
<col style="width: 75px" />
|
||||
<col style="width: 45px" />
|
||||
<col style="width: 45px" />
|
||||
<col style="width: 45px" />
|
||||
<col style="width: 45px" />
|
||||
<col style="width: 70px" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="header-cell">얼 굴</td>
|
||||
<td class="header-cell">이 름</td>
|
||||
<td class="header-cell">연령</td>
|
||||
<td class="header-cell">성격</td>
|
||||
<td class="header-cell">특기</td>
|
||||
<td class="header-cell">레 벨</td>
|
||||
<td class="header-cell">국 가</td>
|
||||
<td class="header-cell">명 성</td>
|
||||
<td class="header-cell">계 급</td>
|
||||
<td class="header-cell">관 직</td>
|
||||
<td class="header-cell">통솔</td>
|
||||
<td class="header-cell">무력</td>
|
||||
<td class="header-cell">지력</td>
|
||||
<td class="header-cell">삭턴</td>
|
||||
<td class="header-cell">벌점</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="15" class="loading-cell">불러오는 중...</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="general in generals"
|
||||
v-else
|
||||
:key="general.id"
|
||||
:data-general-id="general.id"
|
||||
:data-general-wounded="general.injury"
|
||||
:data-general-leadership="general.leadership"
|
||||
:data-general-leadership-bonus="general.leadershipBonus"
|
||||
:data-general-strength="general.strength"
|
||||
:data-general-intel="general.intelligence"
|
||||
:data-is-npc="general.npcState >= 2"
|
||||
:data-npc-type="general.npcState"
|
||||
>
|
||||
<td class="center">
|
||||
<img class="general-icon" width="64" height="64" :src="imageUrl(general)" alt="" />
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span>
|
||||
<template v-if="general.ownerName"><br /><small>({{ general.ownerName }})</small></template>
|
||||
</td>
|
||||
<td class="center">{{ general.age }}세</td>
|
||||
<td class="center">
|
||||
<span :title="general.personality.info">{{ general.personality.name }}</span>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :title="general.specialDomestic.info">{{ general.specialDomestic.name }}</span> /
|
||||
<span :title="general.specialWar.info">{{ general.specialWar.name }}</span>
|
||||
</td>
|
||||
<td class="center">Lv {{ general.experienceLevel }}</td>
|
||||
<td class="center">{{ general.nationName }}</td>
|
||||
<td class="center">{{ general.honorText }}</td>
|
||||
<td class="center">{{ general.dedicationText }}</td>
|
||||
<td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
|
||||
<td class="center">
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0
|
||||
? injuredStat(general.leadership, general.injury)
|
||||
: general.leadership
|
||||
}}</span
|
||||
><span v-if="general.leadershipBonus > 0" class="leadership-bonus"
|
||||
>+{{ general.leadershipBonus }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0
|
||||
? injuredStat(general.intelligence, general.injury)
|
||||
: general.intelligence
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="center">{{ general.killturn }}</td>
|
||||
<td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="directory-table title-table legacy-bg0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><RouterLink class="legacy-button" to="/">창 닫기</RouterLink></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><small>삼국지 모의전투 HiDCHe</small></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.directory-page {
|
||||
width: 1000px;
|
||||
margin: 0 auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.directory-table {
|
||||
width: 1000px;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.directory-table td {
|
||||
border: 1px solid gray;
|
||||
padding: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.title-table {
|
||||
text-align: center;
|
||||
}
|
||||
.directory-page > .title-table:first-child {
|
||||
height: 80.875px;
|
||||
}
|
||||
.title-table td {
|
||||
padding: 1px;
|
||||
}
|
||||
.legacy-button {
|
||||
padding: 5px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.sort-form {
|
||||
margin: 0;
|
||||
}
|
||||
.sort-form select,
|
||||
.sort-form button {
|
||||
font-size: 14px;
|
||||
}
|
||||
.header-cell {
|
||||
height: 18px;
|
||||
text-align: center;
|
||||
background-color: #14241b;
|
||||
background-image: url('/image/game/back_green.jpg');
|
||||
}
|
||||
.general-icon {
|
||||
display: inline;
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
max-width: none;
|
||||
height: 64px;
|
||||
object-fit: fill;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.wounded {
|
||||
color: red;
|
||||
}
|
||||
.leadership-bonus {
|
||||
color: cyan;
|
||||
}
|
||||
.loading-cell {
|
||||
height: 64px;
|
||||
text-align: center;
|
||||
}
|
||||
.directory-error {
|
||||
width: 998px;
|
||||
margin: 0;
|
||||
border: 1px solid gray;
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
color: #ff7373;
|
||||
}
|
||||
</style>
|
||||
@@ -102,6 +102,8 @@ watch(
|
||||
<RouterLink class="ghost" to="/nation/info">세력 정보</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
|
||||
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation-list">세력일람</RouterLink>
|
||||
<RouterLink class="ghost" to="/general-list">장수일람</RouterLink>
|
||||
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Directory = Awaited<ReturnType<typeof trpc.world.getNationDirectory.query>>;
|
||||
type Nation = Directory[number];
|
||||
|
||||
const nations = ref<Directory>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const nationLevelText: Record<number, string> = {
|
||||
7: '황제',
|
||||
6: '왕',
|
||||
5: '공',
|
||||
4: '주목',
|
||||
3: '주자사',
|
||||
2: '군벌',
|
||||
1: '호족',
|
||||
0: '방랑군',
|
||||
};
|
||||
const whiteTextColors = new Set([
|
||||
'',
|
||||
'#330000',
|
||||
'#ff0000',
|
||||
'#800000',
|
||||
'#a0522d',
|
||||
'#ff6347',
|
||||
'#808000',
|
||||
'#008000',
|
||||
'#2e8b57',
|
||||
'#008080',
|
||||
'#6495ed',
|
||||
'#0000ff',
|
||||
'#000080',
|
||||
'#483d8b',
|
||||
'#7b68ee',
|
||||
'#800080',
|
||||
'#a9a9a9',
|
||||
'#000000',
|
||||
]);
|
||||
|
||||
const loadDirectory = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
nations.value = await trpc.world.getNationDirectory.query();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '세력일람을 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const headerTextColor = (color: string): string =>
|
||||
whiteTextColors.has(color.toLowerCase()) ? '#ffffff' : '#000000';
|
||||
|
||||
const officerName = (nation: Nation, officerLevel: number) =>
|
||||
nation.officers.find((officer) => officer.officerLevel === officerLevel)?.general;
|
||||
|
||||
const roamingCityName = (nation: Nation): string => {
|
||||
const chief = officerName(nation, 12);
|
||||
return nation.cities.find((city) => city.id === chief?.cityId)?.name ?? '-';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadDirectory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="directory-page" data-page="nation-directory">
|
||||
<table class="directory-table title-table legacy-bg0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>세 력 일 람<br /><RouterLink class="legacy-button" to="/">창 닫기</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p v-if="error" class="directory-error" role="alert">{{ error }}</p>
|
||||
<p v-else-if="loading" class="directory-loading">불러오는 중...</p>
|
||||
|
||||
<template v-for="nation in nations" :key="nation.id">
|
||||
<table
|
||||
v-if="nation.id !== 0"
|
||||
class="directory-table nation-table legacy-bg2"
|
||||
:data-nation-id="nation.id"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
colspan="8"
|
||||
class="center nation-title"
|
||||
:style="{ color: headerTextColor(nation.color), backgroundColor: nation.color }"
|
||||
>
|
||||
【 {{ nation.name }} 】
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">성 향</td>
|
||||
<td class="value-wide type-name">{{ Array.from(nation.type.name).join(' ') }}</td>
|
||||
<td class="label-cell">작 위</td>
|
||||
<td class="value-wide">{{ nationLevelText[nation.level] ?? '-' }}</td>
|
||||
<td class="label-cell">국 력</td>
|
||||
<td class="value-wide">{{ nation.power }}</td>
|
||||
<td class="label-cell">장수 / 속령</td>
|
||||
<td class="value-wide">{{ nation.generalCount }} / {{ nation.cityCount }}</td>
|
||||
</tr>
|
||||
<tr v-for="row in 2" :key="row">
|
||||
<template v-for="column in 4" :key="column">
|
||||
<td class="label-cell">
|
||||
{{ formatOfficerLevelText(13 - ((row - 1) * 4 + column), nation.level) }}
|
||||
</td>
|
||||
<td class="value-wide">
|
||||
<span
|
||||
v-if="officerName(nation, 13 - ((row - 1) * 4 + column))"
|
||||
:style="{
|
||||
color: getNpcColor(
|
||||
officerName(nation, 13 - ((row - 1) * 4 + column))?.npcState ?? 0
|
||||
),
|
||||
}"
|
||||
>
|
||||
{{ officerName(nation, 13 - ((row - 1) * 4 + column))?.name }}
|
||||
</span>
|
||||
<template v-else>-</template>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">외교권자</td>
|
||||
<td colspan="5">{{ nation.ambassadorNames.join(', ') }}</td>
|
||||
<td class="label-cell">조언자</td>
|
||||
<td class="value-wide">{{ nation.auditorCount }}명</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
<template v-if="nation.level > 0">
|
||||
속령 일람 :
|
||||
<template v-for="city in nation.cities" :key="city.id">
|
||||
<span :class="{ capital: city.capital }">{{ city.capital ? `[${city.name}]` : city.name }}</span
|
||||
>,
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>현재 위치 : <span class="roaming-city">{{ roamingCityName(nation) }}</span></template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
장수 일람 :
|
||||
<template v-for="general in nation.generals" :key="general.id">
|
||||
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
|
||||
>,
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<br v-if="nation.id !== 0" />
|
||||
|
||||
<table v-else class="directory-table neutral-table legacy-bg2" data-nation-id="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="5" class="center">【 재 야 】</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="neutral-spacer"> </td>
|
||||
<td class="neutral-label">장 수</td>
|
||||
<td class="neutral-value">{{ nation.generalCount }}</td>
|
||||
<td class="neutral-label">속 령</td>
|
||||
<td class="neutral-value">{{ nation.cityCount }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
속령 일람 :
|
||||
<template v-for="city in nation.cities" :key="city.id">{{ city.name }}, </template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
장수 일람 :
|
||||
<template v-for="general in nation.generals" :key="general.id">
|
||||
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
|
||||
>,
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<table class="directory-table title-table footer-table legacy-bg0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><RouterLink class="legacy-button" to="/">창 닫기</RouterLink></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><small>삼국지 모의전투 HiDCHe</small></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.directory-page {
|
||||
width: 1000px;
|
||||
margin: 0 auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.directory-table {
|
||||
width: 1000px;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.directory-table td {
|
||||
border: 1px solid gray;
|
||||
padding: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.title-table {
|
||||
text-align: center;
|
||||
}
|
||||
.directory-page > .title-table:first-child {
|
||||
height: 55.6875px;
|
||||
}
|
||||
.title-table td {
|
||||
padding: 1px;
|
||||
}
|
||||
.legacy-button {
|
||||
padding: 5px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.nation-table {
|
||||
background-color: #172a52;
|
||||
}
|
||||
.nation-title {
|
||||
height: 19px;
|
||||
}
|
||||
.label-cell {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
background-color: #14241b;
|
||||
background-image: url('/image/game/back_green.jpg');
|
||||
}
|
||||
.value-wide {
|
||||
width: 170px;
|
||||
text-align: center;
|
||||
}
|
||||
.type-name {
|
||||
color: yellow;
|
||||
}
|
||||
.capital {
|
||||
color: cyan;
|
||||
}
|
||||
.roaming-city {
|
||||
color: yellow;
|
||||
}
|
||||
.neutral-spacer {
|
||||
width: 498px;
|
||||
text-align: center;
|
||||
}
|
||||
.neutral-label,
|
||||
.neutral-value {
|
||||
width: 123px;
|
||||
text-align: center;
|
||||
}
|
||||
.neutral-label {
|
||||
background-color: #14241b;
|
||||
background-image: url('/image/game/back_green.jpg');
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.footer-table {
|
||||
margin-top: 0;
|
||||
}
|
||||
.directory-error,
|
||||
.directory-loading {
|
||||
width: 998px;
|
||||
margin: 0;
|
||||
border: 1px solid gray;
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.directory-error {
|
||||
color: #ff7373;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const baseUrl = process.env.REF_DIRECTORY_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||
const username = process.env.REF_DIRECTORY_USER ?? 'refuser1';
|
||||
const passwordFile = process.env.REF_DIRECTORY_PASSWORD_FILE;
|
||||
const artifactRoot = resolve(process.env.REF_DIRECTORY_ARTIFACT_DIR ?? 'test-results/reference-directories');
|
||||
if (!passwordFile) throw new Error('REF_DIRECTORY_PASSWORD_FILE is required.');
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
|
||||
const measure = async (page, selectors) =>
|
||||
page.evaluate((items) => {
|
||||
const result = {};
|
||||
for (const [name, selector] of Object.entries(items)) {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
result[name] = null;
|
||||
continue;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
result[name] = {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
style: {
|
||||
display: style.display,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
borderCollapse: style.borderCollapse,
|
||||
borderSpacing: style.borderSpacing,
|
||||
borderWidth: style.borderWidth,
|
||||
padding: style.padding,
|
||||
backgroundImage: style.backgroundImage,
|
||||
color: style.color,
|
||||
objectFit: style.objectFit,
|
||||
},
|
||||
image:
|
||||
element instanceof HTMLImageElement
|
||||
? { naturalWidth: element.naturalWidth, naturalHeight: element.naturalHeight }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
elements: result,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
bodyText: document.body.innerText.slice(0, 120),
|
||||
};
|
||||
}, selectors);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1200, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const salt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512').update(salt + password + salt).digest('hex');
|
||||
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const loginResult = await login.json();
|
||||
if (!login.ok() || loginResult.result !== true) throw new Error('Reference login failed.');
|
||||
|
||||
const output = {};
|
||||
const targets = [
|
||||
[
|
||||
'nation-directory',
|
||||
'hwe/a_kingdomList.php',
|
||||
{
|
||||
body: 'body',
|
||||
title: 'body > table:first-of-type',
|
||||
firstNation: 'body > table:nth-of-type(2)',
|
||||
firstNationTitle: 'body > table:nth-of-type(2) tr:first-child td',
|
||||
firstLabel: 'body > table:nth-of-type(2) tr:nth-child(2) td:first-child',
|
||||
},
|
||||
],
|
||||
[
|
||||
'general-directory',
|
||||
'hwe/a_genList.php',
|
||||
{
|
||||
body: 'body',
|
||||
title: 'body > table:first-of-type',
|
||||
list: 'body > table:nth-of-type(2)',
|
||||
header: 'body > table:nth-of-type(2) tr:first-child',
|
||||
firstRow: 'body > table:nth-of-type(2) tr:nth-child(2)',
|
||||
firstImage: 'body > table:nth-of-type(2) tr:nth-child(2) img',
|
||||
},
|
||||
],
|
||||
];
|
||||
for (const [name, path, selectors] of targets) {
|
||||
await page.goto(new URL(path, baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
output[name] = await measure(page, selectors);
|
||||
await page.screenshot({ path: resolve(artifactRoot, `${name}.png`), fullPage: true });
|
||||
}
|
||||
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot })}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user