diff --git a/apps/tools/DESIGN.md b/apps/tools/DESIGN.md index 5eaca3b4..7d6881c2 100644 --- a/apps/tools/DESIGN.md +++ b/apps/tools/DESIGN.md @@ -83,6 +83,38 @@ weight families use the same semantic weight tokens at 500, 600, and 700. directly. Tabs and modal dialogs currently use these thin wrappers. Native buttons, fields, checks, ranges, and simple selects stay native. +### Map planner + +Elite skills extends the shared frames, controls, skill slots, and skill details. +Classic, Modern, and Custom keep the same layout and interaction. Interface +type serves controls and Reading serves encounter notes. Capture data and +tracking behavior belong in +[`docs/elite-skills.md`](../../docs/elite-skills.md). + +- Anchor the trigger and planner inside the world map's upper-right corner. + Keep the planner compact (356px, capped by viewport width) and within the + available map height. Search and filters precede flat skill rows; opening a + skill replaces the list with details and capture locations. +- At short viewport heights (700px or less), scroll the whole planner so + errors, search, results, and footer remain reachable. At taller heights, + results and details scroll inside the panel. Hide pointer previews at + narrow widths (650px or less). +- Keep each known position as an individual skill icon (28px). Do not combine + nearby markers into numbers or add an intermediate group view. + Distinguish the active boss with an accent outline and text label; a target + outside the view uses a dashed edge marker and an explicit label. +- Hover or keyboard focus previews a skill; activation opens persistent + details. Keep previews non-interactive. Search and selection are immediate. +- Keep **Back to skills** and **Close** in a sticky header, including short + windows. Returning to skills preserves search and filters. +- Keyboard opening moves focus to search or the detail Back control. Back + restores the result row when available, and closing returns focus to the + map trigger or tracker control. Buttons use the shared bright two-pixel + focus outline, separate from the active marker's accent outline. +- Place the compact mission tracker inside the mission map's upper-left + corner. Keep its target, status, and manage action together. Empty map + space remains outside the overlay's pointer hit area. + ### Control states - A default button is a routine command such as Export, Copy, Open, or Browse. diff --git a/apps/tools/src/EliteSkillsApp.test.ts b/apps/tools/src/EliteSkillsApp.test.ts new file mode 100644 index 00000000..887b7c4d --- /dev/null +++ b/apps/tools/src/EliteSkillsApp.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi } from "vitest"; +import { flushPromises, mount } from "@vue/test-utils"; +import { ref } from "vue"; +import EliteSkillsApp from "./EliteSkillsApp.vue"; +import { ELITE_FIXTURE_SKILLS, eliteFixtureView } from "./elite-fixture"; +import { createSkillCatalogue } from "./skill-catalog"; +import { useEliteTracking, type EliteTrackingHost } from "./use-elite-tracking"; +import { ELITE_LOCATIONS } from "../../../src/shared/elite-locations"; +import { EMPTY_ELITE_TRACKING, changeEliteTracking, type EliteTracking } from "../../../src/shared/elite-skills"; +import { travelCharacterKey, type TravelCharacterKey } from "../../../src/shared/travel-history"; +import { eliteMarkers } from "./elite-map-markers"; + +function host(): EliteTrackingHost { + let current = EMPTY_ELITE_TRACKING; + return { get: vi.fn(async () => current), update: vi.fn(async ({ change }) => { + current = changeEliteTracking(current, change, ELITE_LOCATIONS); return current; + }) }; +} +async function planner(trackingHost = host()) { + const wrapper = mount(EliteSkillsApp, { attachTo: document.body, props: { + view: eliteFixtureView(), catalogue: createSkillCatalogue(ELITE_FIXTURE_SKILLS), catalogueVersion: 1, + catalogueProblem: "", trackingHost, openWiki: vi.fn(), reloadSkills: vi.fn(), + } }); + await flushPromises(); + await wrapper.get('.elite-map-trigger').trigger('click'); + return wrapper; +} +const lissah = ELITE_LOCATIONS.find((entry) => entry.boss === "Lissah the Packleader")!; + +describe("Elite Skills", () => { + it("finds a skill through boss and area names, and carries the selected boss to the mission map", async () => { + const wrapper = await planner(); + await wrapper.get('input[type="search"]').setValue("Lissah"); + expect(wrapper.findAll('.elite-result')).toHaveLength(1); + expect(wrapper.get('.elite-result').text()).toContain('Eviscerate'); + await wrapper.get('.elite-result-main').trigger('click'); + const target = wrapper.findAll('.elite-location').find((entry) => entry.text().includes('Lissah'))!; + await target.get('button').trigger('click'); + await flushPromises(); + expect(target.text()).toContain('Tracking this boss'); + await wrapper.setProps({ view: eliteFixtureView(true) }); + expect(wrapper.find('.elite-panel').exists()).toBe(false); + expect(wrapper.get('.elite-mission-tracker').text()).toContain('Lissah'); + expect(wrapper.findAll('.elite-marker')).toHaveLength(1); + const missionView = eliteFixtureView(true); + await wrapper.setProps({ view: { ...missionView, mission: { box: missionView.mission!.box, transform: null } } }); + expect(wrapper.findAll('.elite-marker')).toHaveLength(0); + expect(wrapper.get('.elite-mission-tracker').text()).toContain('Boss markers are unavailable in this area'); + await wrapper.setProps({ view: { ...eliteFixtureView(true), mapId: 55 } }); + expect(wrapper.findAll('.elite-marker')).toHaveLength(0); + expect(wrapper.get('.elite-mission-tracker').text()).toContain('Target is in Bjora Marches'); + wrapper.unmount(); + }); + it("keeps unknown skills visible and uses character learned status", async () => { + const wrapper = await planner(); + expect(wrapper.text()).not.toContain('Learned status unavailable'); + await wrapper.setProps({ view: eliteFixtureView(false, false, true) }); + expect(wrapper.findAll('.elite-result').map((entry) => entry.text()).join()).not.toContain('Eviscerate'); + await wrapper.setProps({ view: { ...eliteFixtureView(), observation: { status: 'waiting' } } }); + expect(wrapper.text()).toContain('Learned status unavailable'); + expect(wrapper.findAll('.elite-result').map((entry) => entry.text()).join()).toContain('Eviscerate'); + wrapper.unmount(); + }); + it("shows failed saves as unchanged and retries the intended action", async () => { + const api = host(); + const update = vi.spyOn(api, 'update'); + update.mockRejectedValueOnce(new Error('disk full')); + const wrapper = await planner(api); + await wrapper.get('input[type="search"]').setValue('Eviscerate'); + await wrapper.get('.elite-track-button').trigger('click'); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toContain('saved plan is unchanged'); + expect(wrapper.get('.elite-track-button').attributes('aria-pressed')).toBe('false'); + await wrapper.get('[role="alert"] .ui-button').trigger('click'); + await flushPromises(); + expect(wrapper.get('.elite-track-button').attributes('aria-pressed')).toBe('true'); + wrapper.unmount(); + }); + it("withdraws missing coordinates rather than placing them at zero", async () => { + const missing = ELITE_LOCATIONS.find((entry) => entry.boss === 'Reaper of Agony')!; + const view = eliteFixtureView(true); + expect(eliteMarkers([missing], { box: view.mission!.box, transform: view.mission!.transform! }, missing.id)).toEqual([]); + const markers = eliteMarkers([lissah], { ...view.mission!, transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 } }, lissah.id); + expect(markers[0]?.outside).toBe(true); + expect(markers[0]!.x).toBeLessThan(view.mission!.box.width); + }); + it("keeps nearby and alternate positions as individual skill markers", () => { + const surface = { box: { left: 0, top: 0, width: 400, height: 300 }, + transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 } }; + const locations = [ + { ...lissah, id: 'a', points: [[35, 80], [36, 80]] as const }, + { ...lissah, id: 'b', points: [[37, 80]] as const }, + ]; + const markers = eliteMarkers(locations, surface, 'b'); + expect(markers.map(marker => [marker.key, marker.x, marker.location.id])).toEqual([ + ['a:0', 35, 'a'], ['a:1', 36, 'a'], ['b:0', 37, 'b'], + ]); + expect(markers.filter(marker => marker.active).map(marker => marker.location.id)).toEqual(['b']); + }); + it("maintains keyboard focus through opening, details, back, and collapse", async () => { + const wrapper = await planner(); + expect(document.activeElement).toBe(wrapper.get('input[type="search"]').element); + await wrapper.get('input[type="search"]').setValue('Eviscerate'); + const row = wrapper.get('.elite-result-main'); + await row.trigger('click', { detail: 0 }); + expect(document.activeElement).toBe(wrapper.get('.elite-back').element); + await wrapper.get('.elite-back').trigger('click', { detail: 0 }); + expect(document.activeElement).toBe(wrapper.get('.elite-result-main').element); + expect(wrapper.get('input[type="search"]').element.value).toBe('Eviscerate'); + await wrapper.get('[aria-label="Close Elite Skills"]').trigger('click', { detail: 0 }); + expect(document.activeElement).toBe(wrapper.get('.elite-map-trigger').element); + wrapper.unmount(); + }); + it("ignores an old character's pending load and save after switching characters", async () => { + const character = ref(travelCharacterKey('0123456789abcdef')); + let resolveLoad!: (value: EliteTracking) => void; + let resolveSave!: (value: EliteTracking) => void; + const api: EliteTrackingHost = { get: vi.fn().mockImplementationOnce(() => new Promise((resolve) => { resolveLoad = resolve; })).mockResolvedValue(EMPTY_ELITE_TRACKING), + update: () => new Promise((resolve) => { resolveSave = resolve; }) }; + const controller = useEliteTracking(character, api); + character.value = travelCharacterKey('fedcba9876543210'); + await flushPromises(); + resolveLoad({ skills: [338], activeLocation: lissah.id, missionMap: true }); + await flushPromises(); + expect(controller.tracking.value.skills).toEqual([]); + void controller.change({ kind: 'track', skillId: 338 }); + character.value = null; + await flushPromises(); + resolveSave({ skills: [338], activeLocation: lissah.id, missionMap: true }); + await flushPromises(); + expect(controller.tracking.value.skills).toEqual([]); + expect(controller.loaded.value).toBe(false); + controller.dispose(); + }); +}); diff --git a/apps/tools/src/EliteSkillsApp.vue b/apps/tools/src/EliteSkillsApp.vue new file mode 100644 index 00000000..ce5705ff --- /dev/null +++ b/apps/tools/src/EliteSkillsApp.vue @@ -0,0 +1,246 @@ + + + diff --git a/apps/tools/src/components/EliteMarkers.vue b/apps/tools/src/components/EliteMarkers.vue new file mode 100644 index 00000000..a050c05a --- /dev/null +++ b/apps/tools/src/components/EliteMarkers.vue @@ -0,0 +1,38 @@ + + + diff --git a/apps/tools/src/components/SkillCatalogue.vue b/apps/tools/src/components/SkillCatalogue.vue index 2661417e..631991d3 100644 --- a/apps/tools/src/components/SkillCatalogue.vue +++ b/apps/tools/src/components/SkillCatalogue.vue @@ -1,4 +1,5 @@ + diff --git a/apps/tools/src/elite-fixture.ts b/apps/tools/src/elite-fixture.ts new file mode 100644 index 00000000..e45acfc8 --- /dev/null +++ b/apps/tools/src/elite-fixture.ts @@ -0,0 +1,77 @@ +/** Offline visual fixture for the real planner; all game state is illustrative. */ +import { ELITE_LOCATIONS } from "../../../src/shared/elite-locations"; +import { EMPTY_ELITE_TRACKING, changeEliteTracking, parseEliteTracking } from "../../../src/shared/elite-skills"; +import { travelCharacterKey } from "../../../src/shared/travel-history"; +import { skillId } from "../../../src/shared/builds/library"; +import type { EliteMapView } from "../../../src/shared/elite-map"; +import type { SkillPresentation } from "./skill-catalog"; +import { mountEliteSkills } from "./elite-mount"; +const fixtureBosses = ["Lissah the Packleader", "Fenrir", "Jormungand", "Warrior's Construct"]; +export const ELITE_FIXTURE_SKILLS: readonly SkillPresentation[] = fixtureBosses.map((boss, index) => { + const location = ELITE_LOCATIONS.find((entry) => entry.boss === boss)!; + return { id: skillId(location.skillId), name: ["Eviscerate", "Crippling Slash", "Earth Shaker", "Hundred Blades"][index]!, + profession: "W", attribute: ["AxeMastery", "Swordsmanship", "HammerMastery", "Swordsmanship"][index] as "AxeMastery" | "Swordsmanship" | "HammerMastery", + elite: true, availability: "pve", energyCost: 0, adrenalineCost: 8, healthCost: 0, overcast: 0, + activationSeconds: 0, aftercastSeconds: 0, rechargeSeconds: 0, + description: "Illustrative skill details for the offline fixture. In game, the exact description and costs come from the installed client.", iconUrl: null }; +}); +const characterA = travelCharacterKey("0123456789abcdef"); +const characterB = travelCharacterKey("fedcba9876543210"); +const location = ELITE_LOCATIONS.find((entry) => entry.boss === "Lissah the Packleader")!; +export function eliteFixtureView(mission = false, otherCharacter = false, learned = false): EliteMapView { + return { characterKey: otherCharacter ? characterB : characterA, mapId: location.mapId, + observation: { status: "ready", partyObserved: true, party: { status: "ready", playRegion: "pve", rosterObserved: false, + characterSkills: { knownThrough: 5000, unlocked: learned ? [338] : [] } } }, + world: mission ? null : { continent: 0, + box: { left: 24, top: 88, width: window.innerWidth - 48, height: window.innerHeight - 160 }, + transform: { a: 0.12, b: 0, c: 0, d: 0.12, e: -100, f: -60 } }, + mission: mission ? { box: { left: 24, top: 88, width: Math.min(540, window.innerWidth - 48), height: window.innerHeight - 160 }, + transform: { a: 0.9, b: 0, c: 0, d: 0.9, e: -5550, f: -1150 } } : null, + }; +} +export function mountEliteFixture(target: HTMLElement): void { + const controls = document.createElement("div"); + controls.className = "elite-fixture-controls"; + controls.style.cssText = "position:fixed;left:24px;top:16px;display:flex;gap:8px;flex-wrap:wrap;z-index:5;right:24px"; + const label = document.createElement("strong"); label.textContent = "Elite Skills · offline map fixture"; controls.append(label); + let mission = false, otherCharacter = false, learned = false, failSave = false; + const app = mountEliteSkills(target, { + initialView: eliteFixtureView(), loadSkills: async () => ELITE_FIXTURE_SKILLS, + onOpenChange: () => {}, + openWiki: (entry, page) => { label.textContent = `Wiki action: ${page === "boss" ? entry.boss : entry.skillId}`; }, + tracking: { + get: async ({ characterKey }) => { + const stored = localStorage.getItem(`elite-fixture:${characterKey}`); + return stored ? parseEliteTracking(JSON.parse(stored), ELITE_LOCATIONS) : EMPTY_ELITE_TRACKING; + }, + update: async ({ characterKey, change }) => { + if (failSave) throw new Error("Fixture save failure"); + const stored = localStorage.getItem(`elite-fixture:${characterKey}`); + const current = stored ? parseEliteTracking(JSON.parse(stored), ELITE_LOCATIONS) : EMPTY_ELITE_TRACKING; + const next = changeEliteTracking(current, change, ELITE_LOCATIONS); + localStorage.setItem(`elite-fixture:${characterKey}`, JSON.stringify(next)); + return next; + }, + }, + }); + const map = document.createElement("div"); + map.style.cssText = "position:fixed;left:24px;top:88px;right:24px;bottom:72px;border:1px solid var(--ui-line);background:var(--ui-well-fill);padding:24px;color:var(--ui-text-muted);pointer-events:none"; + map.textContent = "Native map artwork appears here in game. This fixture verifies overlay placement and interaction."; + document.body.prepend(map); + function update() { app.update(eliteFixtureView(mission, otherCharacter, learned)); } + for (const [name, run] of [ + ["World / mission map", () => { mission = !mission; update(); }], + ["Switch character", () => { otherCharacter = !otherCharacter; update(); }], + ["Learn / unlearn Eviscerate", () => { learned = !learned; update(); }], + ["Fail / restore saves", () => { failSave = !failSave; label.textContent = failSave ? "Fixture: saves will fail" : "Elite Skills · offline map fixture"; }], + ["Find Eviscerate", () => app.find(338)], + ["Modern style", () => window.gwApplyFixtureAppearance?.({ uiStyle: "obsidian", uiPanelOpacity: 96 })], + ["Guild Wars style", () => window.gwApplyFixtureAppearance?.({ uiStyle: "guild-wars", uiPanelOpacity: 96 })], + ] as const) { + const button = document.createElement("button"); button.className = "ui-button"; button.textContent = name; button.onclick = run; controls.append(button); + } + window.addEventListener("resize", update); + target.addEventListener("keydown", (event) => { if (event.key === "Escape") app.close(); }); + document.body.append(controls); + target.dataset.ready = "true"; +} diff --git a/apps/tools/src/elite-map-markers.ts b/apps/tools/src/elite-map-markers.ts new file mode 100644 index 00000000..40cc3a41 --- /dev/null +++ b/apps/tools/src/elite-map-markers.ts @@ -0,0 +1,30 @@ +/** Projects individual known spawn markers without inventing live boss positions. */ +import type { EliteLocation } from "../../../src/shared/elite-skills"; +import type { EliteMapSurface } from "../../../src/shared/elite-map"; +export type EliteMarker = Readonly<{ + key: string; x: number; y: number; location: EliteLocation; + active: boolean; outside: boolean; +}>; +export function eliteMarkers( + locations: readonly EliteLocation[], surface: EliteMapSurface, activeLocation: string | null, +): readonly EliteMarker[] { + const markers: EliteMarker[] = []; + const { a, b, c, d, e, f } = surface.transform; + const inset = 16; + if (surface.box.width < inset * 2 || surface.box.height < inset * 2) return []; + for (const location of locations) { + location.points.forEach(([mapX, mapY], index) => { + const px = a * mapX + c * mapY + e; + const py = b * mapX + d * mapY + f; + if (!Number.isFinite(px) || !Number.isFinite(py)) return; + const outside = px < inset || py < inset + || px > surface.box.width - inset || py > surface.box.height - inset; + const active = location.id === activeLocation; + if (outside && !active) return; + const x = Math.max(inset, Math.min(surface.box.width - inset, px)); + const y = Math.max(inset, Math.min(surface.box.height - inset, py)); + markers.push({ key: `${location.id}:${index}`, x, y, location, active, outside }); + }); + } + return markers; +} diff --git a/apps/tools/src/elite-mount.ts b/apps/tools/src/elite-mount.ts new file mode 100644 index 00000000..3f35d091 --- /dev/null +++ b/apps/tools/src/elite-mount.ts @@ -0,0 +1,47 @@ +/** Mounts one Elite Skills planner; both map surfaces consume its character plan. */ +import { createApp, h, ref, shallowRef } from "vue"; +import EliteSkillsApp from "./EliteSkillsApp.vue"; +import { EMPTY_ELITE_MAP, type EliteMapHandle, type EliteMapView } from "../../../src/shared/elite-map"; +import type { EliteLocation } from "../../../src/shared/elite-skills"; +import type { EliteTrackingHost } from "./use-elite-tracking"; +import { createSkillCatalogue, type SkillPresentation } from "./skill-catalog"; +import "./styles.css"; +export function mountEliteSkills(target: HTMLElement, options: { + tracking: EliteTrackingHost; + loadSkills: () => Promise; + openWiki: (location: EliteLocation, page: "boss" | "skill") => void | Promise; + onOpenChange: (open: boolean) => void; + initialView?: EliteMapView; +}): EliteMapHandle { + const view = shallowRef(options.initialView ?? EMPTY_ELITE_MAP); + const catalogue = createSkillCatalogue([]); + const catalogueVersion = ref(0); + const catalogueProblem = ref(""); + const component = ref | null>(null); + let disposed = false; + let loading = false; + async function loadSkills() { + if (loading) return; + loading = true; + try { + const records = await options.loadSkills(); + if (disposed) return; + catalogue.replace(records); catalogueVersion.value++; + catalogueProblem.value = ""; + } catch (error) { + if (!disposed) catalogueProblem.value = error instanceof Error ? error.message : "Skill details could not be loaded."; + } finally { loading = false; } + } + const app = createApp({ setup: () => () => h(EliteSkillsApp, { + ref: component, view: view.value, catalogue, catalogueVersion: catalogueVersion.value, + catalogueProblem: catalogueProblem.value, trackingHost: options.tracking, + openWiki: options.openWiki, reloadSkills: () => { void loadSkills(); }, + onOpenChange: options.onOpenChange, + }) }); + app.mount(target); + void loadSkills(); + return { update: (next) => { view.value = next; }, + find: (id) => component.value?.find(id), close: () => component.value?.close(), + dispose: () => { disposed = true; app.unmount(); }, + }; +} diff --git a/apps/tools/src/embedded.ts b/apps/tools/src/embedded.ts index b5ba0083..e4c7e399 100644 --- a/apps/tools/src/embedded.ts +++ b/apps/tools/src/embedded.ts @@ -1,3 +1,6 @@ +/** Mounts the explicit Tools surfaces inside the certified renderer. */ +import { mountEliteSkills as mountElites } from "./elite-mount"; +import { loadInstalledSkills } from "./skill-catalog"; import { mountWhispers } from "./whispers-mount"; import { createNativeHost } from "./host"; import { mountToolsApp as mount } from "./mount"; @@ -10,6 +13,11 @@ import type { } from "../../../src/shared/tools-bundle-contracts"; const embedded: EmbeddedToolsBundle = Object.freeze({ + mountEliteSkills: (target, options) => mountElites(target, { + tracking: options.nativeApi.eliteTracking, loadSkills: loadInstalledSkills, + onOpenChange: options.onOpenChange, + openWiki: (location, page) => options.nativeApi.eliteTracking.openWiki({ locationId: location.id, page }), + }), mountWhispers, mountToolsApp: (target, { nativeApi, ...options }) => mount(target, { host: createNativeHost( @@ -39,5 +47,5 @@ const embedded: EmbeddedToolsBundle = Object.freeze({ }), }); -export const { mountToolsApp, mountTravelPalette, mountTradeChat } = embedded; +export const { mountEliteSkills, mountToolsApp, mountTravelPalette, mountTradeChat } = embedded; export { mountWhispers }; diff --git a/apps/tools/src/host.ts b/apps/tools/src/host.ts index be869682..53727021 100644 --- a/apps/tools/src/host.ts +++ b/apps/tools/src/host.ts @@ -1,7 +1,5 @@ import { ref, type Ref } from "vue"; import { - SKILL_CATALOGUE_ROUTE, - SKILL_ICON_ROUTE, type GwNativeApi, } from "../../../src/shared/contracts"; import { @@ -13,7 +11,6 @@ import { skillId, } from "../../../src/shared/builds/library"; import { parseBuildLibrary } from "../../../src/shared/builds/parse-library"; -import { parseSkillCatalogue } from "../../../src/shared/skill-catalogue"; import type { TeamApplyPlan, TeamApplyResult, @@ -36,8 +33,8 @@ import { devTrace } from "./dev-trace"; import { teamApplyRuntimeProblemMessage } from "./team-apply-presentation"; import { createSkillCatalogue, + loadInstalledSkills, type SkillCatalogue, - type SkillPresentation, } from "./skill-catalog"; const PUBLISH_UNAVAILABLE = @@ -261,22 +258,7 @@ export function createNativeHost( // indistinguishable from a rendering bug, which is exactly how a missing // protocol route once cost an afternoon. const loadSkills = async () => { - const response = await fetch(`gw://app/${SKILL_CATALOGUE_ROUTE}`); - if (!response.ok) { - throw new Error( - `The skill catalogue is unavailable (${response.status}). Guild Wars ` - + "may still be downloading; the console records why.", - ); - } - const parsed: SkillPresentation[] = parseSkillCatalogue(await response.json()) - .map(({ hasIcon, ...record }) => { - const id = skillId(record.id); - return { - ...record, - id, - iconUrl: hasIcon ? `gw://app/${SKILL_ICON_ROUTE(id)}` : null, - }; - }); + const parsed = await loadInstalledSkills(); skills.replace(parsed); devTrace(development, "skills.loaded", { count: parsed.length }); }; diff --git a/apps/tools/src/skill-catalog.ts b/apps/tools/src/skill-catalog.ts index d76dc865..f8854152 100644 --- a/apps/tools/src/skill-catalog.ts +++ b/apps/tools/src/skill-catalog.ts @@ -1,7 +1,7 @@ -import type { - SkillId, -} from "../../../src/shared/builds/library"; -import type { SkillCatalogueRecord } from "../../../src/shared/skill-catalogue"; +/** Owns the shared skill presentation and installed-client catalogue loader. */ +import { skillId, type SkillId } from "../../../src/shared/builds/library"; +import { SKILL_CATALOGUE_ROUTE, SKILL_ICON_ROUTE } from "../../../src/shared/contracts"; +import { parseSkillCatalogue, type SkillCatalogueRecord } from "../../../src/shared/skill-catalogue"; export interface SkillPresentation extends Omit { @@ -51,3 +51,12 @@ export function createSkillCatalogue( }, }; } + +/** Both map planning and Builds read the same installed-client catalogue route. */ +export async function loadInstalledSkills(): Promise { + const response = await fetch(`gw://app/${SKILL_CATALOGUE_ROUTE}`); + if (!response.ok) throw new Error("The installed skill catalogue is unavailable. Try again after Guild Wars finishes loading."); + return parseSkillCatalogue(await response.json()).map(({ hasIcon, ...record }) => ({ + ...record, id: skillId(record.id), iconUrl: hasIcon ? `gw://app/${SKILL_ICON_ROUTE(record.id)}` : null, + })); +} diff --git a/apps/tools/src/standalone.ts b/apps/tools/src/standalone.ts index d35c6efb..9f05bb99 100644 --- a/apps/tools/src/standalone.ts +++ b/apps/tools/src/standalone.ts @@ -38,7 +38,10 @@ window.gwApplyFixtureAppearance = (fixture: StandaloneAppearanceFixture) => { }; const params = new URLSearchParams(window.location.search); -if (params.has("whispers")) { +if (params.has("elites")) { + const { mountEliteFixture } = await import("./elite-fixture"); + mountEliteFixture(target); +} else if (params.has("whispers")) { let id = 0; const session = createWhisperSession(async (recipient, message) => { session.observe([{ id: ++id, sender: recipient, message, direction: "outgoing" }]); diff --git a/apps/tools/src/styles/elite-skills.css b/apps/tools/src/styles/elite-skills.css new file mode 100644 index 00000000..6be0326d --- /dev/null +++ b/apps/tools/src/styles/elite-skills.css @@ -0,0 +1,76 @@ +/* Elite Skills uses shared materials; this file owns map and planner layout. */ +.elite-skills-root { position: fixed; inset: 0; z-index: 4; pointer-events: none; color: var(--ui-text); font: var(--ui-font-size) / var(--ui-line-height) var(--ui-font); } +.elite-skills-root *, .elite-skills-root *::before, .elite-skills-root *::after { box-sizing: border-box; } +.elite-skills-root svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.elite-panel, .elite-map-trigger { position: fixed; top: 16px; right: 16px; pointer-events: auto; } +.elite-map-trigger { display: flex; align-items: center; gap: var(--ui-space-2); } +.elite-panel { display: flex; flex-direction: column; width: min(356px, calc(100vw - 24px)); max-height: calc(100dvh - 32px); background: var(--ui-panel-fill); overflow: hidden; } +.elite-panel .ui-panel-head { position: sticky; top: 0; z-index: 3; background-color: var(--ui-panel-fill); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; } +.elite-panel h2 { margin: 0; color: var(--ui-text-bright); font-size: var(--ui-font-size-lg); } +.elite-back { display: inline-flex; align-items: center; gap: var(--ui-space-1); } +.elite-search-controls { display: grid; gap: var(--ui-space-3); padding: var(--ui-space-4); } +.elite-search, .elite-filters label { display: grid; gap: var(--ui-space-1); min-width: 0; } +.elite-field-label { color: var(--ui-text-muted); font-size: var(--ui-font-size-sm); } +.elite-search input, .elite-filters select { width: 100%; min-width: 0; } +.elite-filters { display: grid; grid-template-columns: 1fr 1fr; gap: var(--ui-space-2); } +.elite-check { display: flex; align-items: center; gap: var(--ui-space-2); font-size: var(--ui-font-size-sm); cursor: pointer; } +.elite-check input { accent-color: var(--ui-accent); } +.elite-modes { display: flex; gap: var(--ui-space-2); } +.elite-modes .ui-button { flex: 1; } +.elite-modes span { margin-left: var(--ui-space-2); font-variant-numeric: tabular-nums; } +.elite-results-heading { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--ui-space-4) var(--ui-space-2); color: var(--ui-text-muted); font-size: var(--ui-font-size-sm); } +.elite-results { min-height: 100px; overflow-y: auto; flex: 1 1 auto; padding: 0 var(--ui-space-2) var(--ui-space-2); } +.elite-result { display: flex; align-items: center; min-height: 64px; border-bottom: 1px solid var(--ui-line); } +.elite-result-main { display: flex; flex: 1; gap: var(--ui-space-3); padding: var(--ui-space-2); align-items: center; min-width: 0; text-align: left; color: var(--ui-text); background: transparent; border: 0; border-radius: var(--ui-radius-sm); font: inherit; cursor: pointer; } +.elite-result-main:hover { background: var(--ui-hover); } +.elite-result-main > span:last-child { display: grid; gap: 2px; min-width: 0; } +.elite-result-main strong { color: var(--ui-text-bright); overflow-wrap: anywhere; } +.elite-result-main small { color: var(--ui-text-muted); font-size: var(--ui-font-size-sm); } +.elite-skill-icon { width: 38px; height: 38px; flex: 0 0 38px; } +.elite-skill-icon img { width: 100%; height: 100%; object-fit: cover; } +.elite-track-button { width: 34px; padding: 6px; flex-shrink: 0; } +.elite-track-button[aria-pressed="true"] svg { fill: var(--ui-accent); } +.elite-detail-toolbar { display: flex; gap: var(--ui-space-2); justify-content: space-between; align-items: center; padding: var(--ui-space-3) var(--ui-space-4); } +.elite-detail-scroll { min-height: 80px; overflow-y: auto; padding: var(--ui-space-4); } +.skill-details { display: grid; gap: var(--ui-space-3); } +.elite-detail-scroll h3 { color: var(--ui-text-bright); font-size: var(--ui-font-size); margin: var(--ui-space-5) 0 var(--ui-space-2); } +.elite-location { padding: var(--ui-space-3) 0; border-top: 1px solid var(--ui-line); } +.elite-location-heading { display: flex; gap: var(--ui-space-2); justify-content: space-between; color: var(--ui-text-bright); } +.elite-location-heading > span { color: var(--ui-accent); font-size: var(--ui-font-size-sm); } +.elite-location p { margin: var(--ui-space-1) 0; } +.elite-location-note { white-space: pre-line; font-family: var(--ui-font-reading); color: var(--ui-text); line-height: 1.5; } +.elite-support { font-size: var(--ui-font-size-sm); color: var(--ui-text-muted); margin: 0; } +.elite-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--ui-space-3); margin-top: var(--ui-space-3); } +.elite-learned { color: var(--ui-text-muted); font-size: var(--ui-font-size-sm); margin-bottom: 0; } +.elite-learned[data-learned="learned"] { color: var(--ui-success); } +.elite-panel-footer { flex-shrink: 0; display: grid; gap: var(--ui-space-2); padding: var(--ui-space-3) var(--ui-space-4); border-top: 1px solid var(--ui-line); } +.elite-panel-footer p { margin: 0; font-size: var(--ui-font-size-sm); color: var(--ui-text-muted); } +.elite-message { padding: var(--ui-space-3) var(--ui-space-4); background: var(--ui-well-fill); font-size: var(--ui-font-size-sm); } +.elite-message p { margin: 0 0 var(--ui-space-2); } +.elite-message .ui-link { margin-left: var(--ui-space-2); } +.elite-save-state { margin: 0; padding: 0 var(--ui-space-4); font-size: var(--ui-font-size-sm); } +.elite-markers { position: fixed; pointer-events: none; overflow: hidden; } +.elite-marker { position: absolute; display: grid; place-items: center; width: 28px; height: 28px; padding: 2px; transform: translate(-50%, -50%); border: 1px solid var(--ui-outline); background: var(--ui-panel-fill); border-radius: var(--ui-radius-sm); color: var(--ui-text-bright); pointer-events: auto; cursor: pointer; } +.elite-marker img { width: 22px; height: 22px; } +.elite-marker:hover, .elite-marker:focus-visible { z-index: 2; outline: 2px solid var(--ui-focus); } +.elite-marker--active { outline: 2px solid var(--ui-accent); z-index: 1; } +.elite-marker--outside { border-style: dashed; } +.elite-marker-label { position: absolute; top: 32px; width: max-content; max-width: 180px; padding: 3px 6px; background: var(--ui-panel-fill); color: var(--ui-text-bright); font-size: var(--ui-font-size-sm); pointer-events: none; } +.elite-mission-tracker { display: grid; gap: var(--ui-space-1); position: fixed; padding: var(--ui-space-3); background: var(--ui-panel-fill); pointer-events: auto; } +.elite-mission-tracker strong { color: var(--ui-text-bright); } +.elite-mission-tracker p { margin: var(--ui-space-1) 0; color: var(--ui-text-muted); font-size: var(--ui-font-size-sm); } +.elite-mission-tracker .ui-link { justify-self: start; } +.elite-preview { position: fixed; width: 300px; max-height: 340px; overflow: hidden; pointer-events: none; padding: var(--ui-space-4); background: var(--ui-panel-fill); z-index: 2; } +.elite-preview > p { margin-bottom: 0; font-size: var(--ui-font-size-sm); color: var(--ui-text-muted); } +.elite-preview .inspector-identity { display: grid; } +.elite-skills-root button:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: 2px; } +@media (max-width: 650px) { .elite-preview { display: none; } .elite-panel { right: 12px !important; } } +@media (max-height: 600px) { .elite-search-controls { gap: var(--ui-space-2); padding: var(--ui-space-2) var(--ui-space-3); } .elite-panel-footer { padding: var(--ui-space-2) var(--ui-space-3); } } + +.elite-panel .inspector-identity { display: grid; } + +/* Short native map frames scroll as one panel so errors cannot displace controls. */ +@media (max-height: 700px) { + .elite-panel { display: block; overflow-y: auto; } + .elite-panel .elite-results, .elite-panel .elite-detail-scroll { overflow: visible; min-height: 0; } +} diff --git a/apps/tools/src/use-elite-tracking.ts b/apps/tools/src/use-elite-tracking.ts new file mode 100644 index 00000000..df4b8ceb --- /dev/null +++ b/apps/tools/src/use-elite-tracking.ts @@ -0,0 +1,59 @@ +/** Owns asynchronous character-plan loading and saving, including stale responses. */ +import { ref, shallowRef, watch, type Ref } from "vue"; +import { EMPTY_ELITE_TRACKING, type EliteChange, type EliteTracking, type EliteUpdate } from "../../../src/shared/elite-skills"; +import type { TravelCharacterKey } from "../../../src/shared/travel-history"; +export interface EliteTrackingHost { + get(value: { characterKey: string }): Promise; + update(value: EliteUpdate): Promise; +} +export function useEliteTracking(character: Ref, host: EliteTrackingHost) { + const tracking = shallowRef(EMPTY_ELITE_TRACKING); + const busy = ref(false); + const problem = ref(""); + const loaded = ref(false); + let epoch = 0; + let retryAction: (() => Promise) | null = null; + async function load() { + const key = character.value; + const ownEpoch = ++epoch; + tracking.value = EMPTY_ELITE_TRACKING; + loaded.value = false; + problem.value = ""; + retryAction = null; + busy.value = key !== null; + if (key === null) return; + try { + const next = await host.get({ characterKey: key }); + if (ownEpoch !== epoch) return; + tracking.value = next; + loaded.value = true; + } catch { + if (ownEpoch !== epoch) return; + problem.value = "Your tracked skills could not be loaded."; + retryAction = load; + } finally { if (ownEpoch === epoch) busy.value = false; } + } + async function change(value: EliteChange) { + const key = character.value; + if (key === null || busy.value || !loaded.value) return; + const ownEpoch = epoch; + busy.value = true; + problem.value = ""; + try { + const next = await host.update({ characterKey: key, change: value }); + if (ownEpoch !== epoch) return; + tracking.value = next; + retryAction = null; + } catch { + if (ownEpoch !== epoch) return; + problem.value = "Could not save this change. Your saved plan is unchanged."; + retryAction = () => change(value); + } finally { if (ownEpoch === epoch) busy.value = false; } + } + const stop = watch(character, () => { void load(); }, { immediate: true }); + return { tracking, busy, loaded, problem, change, reload: load, + retry: () => retryAction?.(), + dismissError: () => { problem.value = ""; retryAction = null; }, + dispose: () => { epoch++; stop(); }, + }; +} diff --git a/docs/README.md b/docs/README.md index 6757f29d..f8eea605 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ its rules. | How are player skill cooldowns certified and displayed? | [Skill cooldowns](skill-cooldowns.md) | | How are controlled-player effect timers certified and displayed? | [Effect timers](effect-timers.md) | | How do the cartography grid and walkability overlay work? | [Cartography](cartography.md) | +| How do elite capture markers and character plans work? | [Elite skills](elite-skills.md) | | How do I certify every cartography layer in a live game? | [Live cartography certification](live-cartography-certification.md) | | What remains to research for party and hostile effects? | [Future effect and debuff research](future-effect-durations.md) | | How do application releases, Stable, and Beta work? | [Release verification](release-verification.md) | diff --git a/docs/elite-skills.md b/docs/elite-skills.md index 8b896aee..6cffb1b9 100644 --- a/docs/elite-skills.md +++ b/docs/elite-skills.md @@ -8,7 +8,7 @@ See [third-party notices](../THIRD-PARTY-NOTICES.md#elite-capture-locations). ## Data and tracking Rebuild the list with `python3 scripts/import-elite-locations.py `. -The importer accepts only the reviewed source hash. Review the geographic +The importer accepts only the reviewed location and enum source hashes. Review the geographic section boundaries before accepting another revision. Location coordinates are world-map units. Empty coordinates mean no usable boss position; they must never become a marker at zero or an invented entrance. @@ -23,3 +23,46 @@ character names, or account unlocks. Corrupt documents are quarantined. A skill is learned only when the current character's live observation says so. An absent observation or an ID outside its observed range means unknown. Account unlocks never imply that the current character learned a skill. + +## Map planner + +Enable **Maps**, then open the native world map. **Elite skills** opens the +planner in its upper-right corner. Search matches skill, boss, and area names. +Profession, capture-region, learned-status, and tracked filters reduce the list. +Hover or focus a skill for a preview. Open it for full details and capture notes. +The sticky detail header keeps **Back to skills** and **Close** visible. Back retains +the current search and filters. +The Builds skill inspector opens the same details through **Find capture locations**. +Both interfaces read descriptions, mechanics, and icons from the installed client. + +Track a skill to save it for this character. **Track this boss** also selects +one active capture location. Closing the planner leaves only tracked markers. +Each known position keeps its own skill icon, including nearby positions. A +selected boss outside the map view has an edge indicator. Pan and zoom remain +native game controls. + +The mission map shows only tracked locations whose map ID matches the current +instance. It requires matching certified frame generations, a valid world +anchor, and a current area that belongs to the campaign world map. Unsupported +areas, transitions, and stale observations withdraw the affected markers. +Known positions are spawn references, never observations of a living boss. +Empty coordinates remain a notes-only capture target. + +Disabling Maps removes the planner and stops its frame reads. Changing the +character clears learned status and loads that character's saved plan. Failed +saves retain the confirmed plan and offer retry. Wiki buttons resolve reviewed +boss or skill entries through a closed, validated main-process action. + +## Verification + +`tests/unit/elite-skills.test.ts` covers data provenance and persistence. +`tests/unit/elite-map-projection.test.ts` covers native projection refusal and +wiki input validation. Tools component tests cover searching, tracking, +character changes, learned status, failure recovery, and marker placement. + +Run `pnpm tools:dev`, then open `/?elites` for an offline interaction fixture. +It uses four illustrative skill records and synthetic map frames. It does not +prove native alignment, game input, or capture behavior. Live game QA must +check a known boss on both maps, native pan/zoom, an area transition, character +switching, and Maps off/on. Keep the standard live cartography checks as the +owner of native projection certification. diff --git a/src/main/tools-ipc.ts b/src/main/tools-ipc.ts index 19a664b9..71996247 100644 --- a/src/main/tools-ipc.ts +++ b/src/main/tools-ipc.ts @@ -2,6 +2,7 @@ * Owns the optional Tools renderer-to-main channels. Main imports this module * only for a Tools-capable launch, so Core registers no tool implementation. */ +import { eliteWikiUrl } from "../shared/elite-wiki.js"; import { parseEliteCharacter, parseEliteUpdate, type EliteTracking, type EliteUpdate } from "../shared/elite-skills.js"; import type { BrowserWindow } from "electron"; import type { ToolsInvokeChannel } from "../shared/contracts.js"; @@ -40,6 +41,7 @@ export interface ToolsIpcContext extends TradeIpcContext { readonly recovered: boolean; }>; setBuildLibrary(win: BrowserWindow, library: BuildLibrary): Promise; + openEliteWiki(url: string): Promise; getEliteTracking(win: BrowserWindow, characterKey: TravelCharacterKey): Promise; updateEliteTracking(win: BrowserWindow, value: EliteUpdate): Promise; getTravelPreferences(): Promise; @@ -66,6 +68,8 @@ const one = (parse: (value: unknown) => Input): Parser => (args) = export function registerToolsIpcHandlers(ctx: ToolsIpcContext): void { const handlers = { ...tradeChannelDefinitions(ctx), + eliteWikiOpen: channel(one(eliteWikiUrl), (_win, url) => + ctx.runFeature("cartography", "Maps", () => ctx.openEliteWiki(url))), eliteTrackingGet: channel(one(parseEliteCharacter), (win, value) => ctx.runFeature("cartography", "Maps", () => ctx.getEliteTracking(win, value.characterKey))), eliteTrackingUpdate: channel(one(parseEliteUpdate), (win, value) => diff --git a/src/main/tools-runtime.ts b/src/main/tools-runtime.ts index 7a4f1876..4f81d77f 100644 --- a/src/main/tools-runtime.ts +++ b/src/main/tools-runtime.ts @@ -4,7 +4,7 @@ */ import { dirname, join } from "node:path"; import { EliteTrackingStore } from "./core/elite-tracking.js"; -import type { BrowserWindow } from "electron"; +import { shell, type BrowserWindow } from "electron"; import type { AppSettings, SettingsResetOutcome } from "../shared/contracts.js"; import { featureActivationRequested } from "../shared/feature-contracts.js"; import { AllowlistError } from "../shared/errors.js"; @@ -65,6 +65,7 @@ export function createToolsRuntime(input: Readonly<{ buildLibraries.get(win, input.accounts.buildLibraryPathFor(win)), setBuildLibrary: (win: BrowserWindow, library) => buildLibraries.set(win, input.accounts.buildLibraryPathFor(win), library), + openEliteWiki: (url) => shell.openExternal(url), getEliteTracking: (win, characterKey) => eliteTracking.get(elitePath(win), characterKey), updateEliteTracking: (win, value) => eliteTracking.update(elitePath(win), value), getTravelPreferences: () => input.preferences.getTravelPreferences(), diff --git a/src/preload/preload.tools.cjs b/src/preload/preload.tools.cjs index 9b38192a..ea9eeacb 100644 --- a/src/preload/preload.tools.cjs +++ b/src/preload/preload.tools.cjs @@ -9,6 +9,7 @@ */ function installToolsApi(api, ipcRenderer, IPC, listen) { api.eliteTracking = { + openWiki: (value) => ipcRenderer.invoke(IPC.eliteWikiOpen, value), get: (value) => ipcRenderer.invoke(IPC.eliteTrackingGet, value), update: (value) => ipcRenderer.invoke(IPC.eliteTrackingUpdate, value), }; diff --git a/src/renderer/certified-companion-tools-installation.ts b/src/renderer/certified-companion-tools-installation.ts index 6ced9199..f84910a9 100644 --- a/src/renderer/certified-companion-tools-installation.ts +++ b/src/renderer/certified-companion-tools-installation.ts @@ -2,6 +2,7 @@ * Prepares the optional half of a certified companion installation. Core owns * the shared kernel transaction and calls this extension only in Tools mode. */ +import { createEliteMapInstallation } from "./elite-map-installation.js"; import { COMPANION_ABI, COMPANION_DISPATCH_KINDS, COMPANION_FEATURE_BITS } from "../shared/companion-abi.js"; import { ENHANCEMENT_CHAT_FILTER_MASKS, @@ -262,7 +263,7 @@ export async function prepareToolsCompanionExtension( }, }, activate(context) { - const session = activateTools({ context, capabilities, program, foundation, observeState, + const session = activateTools({ mapExports: exports, context, capabilities, program, foundation, observeState, skills, slots, cooldowns, playerEffects, effectIcons, enqueue, traceReader, teamCommands, storage, travel, configureTrade, takeTrade, configureChatFilters, friendPointer, resignExports: capabilities.resignAction ? exports : null, whispers, @@ -288,6 +289,7 @@ export async function prepareToolsCompanionExtension( } type ToolsInput = Readonly<{ + mapExports: WebAssembly.Exports; context: CompanionExtensionActivation; capabilities: EnhancementCapabilities; program: EnhancementProgram; @@ -341,12 +343,17 @@ function activateTools(input: ToolsInput): CompanionExtensionSession { const effectIconsActive = () => capabilities.effectIconGeometry && (program === "effect-observer" || policy().effectTimers); const playRegion = () => snapshot().playRegion; - const cartographyActive = () => { - const settings = window.gwToolsSettings(); - return policy().cartography && (settings.cartographyOverlayEnabled || settings.cartographyGridEnabled); - }; let companionState: CompanionSnapshot | null = null; let party: ToolboxObservation | null = null; + const eliteMaps = createEliteMapInstallation({ exports: input.mapExports, + state: () => { + const region = snapshot().playRegionState; + return { region, observation: party ?? { status: "waiting" }, + onWorldMap: region.status === "ready" && companionState?.status === "ready" + && companionState.mapId === region.mapId && companionState.onWorldMap }; + }, + }); + let eliteIdentity = ""; let readout: ReturnType | null = null; let toolbox: ReturnType | null = null; let professionTrace: ReturnType | null = null; @@ -387,6 +394,7 @@ function activateTools(input: ToolsInput): CompanionExtensionSession { [ () => { readout?.dispose(); readout = null; }, () => toolbox?.dispose(), + () => eliteMaps.dispose(), () => skills.disposePresentation(), () => slots?.dispose(), () => cooldowns?.dispose(), @@ -507,7 +515,7 @@ function activateTools(input: ToolsInput): CompanionExtensionSession { (capabilities.nativeCursor ? COMPANION_FEATURE_BITS.nativeCursor : 0) | (foundation && policy().tools ? COMPANION_FEATURE_BITS.toolboxFoundation : 0) | (capabilities.playRegionObservation ? COMPANION_FEATURE_BITS.playRegionObservation : 0) - | (policy().targetReadout || cartographyActive() + | (policy().targetReadout || policy().cartography ? COMPANION_FEATURE_BITS.targetObservation : 0) | (policy().whispers && capabilities.whisperChat ? COMPANION_FEATURE_BITS.whisperObservation : 0) | skills.activeFeatureFlags @@ -570,6 +578,10 @@ function activateTools(input: ToolsInput): CompanionExtensionSession { } syncStorage(); syncTravel(); + const eliteRegion = snapshot().playRegionState; + const nextEliteIdentity = eliteRegion.status === "ready" ? `${eliteRegion.characterKey}:${eliteRegion.mapId}` : ""; + if (nextEliteIdentity !== eliteIdentity) { party = null; eliteIdentity = nextEliteIdentity; } + eliteMaps.update(policy().cartography); quickItemMoveInstallation?.update(policy().quickItemMove); }; const syncPolicy = (reason: "region" | "settings") => { @@ -608,7 +620,7 @@ function activateTools(input: ToolsInput): CompanionExtensionSession { }]), ], state: observeState ? { - enabled: () => policy().targetReadout || policy().xunlaiStorage || cartographyActive(), + enabled: () => policy().targetReadout || policy().xunlaiStorage || policy().cartography, update: (state) => { companionState = state; updateCartographyPlayerState(state); @@ -616,7 +628,7 @@ function activateTools(input: ToolsInput): CompanionExtensionSession { syncStorage(); }, } : null, - toolbox: foundation ? { enabled: () => policy().buildLibrary || program === "effect-observer", + toolbox: foundation ? { enabled: () => policy().buildLibrary || policy().cartography || program === "effect-observer", update: (state) => { party = state; professionTrace?.poll(state); toolbox?.update(state); } } : null, observeState, publishState: program === "target-observer", diff --git a/src/renderer/elite-map-installation.ts b/src/renderer/elite-map-installation.ts new file mode 100644 index 00000000..01bcc915 --- /dev/null +++ b/src/renderer/elite-map-installation.ts @@ -0,0 +1,119 @@ +/** + * Owns Elite Skills against the certified Maps lifetime and existing input boundary. + * It reads scalar map projections only and never sends map or gameplay commands. + */ +import { isTravelCharacterKey } from "../shared/travel-history.js"; +import type { ToolboxObservation } from "../shared/builds/live-party.js"; +import type { EliteMapHandle } from "../shared/elite-map.js"; +import type { CompanionPlayRegionState } from "./companion-play-region-snapshot.js"; +import type { EmbeddedToolsBundle } from "../shared/tools-bundle-contracts.js"; +import { createCartographyContextReader } from "./cartography-spike/context-observer.js"; +import { createCompassFrameSpikeReader, createMissionMapFrameSpikeReader, createWorldMapFrameSpikeReader } from "./cartography-spike/frame-observer.js"; +import { createWorldMapAnchorSpikeReader } from "./cartography-spike/world-map-anchor-observer.js"; +import { eliteMapSurfaces } from "./elite-map-projection.js"; +import { ensureToolsStylesheet } from "./tools-stylesheet.js"; +import { requireToolsApi } from "./tools-native-api.js"; +import { createNonActivatingSurface } from "./non-activating-surface.js"; +export function createEliteMapInstallation(options: { + exports: WebAssembly.Exports; + state(): Readonly<{ region: CompanionPlayRegionState; observation: ToolboxObservation; onWorldMap: boolean }>; +}) { + let enabled = false; + let disposed = false; + let pending = false; + let frame = 0; + let app: EliteMapHandle | null = null; + let cleanup: (() => void) | null = null; + let requestedSkill: number | null = null; + const context = createCartographyContextReader(options.exports); + const compass = createCompassFrameSpikeReader(options.exports); + const mission = createMissionMapFrameSpikeReader(options.exports); + const anchor = createWorldMapAnchorSpikeReader(options.exports); + const world = createWorldMapFrameSpikeReader(options.exports); + async function mount() { + if (pending || app || !enabled || disposed) return; + pending = true; + let remove: (() => void) | null = null; + try { + const canvas = document.getElementById("canvas"); + if (!(canvas instanceof HTMLCanvasElement)) return; + const specifier = "./tools/tools-app.js"; + const bundle: EmbeddedToolsBundle = await import(specifier); + if (disposed || !enabled) return; + ensureToolsStylesheet(document); + const root = document.createElement("div"); + root.id = "elite-skills-host"; + document.body.append(root); + const surface = window.gwSurfaces.register({ root, priority: 4, dismiss: () => app?.close() }); + const input = createNonActivatingSurface(root, () => canvas); + for (const name of ["keydown", "keyup", "pointerdown", "pointerup", "pointermove", "mousedown", "mouseup", "mousemove", "click", "contextmenu"]) { + root.addEventListener(name, (event) => event.stopPropagation()); + } + root.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true }); + root.addEventListener("pointerdown", () => surface.raise(), true); + remove = () => { surface.dispose(); input.dispose(); root.remove(); }; + app = bundle.mountEliteSkills(root, { nativeApi: requireToolsApi(), onOpenChange: (open) => { + surface.setOpen(open); + if (open && document.pointerLockElement) document.exitPointerLock(); + input.releaseKeyboard(); + } }); + cleanup = remove; + let lastView = ""; + let lastParty = ""; + let lastCharacter = ""; + let observed: ToolboxObservation = { status: "waiting" }; + let nextPartyPoll = 0; + const render = () => { + if (!app || !enabled || disposed) return; + const { region, observation, onWorldMap } = options.state(); + const ready = region.status === "ready" && region.playRegion === "pve"; + const mapId = ready ? region.mapId : null; + const now = performance.now(); + const character = ready ? `${region.characterKey}:${region.mapId}` : ""; + if (character !== lastCharacter) { + observed = { status: "waiting" }; lastParty = ""; nextPartyPoll = 0; lastCharacter = character; + } + if (!ready) { observed = { status: "waiting" }; lastParty = ""; } + else if (now >= nextPartyPoll) { + const identity = JSON.stringify([region.characterKey, region.mapId, observation.status, + observation.partyObserved, observation.party?.characterSkills, + observation.party?.slots?.[0]?.professions]); + if (identity !== lastParty) { lastParty = identity; observed = observation; } + nextPartyPoll = now + 200; + } + const before = context?.refresh() ? context.snapshot() : null; + const surfaces = eliteMapSurfaces({ context: before, mapId, anchor: ready ? anchor?.snapshot() ?? null : null, onWorldMap, compass: ready ? compass?.snapshot() ?? null : null, + mission: ready ? mission?.snapshot() ?? null : null, world: ready ? world?.snapshot() ?? null : null, + canvas: canvas.getBoundingClientRect() }); + const after = context?.snapshot(); + const stable = before && after && before.sequence === after.sequence; + const next = { ...(stable ? surfaces : { world: null, mission: null }), mapId, + characterKey: ready && isTravelCharacterKey(region.characterKey) ? region.characterKey : null, + observation: observed }; + // Vue receives changes only. Native pan/zoom still follows animation frames. + const signature = JSON.stringify([next.world, next.mission, next.mapId, next.characterKey, lastParty]); + if (signature !== lastView) { lastView = signature; app.update(next); } + frame = requestAnimationFrame(render); + }; + render(); + if (requestedSkill !== null) { app.find(requestedSkill); requestedSkill = null; } + } catch (error) { + app?.dispose(); app = null; remove?.(); cleanup = null; + console.error("[maps] Elite Skills could not start", error); + } finally { pending = false; } + } + const find = (event: Event) => { + if (!enabled || !(event instanceof CustomEvent)) return; + const value: unknown = event.detail; + if (!value || typeof value !== "object" || !("skillId" in value) + || typeof value.skillId !== "number" || !Number.isSafeInteger(value.skillId)) return; + event.preventDefault(); + if (app) app.find(value.skillId); + else { requestedSkill = value.skillId; void mount(); } + }; + window.addEventListener("gw:elite-find", find); + function stop() { cancelAnimationFrame(frame); app?.dispose(); app = null; cleanup?.(); cleanup = null; } + return { update(next: boolean) { enabled = next && !disposed; if (enabled) void mount(); else stop(); }, + dispose() { disposed = true; enabled = false; stop(); window.removeEventListener("gw:elite-find", find); }, + }; +} diff --git a/src/renderer/elite-map-projection.ts b/src/renderer/elite-map-projection.ts new file mode 100644 index 00000000..2670e5f8 --- /dev/null +++ b/src/renderer/elite-map-projection.ts @@ -0,0 +1,32 @@ +/** + * Projects independently certified native map frames for capture markers. + * Frame visibility stays available when absolute spawn projection is unsupported. + */ +import type { EliteMapView } from "../shared/elite-map.js"; +import type { + CartographyContextSnapshot, CompassFrameSpikeSnapshot, + MissionMapFrameSpikeSnapshot, WorldMapFrameSpikeSnapshot, WorldMapAnchorSpikeSnapshot, +} from "../shared/cartography-spike.js"; +import { projectMissionMapFrame, projectNativeFrame, type ScreenBox } from "./cartography-spike/frame-placement.js"; +import { projectMissionMapContentBox, projectMapUnitsToMissionMap, projectMapUnitsToWorldMap } from "./cartography-spike/map-projections.js"; +export function eliteMapSurfaces(input: Readonly<{ + context: CartographyContextSnapshot | null; mapId: number | null; + anchor: WorldMapAnchorSpikeSnapshot | null; onWorldMap: boolean; + compass: CompassFrameSpikeSnapshot | null; mission: MissionMapFrameSpikeSnapshot | null; + world: WorldMapFrameSpikeSnapshot | null; canvas: ScreenBox; +}>): Pick { + const { context, compass, mission, world, canvas } = input; + if (!context || context.status !== 1 || context.mapId !== input.mapId) return { world: null, mission: null }; + const worldBox = world?.generation === context.areaEpoch ? projectNativeFrame(world, canvas) : null; + const worldProjection = world && worldBox ? projectMapUnitsToWorldMap(world, worldBox) : null; + const supported = input.onWorldMap && input.anchor?.status === 1 + && input.anchor.generation === context.areaEpoch && [0, 2, 4].includes(input.anchor.continent); + const missionFrame = mission?.generation === context.areaEpoch && compass?.generation === context.areaEpoch + ? projectMissionMapFrame(mission, compass, canvas) : null; + const missionBox = mission && missionFrame ? projectMissionMapContentBox(mission, missionFrame) : null; + const missionProjection = supported && mission && missionBox ? projectMapUnitsToMissionMap(mission, missionBox) : null; + return { + world: worldProjection && world ? { ...worldProjection, continent: world.continent } : null, + mission: missionBox ? { box: missionBox, transform: missionProjection?.transform ?? null } : null, + }; +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index cbd61ddb..6e2ecb51 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -15,6 +15,7 @@ * these boundaries; the sentence a player reads is written in the renderer, * where it can be tested against what is actually shown. */ +import type { EliteWikiRequest } from "./elite-wiki.js"; import type { EliteTracking, EliteUpdate } from "./elite-skills.js"; import type { DiagnosticSummary, @@ -1142,6 +1143,7 @@ export const TOOLS_IPC = { traderPriceHistoryGet: "gw:trader:priceHistory:get", travelPreferencesGet: "gw:travelPreferences:get", travelPreferencesSet: "gw:travelPreferences:set", + eliteWikiOpen: "gw:eliteWiki:open", eliteTrackingGet: "gw:eliteTracking:get", eliteTrackingUpdate: "gw:eliteTracking:update", travelHistoryGet: "gw:travelHistory:get", @@ -1381,6 +1383,7 @@ export interface ToolsNativeApiExtension { set(value: TravelUserPreferencesUpdate): Promise; }; eliteTracking: { + openWiki(value: EliteWikiRequest): Promise; get(value: { characterKey: string }): Promise; update(value: EliteUpdate): Promise; }; diff --git a/src/shared/elite-map.ts b/src/shared/elite-map.ts new file mode 100644 index 00000000..75fd1f0a --- /dev/null +++ b/src/shared/elite-map.ts @@ -0,0 +1,27 @@ +/** + * The presentation-only map boundary consumed by the embedded Elite Skills UI. + * Native observers remain renderer-owned; no memory address crosses this contract. + */ +import type { ToolboxObservation } from "./builds/live-party.js"; +import type { TravelCharacterKey } from "./travel-history.js"; +export type EliteMapSurface = Readonly<{ + box: Readonly<{ left: number; top: number; width: number; height: number }>; + transform: Readonly<{ a: number; b: number; c: number; d: number; e: number; f: number }>; +}>; +export type EliteMapView = Readonly<{ + world: (EliteMapSurface & Readonly<{ continent: number }>) | null; + mission: Readonly<{ box: EliteMapSurface["box"]; transform: EliteMapSurface["transform"] | null }> | null; + mapId: number | null; + characterKey: TravelCharacterKey | null; + observation: ToolboxObservation; +}>; +export const EMPTY_ELITE_MAP: EliteMapView = Object.freeze({ + world: null, mission: null, mapId: null, characterKey: null, + observation: Object.freeze({ status: "waiting" }), +}); +export type EliteMapHandle = Readonly<{ + update(view: EliteMapView): void; + find(skillId: number): void; + close(): void; + dispose(): void; +}>; diff --git a/src/shared/elite-wiki.ts b/src/shared/elite-wiki.ts new file mode 100644 index 00000000..6bc6d95c --- /dev/null +++ b/src/shared/elite-wiki.ts @@ -0,0 +1,17 @@ +/** + * Resolves only reviewed boss and skill wiki pages; callers cannot supply URLs. + * Both IPC validation and its refusal tests use this closed destination policy. + */ +import { ELITE_LOCATIONS } from "./elite-locations.js"; +export type EliteWikiRequest = Readonly<{ locationId: string; page: "boss" | "skill" }>; +export function eliteWikiUrl(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("Invalid wiki request"); + const input = value as Record; + if (Object.keys(input).length !== 2 || (input.page !== "boss" && input.page !== "skill")) { + throw new TypeError("Invalid wiki page"); + } + const location = ELITE_LOCATIONS.find((entry) => entry.id === input.locationId); + if (!location) throw new TypeError("Unknown capture location"); + return input.page === "skill" ? `https://wiki.guildwars.com/wiki/Game_link:Skill_${location.skillId}` + : `https://wiki.guildwars.com/wiki/${encodeURIComponent(location.boss.replaceAll(" ", "_"))}`; +} diff --git a/src/shared/tools-bundle-contracts.ts b/src/shared/tools-bundle-contracts.ts index bebd8fd3..51c4dc3a 100644 --- a/src/shared/tools-bundle-contracts.ts +++ b/src/shared/tools-bundle-contracts.ts @@ -3,6 +3,7 @@ * Tools bundle. The runtime import uses a generated file name, so both builds * import these shapes directly instead of restating them on either side. */ +import type { EliteMapHandle } from "./elite-map.js"; import type { WhisperSession } from "./whisper-session.js"; import type { TravelFriends } from "./friends.js"; import type { ToolboxObservation } from "./builds/live-party.js"; @@ -76,6 +77,7 @@ export type TradeChatMountOptions = Readonly<{ /** The exact named exports of the generated Tools module. */ export type EmbeddedToolsBundle = Readonly<{ + mountEliteSkills: (target: Target, options: Readonly<{ nativeApi: ToolsGwNativeApi; onOpenChange(open: boolean): void }>) => EliteMapHandle; mountWhispers: (target: Target, options: { session: WhisperSession }) => { dispose(): void }; mountToolsApp: ( target: Target, diff --git a/tests/release/preload-behaviour.test.ts b/tests/release/preload-behaviour.test.ts index 4f37d9a9..cae74e81 100644 --- a/tests/release/preload-behaviour.test.ts +++ b/tests/release/preload-behaviour.test.ts @@ -218,6 +218,11 @@ const INVOCATIONS: Invocation[] = [ }], channel: IPC.travelPreferencesSet, }, + { + path: "eliteTracking.openWiki", + args: [{ locationId: "abcdef0123456789", page: "boss" }], + channel: IPC.eliteWikiOpen, + }, { path: "eliteTracking.get", args: [{ characterKey: "0123456789abcdef" }], diff --git a/tests/unit/elite-map-projection.test.ts b/tests/unit/elite-map-projection.test.ts new file mode 100644 index 00000000..ace080e7 --- /dev/null +++ b/tests/unit/elite-map-projection.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { eliteMapSurfaces } from "../../src/renderer/elite-map-projection.js"; +import { eliteWikiUrl } from "../../src/shared/elite-wiki.js"; +import { ELITE_LOCATIONS } from "../../src/shared/elite-locations.js"; +const context = { status: 1, sequence: 2, areaEpoch: 7, mapId: 482, layoutId: 1 } as const; +const native = { status: 1, generation: 7, frameId: 24, visible: true, + viewportWidth: 1200, viewportHeight: 800, left: 100, bottom: 100, right: 740, top: 500 }; +const compass = { ...native, cameraSequence: 9, compassDirectionX: 0, compassDirectionY: 1 }; +const mission = { ...native, projectionStatus: 1, projectionSequence: 4, projectionGeneration: 7, + zoom: 1, panX: 4666, panY: 4317, drawableWidth: 640, drawableHeight: 320, + playerMapX: 4666, playerMapY: 4317, nativeMapWidth: 640, nativeMapHeight: 320 }; +const world = { ...native, sequence: 11, continent: 0, zoom: 0, + topLeftX: 0, topLeftY: 0, bottomRightX: 8192, bottomRightY: 16384 }; +const anchor = { status: 1, generation: 7, continent: 0, worldAnchorX: 4600, worldAnchorY: 4300, mapMinX: 0, mapMinY: 0, mapMaxX: 100, mapMaxY: 100 }; +const input = { anchor, onWorldMap: true, context, mapId: 482, compass, mission, world, + canvas: { left: 0, top: 0, width: 1200, height: 800 } }; +test("elite surfaces follow native pan/zoom and refuse stale instances independently", () => { + const ready = eliteMapSurfaces(input); + assert.ok(ready.world); assert.ok(ready.mission?.transform); + const panned = eliteMapSurfaces({ ...input, mission: { ...mission, panX: 4000, zoom: 2 } }); + assert.notEqual(ready.mission.transform.e, panned.mission?.transform?.e); + assert.deepEqual(eliteMapSurfaces({ ...input, mapId: 55 }), { world: null, mission: null }); + for (const overrides of [{ onWorldMap: false }, { anchor: null }, { anchor: { ...anchor, generation: 6 } }, { anchor: { ...anchor, continent: 5 } }]) { + const unsupported = eliteMapSurfaces({ ...input, ...overrides }); + assert.ok(unsupported.mission?.box); assert.equal(unsupported.mission.transform, null); assert.ok(unsupported.world); + } + const staleMission = eliteMapSurfaces({ ...input, mission: { ...mission, projectionGeneration: 6 } }); + assert.ok(staleMission.world); assert.equal(staleMission.mission?.transform, null); + const closedWorld = eliteMapSurfaces({ ...input, world: { ...world, visible: false } }); + assert.equal(closedWorld.world, null); assert.ok(closedWorld.mission); + assert.deepEqual(eliteMapSurfaces({ ...input, context: { ...context, areaEpoch: 8 } }), { world: null, mission: null }); +}); +test("wiki actions resolve reviewed data and never accept caller URLs", () => { + const boss = ELITE_LOCATIONS.find((entry) => entry.boss === "Lissah the Packleader")!; + assert.equal(eliteWikiUrl({ locationId: boss.id, page: "skill" }), "https://wiki.guildwars.com/wiki/Game_link:Skill_338"); + assert.match(eliteWikiUrl({ locationId: boss.id, page: "boss" }), /Lissah_the_Packleader$/u); + assert.throws(() => eliteWikiUrl({ locationId: boss.id, page: "boss", url: "https://evil.example" })); + assert.throws(() => eliteWikiUrl({ locationId: "https://evil.example", page: "boss" })); +});