Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,14 @@ GWonMac adds the dark face-button plates and the L4, L5, R4, R5, L, and R label
plates needed by Guild Wars' fixed controller atlas. PlayStation is a trademark
of Sony Interactive Entertainment Inc.; GWonMac is not affiliated with or
endorsed by Sony.

### Elite capture locations

The elite capture catalogue is derived from GWToolbox++
`GWToolboxdll/Widgets/WorldMapWidget_Constants.h` at commit
`baaaf0de574b02008baa57a574625a99009cd5ac`, distributed under the MIT License
reproduced above. Toolbox credits the original locations to **MappingOut
v4.0.0 by Aylee Sedai**. Encounter notes and alternate coordinates are retained.
The import is reproducible with `scripts/import-elite-locations.py` and a
checkout of that exact revision. The importer checks the source hash before
using its reviewed geographic sections. Game names remain ArenaNet material.
25 changes: 25 additions & 0 deletions docs/elite-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Elite skills

Elite skills joins the installed skill catalogue to a pinned GWToolbox++
capture-location list. The imported list owns boss names, map IDs, alternate
positions, capture regions, and encounter notes. It never authorizes travel.
See [third-party notices](../THIRD-PARTY-NOTICES.md#elite-capture-locations).

## Data and tracking

Rebuild the list with `python3 scripts/import-elite-locations.py <Toolbox checkout>`.
The importer accepts only the reviewed source hash. 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.

Main stores `elite-tracking.json` beside each account profile's build library.
The existing privacy-safe character key separates characters. Named actions
are validated and serialized before an atomic save. Concurrent actions merge
against current disk state. Tracking records only skill IDs, one active boss,
and the mission-map preference. It does not record learned skills, routes,
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.
71 changes: 71 additions & 0 deletions scripts/import-elite-locations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Rebuild the pinned Toolbox capture catalogue; requires the reviewed checkout."""
import hashlib
import json
import pathlib
import re
import sys

ROOT = pathlib.Path(__file__).resolve().parents[1]
SOURCE = pathlib.Path(sys.argv[1])
COMMIT = 'baaaf0de574b02008baa57a574625a99009cd5ac'
HEADER = SOURCE / 'GWToolboxdll/Widgets/WorldMapWidget_Constants.h'
text = HEADER.read_text()
assert hashlib.sha256(HEADER.read_bytes()).hexdigest() == 'ecf7a506e4b213578b4669c56d310b6ab81d3d4164b93a607102bbf012ba8d5f', 'Review a changed source before importing'

ENUM_HASHES = {
'Skills': '2a13a9f46241308fba1ee52af9b1fc56f988042a82594fe5a0dc8d037531310c',
'Maps': 'dab0d410d4e0c937cfd6cdf32f7c53877fa554423e58914b199550c4256b06a5',
}
def enum(name):
path = SOURCE / f'Dependencies/GWCA/include/GWCA/Constants/{name}.h'
assert hashlib.sha256(path.read_bytes()).hexdigest() == ENUM_HASHES[name], f'Review changed {name} IDs before importing'
source = path.read_text()
body = source.split('enum class ', 1)[1].split('{', 1)[1].split('};', 1)[0]
body = re.sub(r'/\*.*?\*/|//[^\n]*', '', body, flags=re.S)
result, number = {}, -1
for item in body.split(','):
item = item.strip()
if not item:
continue
parts = item.split('=')
key = parts[0].strip()
number = (int(parts[1].strip(), 0) if len(parts) == 2 else number + 1)
result[key] = number
return result

skills, maps = enum('Skills'), enum('Maps')
string = r'"(?:\\.|[^"\\])*"'
constants = {k: json.loads(v) for k, v in re.findall(r'const char\* (\w+) = (' + string + r');', text)}
pattern = re.compile(r'\{GW::Constants::SkillID::(\w+),\s*(' + string + r'|\w+),\s*GW::Constants::MapID::(\w+),\s*(\{(?:[^{}]|\{[^{}]*\})*\})(?:,\s*(' + string + r'|\w+))?\s*\}')
def literal(value):
return json.loads(value) if value.startswith('"') else constants[value]

rows = []
# These boundaries describe this exact, hash-checked source's geographic sections.
# They classify capture region, never the skill's campaign of origin.
region = 'Eye of the North'
for match in pattern.finditer(text):
skill, boss, area, coords, note = match.groups()
if area == 'Issnur_Isles': region = 'Elona'
if area == 'Tsumei_Village_Winds_of_Change__A_Treatys_a_Treaty': region = 'Cantha'
if area == 'Prophets_Path': region = 'Tyria'
if area == 'Catacombs_of_Kathandrax_Level_1': region = 'Eye of the North'
if area == 'War_in_Kryta_Divinity_Coast': region = 'Tyria'
boss, note = literal(boss), literal(note) if note else None
points = [[int(x), int(y)] for x, y in re.findall(r'\{\s*(-?\d+),\s*(-?\d+)\s*\}', coords)]
key = f'{skills[skill]}:{maps[area]}:{boss}:{note or ""}'
rows.append(dict(id=hashlib.sha256(key.encode()).hexdigest()[:16], skillId=skills[skill], boss=boss,
mapId=maps[area], region=region, points=points, note=note))
assert len(rows) == 926, len(rows)
# Exact duplicates are merged, retaining all known alternate points.
unique = {}
for row in rows:
if row['id'] in unique:
existing = unique[row['id']]['points']
existing.extend(p for p in row['points'] if p not in existing)
else: unique[row['id']] = row
output = '/**\n * Reviewed capture locations from GWToolbox++ ' + COMMIT + '.\n * Derived by scripts/import-elite-locations.py; preserve the third-party notice.\n */\nimport type { EliteLocation } from "./elite-skills.js";\n\nexport const ELITE_LOCATIONS: readonly EliteLocation[] = [\n'
output += ''.join(' ' + json.dumps(row, ensure_ascii=False, separators=(',', ':')) + ',\n' for row in unique.values())
output += '];\n'
(ROOT / 'src/shared/elite-locations.ts').write_text(output)
print(f'Imported {len(unique)} locations / {len(set(r["skillId"] for r in rows))} skills')
61 changes: 61 additions & 0 deletions src/main/core/elite-tracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Saves character capture plans atomically within the owning account profile.
* Serialized actions merge current disk state, so two windows cannot lose a plan.
*/
import { readFile } from "node:fs/promises";
import { ELITE_LOCATIONS } from "../../shared/elite-locations.js";
import {
EMPTY_ELITE_TRACKING, changeEliteTracking, parseEliteTracking,
type EliteTracking, type EliteUpdate,
} from "../../shared/elite-skills.js";
import { isTravelCharacterKey, type TravelCharacterKey } from "../../shared/travel-history.js";
import { writeAtomicJson } from "./atomic-file.js";
import { quarantineCorruptDocument } from "./corrupt-document.js";
import { Mutex } from "./mutex.js";

type Document = { formatVersion: 1; characters: Record<string, EliteTracking> };
async function read(path: string): Promise<Document> {
let text: string;
try { text = await readFile(path, "utf8"); } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { formatVersion: 1, characters: {} };
throw error;
}
try {
const input = JSON.parse(text) as Document;
if (input?.formatVersion !== 1 || typeof input.characters !== "object" || !input.characters
|| Array.isArray(input.characters) || Object.keys(input).length !== 2
|| Object.keys(input.characters).length > 64) throw new TypeError("Invalid capture plans");
const characters: Record<string, EliteTracking> = {};
for (const [key, value] of Object.entries(input.characters)) {
if (!isTravelCharacterKey(key)) throw new TypeError("Invalid character");
characters[key] = parseEliteTracking(value, ELITE_LOCATIONS);
}
return { formatVersion: 1, characters };
} catch {
await quarantineCorruptDocument(path);
return { formatVersion: 1, characters: {} };
}
}
export class EliteTrackingStore {
readonly #locks = new Map<string, Mutex>();
#lock(path: string): Mutex {
let lock = this.#locks.get(path);
if (!lock) { lock = new Mutex(); this.#locks.set(path, lock); }
return lock;
}
get(path: string, characterKey: TravelCharacterKey): Promise<EliteTracking> {
return this.#lock(path).run(async () => (await read(path)).characters[characterKey] ?? EMPTY_ELITE_TRACKING);
}
update(path: string, { characterKey, change }: EliteUpdate): Promise<EliteTracking> {
return this.#lock(path).run(async () => {
const document = await read(path);
if (!(characterKey in document.characters) && Object.keys(document.characters).length >= 64) {
throw new Error("This profile has reached its character tracking limit");
}
const next = changeEliteTracking(document.characters[characterKey] ?? EMPTY_ELITE_TRACKING, change, ELITE_LOCATIONS);
document.characters[characterKey] = next;
await writeAtomicJson(path, document);
return next;
});
}
}
11 changes: 9 additions & 2 deletions src/main/tools-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { parseEliteCharacter, parseEliteUpdate, type EliteTracking, type EliteUpdate } from "../shared/elite-skills.js";
import type { BrowserWindow } from "electron";
import type { ToolsInvokeChannel } from "../shared/contracts.js";
import type { BuildLibrary } from "../shared/builds/library.js";
Expand Down Expand Up @@ -39,13 +40,15 @@ export interface ToolsIpcContext extends TradeIpcContext {
readonly recovered: boolean;
}>;
setBuildLibrary(win: BrowserWindow, library: BuildLibrary): Promise<BuildLibrary>;
getEliteTracking(win: BrowserWindow, characterKey: TravelCharacterKey): Promise<EliteTracking>;
updateEliteTracking(win: BrowserWindow, value: EliteUpdate): Promise<EliteTracking>;
getTravelPreferences(): Promise<TravelUserPreferences>;
setTravelPreferences(update: TravelUserPreferencesUpdate): Promise<TravelUserPreferences>;
getTravelHistory(characterKey: TravelCharacterKey): Promise<TravelHistory>;
recordTravelHistory(characterKey: TravelCharacterKey, mapId: number): Promise<TravelHistory>;
isFeatureEnabled(feature: "buildLibrary" | "travelPalette" | "tradeChat"): boolean;
isFeatureEnabled(feature: "buildLibrary" | "travelPalette" | "cartography" | "tradeChat"): boolean;
runFeature<Value>(
feature: "buildLibrary" | "travelPalette",
feature: "buildLibrary" | "travelPalette" | "cartography",
label: string,
operation: () => Promise<Value>,
): Promise<Value>;
Expand All @@ -63,6 +66,10 @@ const one = <Input>(parse: (value: unknown) => Input): Parser<Input> => (args) =
export function registerToolsIpcHandlers(ctx: ToolsIpcContext): void {
const handlers = {
...tradeChannelDefinitions(ctx),
eliteTrackingGet: channel(one(parseEliteCharacter), (win, value) =>
ctx.runFeature("cartography", "Maps", () => ctx.getEliteTracking(win, value.characterKey))),
eliteTrackingUpdate: channel(one(parseEliteUpdate), (win, value) =>
ctx.runFeature("cartography", "Maps", () => ctx.updateEliteTracking(win, value))),
buildLibraryGet: channel(nothing, (win) => ctx.runFeature(
"buildLibrary", "build library", () => ctx.getBuildLibrary(win),
)),
Expand Down
11 changes: 10 additions & 1 deletion src/main/tools-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* Owns every optional main-process Tools implementation for one Tools-capable
* launch. Core never imports this module, its stores, or its network client.
*/
import { dirname, join } from "node:path";
import { EliteTrackingStore } from "./core/elite-tracking.js";
import type { BrowserWindow } from "electron";
import type { AppSettings, SettingsResetOutcome } from "../shared/contracts.js";
import { featureActivationRequested } from "../shared/feature-contracts.js";
Expand Down Expand Up @@ -30,18 +32,21 @@ export function createToolsRuntime(input: Readonly<{
preferences: PreferencesCoordinator;
initialSettings: AppSettings;
}>): ToolsRuntime {
const eliteTracking = new EliteTrackingStore();
const elitePath = (win: BrowserWindow) => join(dirname(input.accounts.buildLibraryPathFor(win)), "elite-tracking.json");
const buildLibraries = new BuildLibraryCoordinator();
const tradeChat = new TradeChatService();
const tradeSaved = new TradeSavedStore(input.paths.tradeSaved);
const travelHistory = new TravelHistoryStore(input.paths.travelHistory);
let settings = input.initialSettings;
const fileGates = {
buildLibrary: new Mutex(),
cartography: new Mutex(),
travelPalette: new Mutex(),
} as const;
let tradeEnabled = featureActivationRequested("tradeChat", settings);
const isFeatureEnabled = (
feature: "buildLibrary" | "travelPalette" | "tradeChat",
feature: "buildLibrary" | "travelPalette" | "tradeChat" | "cartography",
): boolean => featureActivationRequested(
feature === "travelPalette" ? "travel" : feature,
settings,
Expand All @@ -60,6 +65,8 @@ export function createToolsRuntime(input: Readonly<{
buildLibraries.get(win, input.accounts.buildLibraryPathFor(win)),
setBuildLibrary: (win: BrowserWindow, library) =>
buildLibraries.set(win, input.accounts.buildLibraryPathFor(win), library),
getEliteTracking: (win, characterKey) => eliteTracking.get(elitePath(win), characterKey),
updateEliteTracking: (win, value) => eliteTracking.update(elitePath(win), value),
getTravelPreferences: () => input.preferences.getTravelPreferences(),
setTravelPreferences: (update) => input.preferences.updateTravelPreferences(update),
getTravelHistory: (characterKey) => travelHistory.get(characterKey),
Expand All @@ -78,6 +85,7 @@ export function createToolsRuntime(input: Readonly<{
tradeEnabled = nextTradeEnabled;
await Promise.all([
fileGates.buildLibrary.settled,
fileGates.cartography.settled,
fileGates.travelPalette.settled,
]);
},
Expand All @@ -88,6 +96,7 @@ export function createToolsRuntime(input: Readonly<{
tradeChat.dispose();
await Promise.all([
fileGates.buildLibrary.settled,
fileGates.cartography.settled,
fileGates.travelPalette.settled,
]);
},
Expand Down
4 changes: 4 additions & 0 deletions src/preload/preload.tools.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
* @param {<T>(channel: string, callback: (value: T) => void) => () => void} listen
*/
function installToolsApi(api, ipcRenderer, IPC, listen) {
api.eliteTracking = {
get: (value) => ipcRenderer.invoke(IPC.eliteTrackingGet, value),
update: (value) => ipcRenderer.invoke(IPC.eliteTrackingUpdate, value),
};
api.trade = {
subscribe: (source) => ipcRenderer.invoke(IPC.tradeSubscribe, source),
unsubscribe: () => ipcRenderer.invoke(IPC.tradeUnsubscribe),
Expand Down
8 changes: 8 additions & 0 deletions src/shared/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { EliteTracking, EliteUpdate } from "./elite-skills.js";
import type {
DiagnosticSummary,
RendererFrameBatch,
Expand Down Expand Up @@ -1141,6 +1142,8 @@ export const TOOLS_IPC = {
traderPriceHistoryGet: "gw:trader:priceHistory:get",
travelPreferencesGet: "gw:travelPreferences:get",
travelPreferencesSet: "gw:travelPreferences:set",
eliteTrackingGet: "gw:eliteTracking:get",
eliteTrackingUpdate: "gw:eliteTracking:update",
travelHistoryGet: "gw:travelHistory:get",
travelHistoryRecord: "gw:travelHistory:record",
buildLibraryGet: "gw:buildLibrary:get",
Expand Down Expand Up @@ -1377,6 +1380,10 @@ export interface ToolsNativeApiExtension {
get(): Promise<TravelUserPreferences>;
set(value: TravelUserPreferencesUpdate): Promise<TravelUserPreferences>;
};
eliteTracking: {
get(value: { characterKey: string }): Promise<EliteTracking>;
update(value: EliteUpdate): Promise<EliteTracking>;
};
travelHistory: {
get(value: { characterKey: string }): Promise<readonly number[]>;
record(value: { characterKey: string; mapId: number }): Promise<readonly number[]>;
Expand All @@ -1393,6 +1400,7 @@ const TOOL_NATIVE_NAMESPACE_MAP = {
trade: true,
travelPreferences: true,
travelHistory: true,
eliteTracking: true,
buildLibrary: true,
} as const satisfies Record<ToolNativeNamespace, true>;

Expand Down
Loading
Loading