merge: complete map-to-city page parity

This commit is contained in:
2026-07-26 05:14:10 +00:00
8 changed files with 805 additions and 136 deletions
+78 -1
View File
@@ -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 }) => {
+55 -13
View File
@@ -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 });
}
);
});
+192 -11
View File
@@ -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 {
+309 -85
View File
@@ -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>
+26 -17
View File
@@ -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();
}