merge: complete map-to-city page parity
This commit is contained in:
@@ -7,6 +7,7 @@ import { authedProcedure } from '../../trpc.js';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { loadWorldMap } from '../../maps/worldMap.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js';
|
||||
import { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
|
||||
|
||||
const isWorldAdmin = (roles: readonly string[]): boolean =>
|
||||
@@ -33,6 +34,29 @@ const defenceTrain = (meta: unknown): number => {
|
||||
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
|
||||
};
|
||||
|
||||
const unitSetName = (world: WorldStateRow | null, fallback: string): string => {
|
||||
const config = asRecord(world?.config);
|
||||
const environment = asRecord(config.environment ?? config.map);
|
||||
return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback;
|
||||
};
|
||||
|
||||
const crewTypeNameCache = new Map<string, Promise<Map<number, string>>>();
|
||||
const loadCrewTypeNames = (name: string): Promise<Map<number, string>> => {
|
||||
const cached = crewTypeNameCache.get(name);
|
||||
if (cached) return cached;
|
||||
const pending = loadUnitSetDefinitionByName(name)
|
||||
.then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name])))
|
||||
.catch(() => new Map<number, string>());
|
||||
crewTypeNameCache.set(name, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
const leadershipBonus = (officerLevel: number, nationLevel: number): number => {
|
||||
if (officerLevel === 12) return nationLevel * 2;
|
||||
if (officerLevel >= 5) return nationLevel;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const toWorldStateSnapshot = (row: WorldStateRow) => ({
|
||||
scenarioCode: row.scenarioCode,
|
||||
currentYear: row.currentYear,
|
||||
@@ -122,6 +146,8 @@ export const worldRouter = router({
|
||||
if (me.officerLevel > 0 && me.nationId > 0) {
|
||||
cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id));
|
||||
nationGenerals.forEach((general) => selectable.add(general.cityId));
|
||||
}
|
||||
if ((nation?.level ?? 0) > 0) {
|
||||
Object.keys(spy).forEach((id) => selectable.add(Number(id)));
|
||||
}
|
||||
if (admin) cities.forEach((city) => selectable.add(city.id));
|
||||
@@ -150,6 +176,8 @@ export const worldRouter = router({
|
||||
turnMap.set(turn.generalId, list);
|
||||
}
|
||||
const nationMap = new Map(nations.map((item) => [item.id, item]));
|
||||
const selectedNation = nationMap.get(selected.nationId);
|
||||
const crewTypeNames = await loadCrewTypeNames(unitSetName(world, ctx.profile.id));
|
||||
const officers = await ctx.db.general.findMany({
|
||||
where: { officerLevel: { in: [2, 3, 4] } },
|
||||
select: { name: true, officerLevel: true, meta: true },
|
||||
@@ -175,14 +203,59 @@ export const worldRouter = router({
|
||||
intelligence: general.intel,
|
||||
injury: general.injury,
|
||||
officerLevel: general.officerLevel,
|
||||
leadershipBonus: leadershipBonus(general.officerLevel, nationMap.get(general.nationId)?.level ?? 0),
|
||||
defenceTrain: ours ? defenceTrain(general.meta) : null,
|
||||
crewTypeId: ours ? general.crewTypeId : null,
|
||||
crewTypeName: ours ? (crewTypeNames.get(general.crewTypeId) ?? null) : null,
|
||||
crew: ours || full ? general.crew : null,
|
||||
train: ours ? general.train : null,
|
||||
atmos: ours ? general.atmos : null,
|
||||
turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [],
|
||||
};
|
||||
});
|
||||
const forceSummary = mappedGenerals.reduce(
|
||||
(summary, general) => {
|
||||
if (general.nationId > 0 && me.nationId > 0 && general.nationId !== me.nationId) {
|
||||
summary.enemyGenerals += 1;
|
||||
if (general.crew !== null && general.crew >= 0) summary.enemyCrew += general.crew;
|
||||
if (general.crew !== null && general.crew > 0) summary.enemyArmedGenerals += 1;
|
||||
return summary;
|
||||
}
|
||||
if (me.nationId <= 0 || general.nationId !== me.nationId) return summary;
|
||||
summary.ownGenerals += 1;
|
||||
summary.ownCrew += general.crew ?? 0;
|
||||
if ((general.crew ?? 0) <= 0) return summary;
|
||||
summary.ownArmedGenerals += 1;
|
||||
const readiness = Math.min(general.train ?? -1, general.atmos ?? -1);
|
||||
if (readiness >= 90) {
|
||||
summary.ready90Crew += general.crew ?? 0;
|
||||
summary.ready90Generals += 1;
|
||||
}
|
||||
if (readiness >= 60) {
|
||||
summary.ready60Crew += general.crew ?? 0;
|
||||
summary.ready60Generals += 1;
|
||||
}
|
||||
if (general.defenceTrain !== null && readiness >= general.defenceTrain) {
|
||||
summary.defenceReadyCrew += general.crew ?? 0;
|
||||
summary.defenceReadyGenerals += 1;
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
enemyCrew: 0,
|
||||
enemyArmedGenerals: 0,
|
||||
enemyGenerals: 0,
|
||||
ownCrew: 0,
|
||||
ownArmedGenerals: 0,
|
||||
ownGenerals: 0,
|
||||
ready90Crew: 0,
|
||||
ready90Generals: 0,
|
||||
ready60Crew: 0,
|
||||
ready60Generals: 0,
|
||||
defenceReadyCrew: 0,
|
||||
defenceReadyGenerals: 0,
|
||||
}
|
||||
);
|
||||
return {
|
||||
me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin },
|
||||
options: [...selectable]
|
||||
@@ -194,6 +267,7 @@ export const worldRouter = router({
|
||||
id: selected.id,
|
||||
name: selected.name,
|
||||
nationId: selected.nationId,
|
||||
nationColor: selectedNation?.color ?? '#000000',
|
||||
level: selected.level,
|
||||
region: selected.region,
|
||||
population: redact(selected.population),
|
||||
@@ -217,8 +291,11 @@ export const worldRouter = router({
|
||||
},
|
||||
},
|
||||
generals: mappedGenerals,
|
||||
forceSummary,
|
||||
lastExecute:
|
||||
typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '',
|
||||
typeof asRecord(world?.meta).turntime === 'string'
|
||||
? String(asRecord(world?.meta).turntime).slice(5, 19)
|
||||
: '',
|
||||
};
|
||||
}),
|
||||
getState: procedure.query(async ({ ctx }) => {
|
||||
|
||||
@@ -53,13 +53,13 @@ const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
});
|
||||
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
|
||||
const auth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
|
||||
sessionId: 'session',
|
||||
user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles },
|
||||
user: { id: userId, username: 'tester', displayName: 'Tester', roles },
|
||||
sanctions: {},
|
||||
});
|
||||
const city = (id: number, nationId: number) => ({
|
||||
@@ -88,15 +88,30 @@ const city = (id: number, nationId: number) => ({
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record<string, unknown> } = {}) => {
|
||||
const context = (
|
||||
options: {
|
||||
me?: GeneralRow;
|
||||
roles?: string[];
|
||||
userId?: string;
|
||||
nationMeta?: Record<string, unknown>;
|
||||
nationLevel?: number;
|
||||
stationCityId?: number;
|
||||
} = {}
|
||||
) => {
|
||||
const me = options.me ?? general();
|
||||
const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)];
|
||||
const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 });
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => me),
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||
where.userId === me.userId ? me : null
|
||||
),
|
||||
findMany: vi.fn(async (args: { where?: Record<string, unknown>; select?: Record<string, boolean> }) => {
|
||||
if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }];
|
||||
if (args.where?.nationId === 1 && args.select?.cityId)
|
||||
return [
|
||||
{ cityId: me.cityId },
|
||||
...(options.stationCityId ? [{ cityId: options.stationCityId }] : []),
|
||||
];
|
||||
if (args.where?.cityId === 2) return [foreign];
|
||||
if (args.where?.cityId === 3) return [foreign];
|
||||
if (args.where?.officerLevel) return [];
|
||||
@@ -108,7 +123,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
|
||||
id: 1,
|
||||
name: '아국',
|
||||
color: '#008000',
|
||||
level: 1,
|
||||
level: options.nationLevel ?? 1,
|
||||
capitalCityId: 1,
|
||||
meta: options.nationMeta ?? {},
|
||||
})),
|
||||
@@ -130,7 +145,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
|
||||
turnDaemon: {} as GameApiContext['turnDaemon'],
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth: auth(options.roles),
|
||||
auth: auth(options.roles, options.userId),
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
@@ -157,6 +172,25 @@ describe('in-game information permissions', () => {
|
||||
expect(result.generals).toEqual([]);
|
||||
});
|
||||
|
||||
it('derives the actor from the session user instead of accepting another user general', async () => {
|
||||
const caller = appRouter.createCaller(context({ userId: 'user-2' }));
|
||||
await expect(caller.world.getCurrentCity({ cityId: 1 })).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('allows a nation member to select a city occupied by another general of the same nation', async () => {
|
||||
const result = await appRouter.createCaller(context({ stationCityId: 2 })).world.getCurrentCity({ cityId: 2 });
|
||||
expect(result.options.map((entry) => entry.id)).toContain(2);
|
||||
expect(result.visibility.full).toBe(true);
|
||||
});
|
||||
|
||||
it('does not grant a spy city while the nation has no active level', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(context({ nationMeta: { spy: { 2: 2 } }, nationLevel: 0 }))
|
||||
.world.getCurrentCity({ cityId: 2 });
|
||||
expect(result.options.map((entry) => entry.id)).not.toContain(2);
|
||||
expect(result.visibility.full).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps adjacent foreign detail redacted and never reveals military fields', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(context({ me: general({ cityId: 80 }) }))
|
||||
@@ -174,12 +208,20 @@ describe('in-game information permissions', () => {
|
||||
expect(result.visibility.full).toBe(true);
|
||||
expect(result.city.population).toBe(1000);
|
||||
expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null });
|
||||
expect(result.forceSummary).toMatchObject({
|
||||
enemyCrew: 777,
|
||||
enemyArmedGenerals: 1,
|
||||
enemyGenerals: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows administrative roles to inspect all city and general fields', async () => {
|
||||
const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 });
|
||||
expect(result.options).toHaveLength(4);
|
||||
expect(result.visibility.full).toBe(true);
|
||||
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
|
||||
});
|
||||
it.each(['admin', 'superuser', 'admin.superuser'])(
|
||||
'allows the %s role to inspect all city and general fields',
|
||||
async (role) => {
|
||||
const result = await appRouter.createCaller(context({ roles: [role] })).world.getCurrentCity({ cityId: 3 });
|
||||
expect(result.options).toHaveLength(4);
|
||||
expect(result.visibility.full).toBe(true);
|
||||
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const artifactRoot = process.env.CITY_PARITY_ARTIFACT_DIR;
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
|
||||
const readImage = async (relativePath: string): Promise<Buffer> => {
|
||||
if (relativePath.includes('..')) throw new Error(`Unsafe fixture image path: ${relativePath}`);
|
||||
for (const root of imageRoots) {
|
||||
try {
|
||||
return await readFile(resolve(root, relativePath));
|
||||
} catch {
|
||||
// Product checkout and feature worktrees have different image-root parents.
|
||||
}
|
||||
}
|
||||
throw new Error(`Fixture image not found: ${relativePath}`);
|
||||
};
|
||||
const imageContentType = (relativePath: string) => {
|
||||
if (relativePath.endsWith('.png')) return 'image/png';
|
||||
if (relativePath.endsWith('.gif')) return 'image/gif';
|
||||
return 'image/jpeg';
|
||||
};
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
const city = {
|
||||
@@ -46,19 +68,68 @@ const layout = {
|
||||
regionMap: { 1: '하북' },
|
||||
levelMap: { 8: '특' },
|
||||
};
|
||||
const generalContext = {
|
||||
general: {
|
||||
id: 1,
|
||||
name: '장수',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
officerLevel: 1,
|
||||
npcState: 0,
|
||||
troopId: 0,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 500,
|
||||
train: 90,
|
||||
atmos: 90,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
||||
},
|
||||
city,
|
||||
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
|
||||
settings: {},
|
||||
penalties: {},
|
||||
};
|
||||
const emptyMessages = {
|
||||
private: [],
|
||||
national: [],
|
||||
public: [],
|
||||
diplomacy: [],
|
||||
sequence: -1,
|
||||
hasMore: { private: false, national: false, public: false, diplomacy: false },
|
||||
latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 },
|
||||
canRespondDiplomacy: false,
|
||||
};
|
||||
|
||||
const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_info');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
await page.route('**/image/game/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
||||
);
|
||||
await page.route('**/image/**', async (route) => {
|
||||
const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? '');
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: imageContentType(relativePath),
|
||||
body: await readImage(relativePath),
|
||||
});
|
||||
});
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'general.me') return response(generalContext);
|
||||
if (operation === 'world.getMap') return response(map);
|
||||
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||
if (operation === 'turns.reserved.getGeneral') return response([]);
|
||||
if (operation === 'messages.getRecent') return response(emptyMessages);
|
||||
if (operation === 'board.getAccess') return response({ canMeeting: false, canSecret: false });
|
||||
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||
if (operation === 'nation.getNationInfo')
|
||||
return response({
|
||||
nation: {
|
||||
@@ -158,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
|
||||
id: 1,
|
||||
name: '업',
|
||||
nationId: 1,
|
||||
nationColor: '#008000',
|
||||
level: 8,
|
||||
region: 1,
|
||||
population: mode === 'wanderer' ? null : 150000,
|
||||
@@ -193,14 +265,30 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
|
||||
intelligence: 50,
|
||||
injury: 0,
|
||||
officerLevel: 1,
|
||||
leadershipBonus: 0,
|
||||
defenceTrain: 80,
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
crew: 500,
|
||||
train: 90,
|
||||
atmos: 90,
|
||||
turns: ['징병'],
|
||||
},
|
||||
],
|
||||
forceSummary: {
|
||||
enemyCrew: 0,
|
||||
enemyArmedGenerals: 0,
|
||||
enemyGenerals: 0,
|
||||
ownCrew: mode === 'wanderer' ? 0 : 500,
|
||||
ownArmedGenerals: mode === 'wanderer' ? 0 : 1,
|
||||
ownGenerals: mode === 'wanderer' ? 0 : 1,
|
||||
ready90Crew: mode === 'wanderer' ? 0 : 500,
|
||||
ready90Generals: mode === 'wanderer' ? 0 : 1,
|
||||
ready60Crew: mode === 'wanderer' ? 0 : 500,
|
||||
ready60Generals: mode === 'wanderer' ? 0 : 1,
|
||||
defenceReadyCrew: mode === 'wanderer' ? 0 : 500,
|
||||
defenceReadyGenerals: mode === 'wanderer' ? 0 : 1,
|
||||
},
|
||||
lastExecute: '2026-07-26',
|
||||
});
|
||||
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||
@@ -215,11 +303,11 @@ const go = async (page: Page, path: string) => {
|
||||
test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
for (const [path, selector] of [
|
||||
['nation/info', '.legacy-info-page'],
|
||||
['nation/cities', '.nation-cities-page'],
|
||||
['global-info', '.global-page'],
|
||||
['current-city', '.city-page'],
|
||||
for (const [path, selector, fontSize, fontFamily, borderCollapse] of [
|
||||
['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'],
|
||||
['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'],
|
||||
['global-info', '.global-page', '14px', 'Pretendard', 'collapse'],
|
||||
['current-city', '.city-page', '16px', 'Times New Roman', 'separate'],
|
||||
] as const) {
|
||||
await go(page, path);
|
||||
await expect(page.locator(selector)).toBeVisible();
|
||||
@@ -230,14 +318,14 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p
|
||||
});
|
||||
expect(box.width).toBe(1000);
|
||||
expect(box.x).toBe(100);
|
||||
expect(box.fontSize).toBe('14px');
|
||||
expect(box.fontFamily).toContain('Pretendard');
|
||||
expect(box.fontSize).toBe(fontSize);
|
||||
expect(box.fontFamily).toContain(fontFamily);
|
||||
expect(
|
||||
await page
|
||||
.locator('table')
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).borderCollapse)
|
||||
).toBe('collapse');
|
||||
).toBe(borderCollapse);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -250,7 +338,100 @@ test('current-city hides values and general rows for a wandering user', async ({
|
||||
|
||||
test('current-city exposes own general details to a member and admin fixture', async ({ page }) => {
|
||||
await install(page, 'admin');
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await go(page, 'current-city');
|
||||
await expect(page.locator('.generals')).toContainText('장수');
|
||||
await expect(page.locator('.generals')).toContainText('90');
|
||||
const legacyGeometry = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
const box = document.querySelector(selector)?.getBoundingClientRect();
|
||||
return box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null;
|
||||
};
|
||||
const icon = document.querySelector<HTMLImageElement>('.general-icon');
|
||||
return {
|
||||
selector: rect('#citySelector'),
|
||||
stats: rect('.stats'),
|
||||
generals: rect('.generals'),
|
||||
titleAlign: getComputedStyle(document.querySelector('.city-page > table:first-child td')!).textAlign,
|
||||
icon: icon
|
||||
? {
|
||||
...rect('.general-icon'),
|
||||
naturalWidth: icon.naturalWidth,
|
||||
naturalHeight: icon.naturalHeight,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 });
|
||||
expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 });
|
||||
expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 });
|
||||
expect(legacyGeometry.titleAlign).toBe('start');
|
||||
expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 });
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
const computedDom = await page.evaluate(() => {
|
||||
const measure = (selector: string) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return null;
|
||||
const box = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
|
||||
style: {
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
borderCollapse: style.borderCollapse,
|
||||
padding: style.padding,
|
||||
textAlign: style.textAlign,
|
||||
},
|
||||
};
|
||||
};
|
||||
const icon = document.querySelector<HTMLImageElement>('.general-icon');
|
||||
return {
|
||||
body: measure('body'),
|
||||
page: measure('.city-page'),
|
||||
selector: measure('#citySelector'),
|
||||
stats: measure('.stats'),
|
||||
generals: measure('.generals'),
|
||||
title: measure('.city-title'),
|
||||
firstIcon: icon
|
||||
? {
|
||||
...measure('.general-icon'),
|
||||
naturalWidth: icon.naturalWidth,
|
||||
naturalHeight: icon.naturalHeight,
|
||||
}
|
||||
: null,
|
||||
document: {
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
});
|
||||
await writeFile(
|
||||
resolve(artifactRoot, 'core-current-city-computed-dom.json'),
|
||||
`${JSON.stringify(computedDom, null, 2)}\n`
|
||||
);
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, 'core-current-city-desktop.png'),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('a Chromium map click opens the clicked city route and keeps the legacy pointer interaction', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await go(page, '');
|
||||
const cityLink = page.locator('.map-city').first();
|
||||
await expect(cityLink).toBeVisible();
|
||||
await cityLink.hover();
|
||||
await expect(cityLink).toHaveCSS('cursor', 'pointer');
|
||||
await cityLink.click();
|
||||
await expect(page).toHaveURL(/\/che\/current-city\?cityId=1$/);
|
||||
await expect(page.locator('.stats')).toContainText('업');
|
||||
});
|
||||
|
||||
@@ -34,8 +34,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<RouterLink
|
||||
class="map-city"
|
||||
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
||||
:class="[
|
||||
`state-${props.city.stateClass}`,
|
||||
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
|
||||
@@ -45,20 +46,22 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
||||
@mouseleave="emit('leave')"
|
||||
@click.stop="emit('select', props.city.id)"
|
||||
>
|
||||
<div
|
||||
class="city-dot"
|
||||
:style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }"
|
||||
>
|
||||
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
|
||||
<span v-if="props.city.isCapital" class="capital" />
|
||||
</div>
|
||||
<div
|
||||
v-if="props.city.state > 0"
|
||||
class="city-state"
|
||||
:class="`state-${props.city.stateClass}`"
|
||||
:style="{ width: `${stateSize}px`, height: `${stateSize}px`, left: `${stateOffset}px`, top: `${stateOffset}px` }"
|
||||
:style="{
|
||||
width: `${stateSize}px`,
|
||||
height: `${stateSize}px`,
|
||||
left: `${stateOffset}px`,
|
||||
top: `${stateOffset}px`,
|
||||
}"
|
||||
/>
|
||||
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -71,6 +74,8 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.city-dot {
|
||||
|
||||
@@ -149,8 +149,9 @@ const cityStateStyle = computed(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<RouterLink
|
||||
class="city-base"
|
||||
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
||||
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
|
||||
:style="cityBaseStyle"
|
||||
@mouseenter="emit('hover', props.city.id)"
|
||||
@@ -172,7 +173,7 @@ const cityStateStyle = computed(() => ({
|
||||
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
|
||||
<img :src="stateIcon" />
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -181,6 +182,8 @@ const cityStateStyle = computed(() => ({
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232, 221, 196, 0.9);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.city-bg {
|
||||
|
||||
@@ -1,32 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { formatOfficerLevelText, cityLevelMap, regionMap } from '../utils/nationFormat';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
|
||||
type General = Result['generals'][number];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const data = ref<Result | null>(null);
|
||||
const error = ref('');
|
||||
const selected = ref<number>();
|
||||
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
|
||||
let loadSequence = 0;
|
||||
|
||||
const parseCityId = (): number | undefined => {
|
||||
const raw = route.query.cityId ?? route.query.citylist;
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) return undefined;
|
||||
const cityId = Number(value);
|
||||
return Number.isSafeInteger(cityId) && cityId > 0 ? cityId : undefined;
|
||||
};
|
||||
|
||||
const load = async (cityId?: number) => {
|
||||
const sequence = ++loadSequence;
|
||||
try {
|
||||
data.value = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
|
||||
selected.value = data.value.city.id;
|
||||
const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
|
||||
if (sequence !== loadSequence) return;
|
||||
data.value = result;
|
||||
selected.value = result.city.id;
|
||||
error.value = '';
|
||||
} catch (cause) {
|
||||
if (sequence !== loadSequence) return;
|
||||
error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [route.query.cityId, route.query.citylist],
|
||||
() => void load(parseCityId()),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const selectCity = async () => {
|
||||
if (!selected.value) return;
|
||||
await router.push({ name: 'current-city', query: { cityId: selected.value } });
|
||||
};
|
||||
|
||||
const city = computed(() => data.value?.city);
|
||||
onMounted(() => void load());
|
||||
const summary = computed(() => data.value?.forceSummary);
|
||||
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
|
||||
const showPair = (crew: number, generals: number) => `${show(crew)}/${show(generals)}`;
|
||||
const populationRate = computed(() => {
|
||||
if (!city.value || city.value.population === null) return '?';
|
||||
return String(Math.round((city.value.population / city.value.populationMax) * 10_000) / 100);
|
||||
});
|
||||
const contrastColors = new Set([
|
||||
'',
|
||||
'#330000',
|
||||
'#FF0000',
|
||||
'#800000',
|
||||
'#A0522D',
|
||||
'#FF6347',
|
||||
'#808000',
|
||||
'#008000',
|
||||
'#2E8B57',
|
||||
'#008080',
|
||||
'#6495ED',
|
||||
'#0000FF',
|
||||
'#000080',
|
||||
'#483D8B',
|
||||
'#7B68EE',
|
||||
'#800080',
|
||||
'#A9A9A9',
|
||||
'#000000',
|
||||
]);
|
||||
const cityTitleStyle = computed(() => {
|
||||
const backgroundColor = city.value?.nationColor.toUpperCase() ?? '#000000';
|
||||
return {
|
||||
backgroundColor,
|
||||
color: contrastColors.has(backgroundColor) ? '#FFFFFF' : '#000000',
|
||||
};
|
||||
});
|
||||
const woundedStat = (value: number, injury: number) =>
|
||||
injury === 0 ? value : Math.floor((value * (100 - injury)) / 100);
|
||||
const defenceTrainText = (value: number | null) => {
|
||||
if (value === null) return '?';
|
||||
if (value === 999) return '×';
|
||||
if (value >= 90) return '☆';
|
||||
if (value >= 80) return '◎';
|
||||
if (value >= 60) return '○';
|
||||
return '△';
|
||||
};
|
||||
const generalImage = (general: General) => {
|
||||
const picture = general.picture ?? 'default.jpg';
|
||||
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="city-page">
|
||||
<table class="legacy-table legacy-bg0 center">
|
||||
<table class="legacy-table legacy-bg0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>도 시 정 보<br /><RouterLink to="/">돌아가기</RouterLink></td>
|
||||
<td>도 시 정 보<br /><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -34,32 +112,57 @@ onMounted(() => void load());
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
도시선택 :
|
||||
<select v-model.number="selected" @change="load(selected)">
|
||||
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
|
||||
【{{ option.name.padEnd(4, '_') }}】{{
|
||||
option.nationId === data?.me.nationId
|
||||
? '본국'
|
||||
: option.nationId === 0
|
||||
? '공백지'
|
||||
: '타국'
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
|
||||
<form @submit.prevent="selectCity">
|
||||
<div>
|
||||
도시선택 :
|
||||
<select id="citySelector" v-model.number="selected" @change="selectCity">
|
||||
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
|
||||
【{{ option.name.padEnd(4, '_') }}】{{
|
||||
option.nationId === data?.me.nationId
|
||||
? '본국'
|
||||
: option.nationId === 0
|
||||
? '공백지'
|
||||
: '타국'
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<template v-if="data && city">
|
||||
<table class="legacy-table legacy-bg2 stats">
|
||||
<table class="legacy-table legacy-bg0 back-row">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="11" class="city-title">
|
||||
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="legacy-table legacy-bg2 stats">
|
||||
<colgroup>
|
||||
<col class="label-col" />
|
||||
<col class="first-value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="11" class="city-title" :style="cityTitleStyle">
|
||||
【 {{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} 】 {{ city.name }}
|
||||
</td>
|
||||
<td class="city-title">{{ data.lastExecute }}</td>
|
||||
<td class="city-title" :style="cityTitleStyle">{{ data.lastExecute }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>주민</th>
|
||||
@@ -79,15 +182,9 @@ onMounted(() => void load());
|
||||
<th>민심</th>
|
||||
<td>{{ show(city.trust) }}</td>
|
||||
<th>시세</th>
|
||||
<td>{{ city.trade ?? '-' }}%</td>
|
||||
<td>{{ city.trade ?? '- ' }}%</td>
|
||||
<th>인구</th>
|
||||
<td>
|
||||
{{
|
||||
city.population === null
|
||||
? '?'
|
||||
: ((city.population / city.populationMax) * 100).toFixed(2)
|
||||
}}%
|
||||
</td>
|
||||
<td>{{ populationRate }}%</td>
|
||||
<th>태수</th>
|
||||
<td>{{ city.officers[4] }}</td>
|
||||
<th>군사</th>
|
||||
@@ -95,19 +192,60 @@ onMounted(() => void load());
|
||||
<th>종사</th>
|
||||
<td>{{ city.officers[2] }}</td>
|
||||
</tr>
|
||||
<tr v-if="summary">
|
||||
<th>도시명</th>
|
||||
<td>{{ city.name }}</td>
|
||||
<th>적군</th>
|
||||
<td>
|
||||
{{ show(summary.enemyCrew) }}/{{ show(summary.enemyArmedGenerals) }}({{
|
||||
show(summary.enemyGenerals)
|
||||
}})
|
||||
</td>
|
||||
<th>병장(총)</th>
|
||||
<td>
|
||||
{{ show(summary.ownCrew) }}/{{ show(summary.ownArmedGenerals) }}({{
|
||||
show(summary.ownGenerals)
|
||||
}})
|
||||
</td>
|
||||
<th>90병장</th>
|
||||
<td>{{ showPair(summary.ready90Crew, summary.ready90Generals) }}</td>
|
||||
<th>60병장</th>
|
||||
<td>{{ showPair(summary.ready60Crew, summary.ready60Generals) }}</td>
|
||||
<th>수비○</th>
|
||||
<td>{{ showPair(summary.defenceReadyCrew, summary.defenceReadyGenerals) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>장수</th>
|
||||
<td colspan="11">
|
||||
{{
|
||||
data.visibility.detailed
|
||||
? data.generals.map((g) => g.name).join(', ') || '-'
|
||||
: '알 수 없음'
|
||||
}}
|
||||
<td colspan="11" class="general-names">
|
||||
<template v-if="data.visibility.detailed">
|
||||
<template v-if="data.generals.length">
|
||||
<template v-for="(general, index) in data.generals" :key="general.id">
|
||||
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
|
||||
><template v-if="index < data.generals.length - 1">, </template>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</template>
|
||||
<span v-else class="unknown">알 수 없음</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table v-if="data.visibility.detailed" class="legacy-table legacy-bg0 generals">
|
||||
<table v-if="data.visibility.detailed" id="general_list" class="legacy-table legacy-bg0 generals">
|
||||
<colgroup>
|
||||
<col style="width: 64px" />
|
||||
<col style="width: 128px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 78px" />
|
||||
<col style="width: 28px" />
|
||||
<col style="width: 78px" />
|
||||
<col style="width: 78px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 280px" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>얼 굴</th>
|
||||
@@ -125,42 +263,53 @@ onMounted(() => void load());
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="general in data.generals" :key="general.id">
|
||||
<td>
|
||||
<img
|
||||
v-if="general.picture"
|
||||
width="64"
|
||||
height="64"
|
||||
:src="`/image/general/${general.picture}`"
|
||||
/>
|
||||
<tr
|
||||
v-for="general in data.generals"
|
||||
:key="general.id"
|
||||
:data-is-our-general="general.train !== null"
|
||||
:data-general-wounded="general.injury"
|
||||
>
|
||||
<td class="icon-cell">
|
||||
<img class="general-icon" width="64" height="64" :src="generalImage(general)" />
|
||||
</td>
|
||||
<td :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
|
||||
<td :class="{ wounded: general.injury !== 0 }">
|
||||
{{ woundedStat(general.leadership, general.injury)
|
||||
}}<span v-if="general.leadershipBonus" class="leadership-bonus"
|
||||
>+{{ general.leadershipBonus }}</span
|
||||
>
|
||||
</td>
|
||||
<td :class="{ wounded: general.injury !== 0 }">
|
||||
{{ woundedStat(general.strength, general.injury) }}
|
||||
</td>
|
||||
<td :class="{ wounded: general.injury !== 0 }">
|
||||
{{ woundedStat(general.intelligence, general.injury) }}
|
||||
</td>
|
||||
<td>{{ general.name }}</td>
|
||||
<td>{{ general.leadership }}</td>
|
||||
<td>{{ general.strength }}</td>
|
||||
<td>{{ general.intelligence }}</td>
|
||||
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
|
||||
<td>{{ general.defenceTrain ?? '?' }}</td>
|
||||
<td>{{ general.crewTypeId ?? '?' }}</td>
|
||||
<td>{{ defenceTrainText(general.defenceTrain) }}</td>
|
||||
<td>{{ general.crewTypeName ?? '?' }}</td>
|
||||
<td>{{ general.crew ?? '?' }}</td>
|
||||
<td>{{ general.train ?? '?' }}</td>
|
||||
<td>{{ general.atmos ?? '?' }}</td>
|
||||
<td class="turns">
|
||||
{{
|
||||
general.turns.length
|
||||
? general.turns.map((turn, index) => `${index + 1} : ${turn}`).join(' / ')
|
||||
: general.npcState > 1
|
||||
? 'NPC 장수'
|
||||
: `【${general.nationName}】 장수`
|
||||
}}
|
||||
<template v-if="general.turns.length">
|
||||
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
|
||||
>{{ index + 1 }} : {{ turn }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else-if="general.npcState > 1">NPC 장수</template>
|
||||
<template v-else-if="general.nationId !== data.me.nationId">
|
||||
{{ general.nationId === 0 ? '재 야' : `【${general.nationName}】 장수` }}
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<table class="legacy-table legacy-bg0 center footer">
|
||||
<table class="legacy-table legacy-bg0 footer">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><RouterLink to="/">돌아가기</RouterLink></td>
|
||||
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -170,60 +319,138 @@ onMounted(() => void load());
|
||||
<style scoped>
|
||||
.city-page {
|
||||
width: 1000px;
|
||||
margin: 0 auto;
|
||||
font-size: 14px;
|
||||
margin: 8px auto 0;
|
||||
font-family: 'Times New Roman', serif;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
}
|
||||
.legacy-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-collapse: separate;
|
||||
border-spacing: 2px;
|
||||
}
|
||||
.legacy-table td,
|
||||
.legacy-table th {
|
||||
border: 1px solid #777;
|
||||
padding: 3px;
|
||||
border: 0;
|
||||
padding: 1px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.center {
|
||||
.center,
|
||||
.selector,
|
||||
.stats td,
|
||||
.stats th,
|
||||
.generals th,
|
||||
.generals td:not(:last-child) {
|
||||
text-align: center;
|
||||
}
|
||||
.selector {
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
.selector select {
|
||||
display: inline-block;
|
||||
min-width: 400px;
|
||||
height: 19px;
|
||||
padding: 0;
|
||||
border: 1px solid #767676;
|
||||
background: #6b6b6b;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 13.3333px;
|
||||
}
|
||||
.selector {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.selector p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
.back-row {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.stats {
|
||||
margin-top: 14px;
|
||||
margin-top: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.label-col {
|
||||
width: 48px;
|
||||
}
|
||||
.value-col {
|
||||
width: 108px;
|
||||
}
|
||||
.first-value-col {
|
||||
width: 112px;
|
||||
}
|
||||
.stats th,
|
||||
.generals th {
|
||||
background-color: #14241b;
|
||||
background-image: url('/image/game/back_green.jpg');
|
||||
text-align: center;
|
||||
}
|
||||
.stats td {
|
||||
text-align: center;
|
||||
}
|
||||
.city-title {
|
||||
text-align: center;
|
||||
}
|
||||
.generals {
|
||||
margin-top: 14px;
|
||||
.stats {
|
||||
height: 136px;
|
||||
}
|
||||
.generals td {
|
||||
text-align: center;
|
||||
.general-names {
|
||||
text-align: left !important;
|
||||
}
|
||||
.unknown {
|
||||
color: gray;
|
||||
}
|
||||
.generals {
|
||||
width: 1024px;
|
||||
margin: 18px 0 0 50%;
|
||||
table-layout: fixed;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.generals td:last-child {
|
||||
text-align: left;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.icon-cell {
|
||||
height: 64px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.generals tbody tr {
|
||||
height: 72px;
|
||||
}
|
||||
.general-icon {
|
||||
display: block;
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
height: 64px;
|
||||
object-fit: fill;
|
||||
}
|
||||
.turns {
|
||||
font-size: x-small;
|
||||
}
|
||||
.turn-line {
|
||||
display: block;
|
||||
}
|
||||
.wounded {
|
||||
color: red;
|
||||
}
|
||||
.leadership-bonus {
|
||||
color: cyan;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
border: 1px solid #6c757d;
|
||||
border-radius: 0.2rem;
|
||||
background: #6c757d;
|
||||
color: #fff;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover,
|
||||
.back-link:focus,
|
||||
.back-link:active {
|
||||
border-color: #565e64;
|
||||
background: #5c636a;
|
||||
color: #fff;
|
||||
}
|
||||
.error {
|
||||
text-align: center;
|
||||
color: #ff7373;
|
||||
@@ -233,8 +460,5 @@ onMounted(() => void load());
|
||||
width: 1000px;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.selector select {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -43,23 +43,24 @@ storage, route guards, and image loading.
|
||||
|
||||
## Enforced contracts
|
||||
|
||||
| Screen | Ref entry point | Current automated contract |
|
||||
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
|
||||
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
|
||||
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
|
||||
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
||||
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
||||
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
|
||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
|
||||
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
||||
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||
| Screen | Ref entry point | Current automated contract |
|
||||
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
|
||||
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
|
||||
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
|
||||
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
||||
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
||||
| current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
|
||||
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
|
||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
|
||||
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
||||
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||
|
||||
The global game baseline is black, white, Pretendard 14px. Legacy texture
|
||||
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
||||
@@ -97,6 +98,14 @@ reference collection, `tools/frontend-legacy-parity/reference-nation-offices.mjs
|
||||
records desktop/500px computed DOM and screenshots from the PHP service without
|
||||
changing its product code.
|
||||
|
||||
The current-city reference can be collected independently with
|
||||
`tools/frontend-legacy-parity/reference-current-city.mjs`. It requires
|
||||
`REF_PARITY_PASSWORD_FILE` and accepts `REF_PARITY_URL`, `REF_PARITY_USER`, and
|
||||
`REF_PARITY_ARTIFACT_DIR`; the password is read from the ignored secret file
|
||||
and is never written to the artifact. The matching core fixture is
|
||||
`app/game-frontend/e2e/inGameInfo.spec.ts`, which writes its computed DOM and
|
||||
screenshot only when `CITY_PARITY_ARTIFACT_DIR` is set.
|
||||
|
||||
For a review run that also writes full-page screenshots, create an ignored
|
||||
artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the
|
||||
suite. The ordinary CI run does not write screenshots after successful tests.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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_PARITY_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||
const username = process.env.REF_PARITY_USER ?? 'refadmin';
|
||||
const passwordFile = process.env.REF_PARITY_PASSWORD_FILE;
|
||||
const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-current-city');
|
||||
|
||||
if (!passwordFile) {
|
||||
throw new Error('REF_PARITY_PASSWORD_FILE is required.');
|
||||
}
|
||||
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1200, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'Asia/Seoul',
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const loginResponse = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const loginResult = await loginResponse.json();
|
||||
if (!loginResponse.ok() || loginResult.result !== true) {
|
||||
throw new Error('Reference login failed.');
|
||||
}
|
||||
|
||||
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
const mapCity = page.locator('a[href*="b_currentCity.php?citylist="]').first();
|
||||
const hasMapCity = await mapCity.isVisible({ timeout: 8_000 }).catch(() => false);
|
||||
let mapInteraction;
|
||||
if (hasMapCity) {
|
||||
mapInteraction = await mapCity.evaluate((element) => ({
|
||||
available: true,
|
||||
href: element.getAttribute('href'),
|
||||
cursor: getComputedStyle(element).cursor,
|
||||
rect: (() => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
})(),
|
||||
}));
|
||||
await mapCity.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
if (!page.url().includes('b_currentCity.php?citylist=')) {
|
||||
throw new Error(`Reference map click did not open current city: ${page.url()}`);
|
||||
}
|
||||
} else {
|
||||
mapInteraction = {
|
||||
available: false,
|
||||
pageUrl: page.url(),
|
||||
pageTitle: await page.title(),
|
||||
};
|
||||
await page.goto(new URL('hwe/b_currentCity.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
}
|
||||
|
||||
const measurements = await page.evaluate(() => {
|
||||
const measure = (element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
|
||||
style: {
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
borderCollapse: style.borderCollapse,
|
||||
padding: style.padding,
|
||||
textAlign: style.textAlign,
|
||||
},
|
||||
};
|
||||
};
|
||||
const tables = [...document.querySelectorAll('table')];
|
||||
const selector = document.querySelector('#citySelector');
|
||||
const stats = tables.find((table) => table.textContent?.includes('90병장'));
|
||||
const generals = document.querySelector('#general_list')?.closest('table');
|
||||
const firstIcon = document.querySelector('.generalIcon');
|
||||
const title = stats?.querySelector('tr:first-child td');
|
||||
return {
|
||||
body: measure(document.body),
|
||||
tables: tables.map(measure),
|
||||
selector: selector ? measure(selector) : null,
|
||||
stats: stats ? measure(stats) : null,
|
||||
generals: generals ? measure(generals) : null,
|
||||
firstIcon: firstIcon
|
||||
? {
|
||||
...measure(firstIcon),
|
||||
naturalWidth: firstIcon.naturalWidth,
|
||||
naturalHeight: firstIcon.naturalHeight,
|
||||
}
|
||||
: null,
|
||||
title: title ? measure(title) : null,
|
||||
document: {
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, 'reference-current-city-desktop.png'),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
await writeFile(
|
||||
resolve(artifactRoot, 'reference-current-city-computed-dom.json'),
|
||||
`${JSON.stringify({ mapInteraction, currentCity: measurements }, null, 2)}\n`
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot })}\n`);
|
||||
await context.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user