feat: adapt Exile-ui for linux
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
7
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"svelte.enable-ts-plugin": true
|
||||
}
|
||||
48
README.md
@ -20,9 +20,12 @@ configurer. Pour les sessions X11 c'est sans effet.
|
||||
> position/taille forcées, *ignorer la barre des tâches*. Jouer en **plein écran
|
||||
> fenêtré** (borderless).
|
||||
|
||||
## Périmètre actuel (v1)
|
||||
## Fonctionnalités
|
||||
|
||||
Socle + **Act-Tracker / leveling** :
|
||||
Socle + **Act-Tracker / leveling**, **timer de run**, **profils de personnage**
|
||||
et **zone-layout viewer**.
|
||||
|
||||
### Act-Tracker / leveling
|
||||
|
||||
- Lecture en continu du `Client.txt` du jeu (détection de zone et de niveau via
|
||||
les lignes `Generating level … area "…"` et `… is now level N`).
|
||||
@ -36,7 +39,31 @@ Socle + **Act-Tracker / leveling** :
|
||||
- **Hotkeys globaux** configurables (suivant / précédent / afficher-masquer).
|
||||
- Branche **league-start** vs non-league-start du guide.
|
||||
- L'overlay n'apparaît que lorsque le jeu est au premier plan (optionnel).
|
||||
- Réglages persistés dans `~/.config/exile-ui/config.json`.
|
||||
|
||||
### Timer de run
|
||||
|
||||
- Chronomètre de speedrun campagne : temps total + **splits par acte**, calqué
|
||||
sur le timer de l'outil d'origine.
|
||||
- **Pause automatique** en ville/hideout et sur **inactivité (AFK)** — la
|
||||
détection AFK se fait par polling de la souris via `xdotool` (les API d'idle
|
||||
sont absentes sous Wayland/XWayland).
|
||||
- Pause manuelle distincte de la pause automatique.
|
||||
|
||||
### Profils de personnage
|
||||
|
||||
- Profils créés manuellement, **liés à un personnage in-game par son nom**
|
||||
(reconnu dans le log) — pas d'API GGG (OAuth réservé).
|
||||
- Chaque profil garde sa **propre progression**, sa branche de guide et son
|
||||
**timer** (avec splits par acte).
|
||||
|
||||
### Zone-layout viewer
|
||||
|
||||
- Sélecteur de layouts de zone interactif, **porté de l'`act-decoder`** AHK :
|
||||
arbre de décision sur les images groupées par `areaID`, qu'on affine
|
||||
manuellement selon ce qu'on voit à l'écran (aucune capture d'écran — ToS).
|
||||
- Images de layouts PoE2 embarquées dans `static/layouts/`.
|
||||
|
||||
Réglages et profils persistés dans `~/.config/exile-ui/config.json`.
|
||||
|
||||
## Développement
|
||||
|
||||
@ -59,19 +86,24 @@ npm run tauri build # produit l'AppImage + .deb dans src-tauri/target/release
|
||||
src/ frontend (SvelteKit, SPA)
|
||||
lib/api.ts pont commandes/events Tauri
|
||||
lib/markup.ts parseur du langage de balisage du guide → HTML
|
||||
lib/StepView.svelte rendu d'une étape du guide (lignes + optionnels)
|
||||
lib/Overlay.svelte fenêtre overlay (label "overlay")
|
||||
lib/Settings.svelte fenêtre principale / réglages (label "main")
|
||||
lib/Settings.svelte fenêtre principale / réglages / profils (label "main")
|
||||
lib/Layouts.svelte fenêtre du zone-layout viewer (label "layout")
|
||||
lib/layouts.ts arbre de décision des layouts (manifest areaID → chemins)
|
||||
src-tauri/src/
|
||||
lib.rs état partagé, commandes, fenêtres, hotkeys
|
||||
logwatch.rs tail du Client.txt → parsing → events + progression
|
||||
leveltracker.rs chargement du guide/zones, logique de progression
|
||||
timer.rs timer de run (total + splits par acte, pauses auto/AFK)
|
||||
poe.rs détection du log + fenêtre active (X11/xdotool)
|
||||
config.rs persistance de la configuration
|
||||
src-tauri/data/ données PoE2 embarquées (guide, zones, gemmes)
|
||||
config.rs persistance config + profils de personnage
|
||||
src-tauri/data/ données PoE2 embarquées (guide2, areas2, gems2)
|
||||
static/layouts/ images de layouts de zone (zone-layout viewer)
|
||||
```
|
||||
|
||||
Les deux fenêtres chargent le même bundle SPA ; la vue est choisie selon le
|
||||
label de la fenêtre (`overlay` vs `main`).
|
||||
Les fenêtres chargent le même bundle SPA ; la vue est choisie selon le label de
|
||||
la fenêtre (`overlay`, `main`, `layout`).
|
||||
|
||||
## Crédits
|
||||
|
||||
|
||||
1938
package-lock.json
generated
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "exile-ui",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
7
src-tauri/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5150
src-tauri/Cargo.lock
generated
Normal file
27
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "exile-ui"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "exile_ui_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "6"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
15
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main and overlay windows",
|
||||
"windows": ["main", "overlay"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
466
src-tauri/data/areas2.json
Normal file
@ -0,0 +1,466 @@
|
||||
[
|
||||
[
|
||||
{
|
||||
"id": "g1_1",
|
||||
"name": "the riverbank"
|
||||
},
|
||||
{
|
||||
"id": "g1_town",
|
||||
"name": "clearfell encampment"
|
||||
},
|
||||
{
|
||||
"id": "g1_2",
|
||||
"name": "clearfell",
|
||||
"recommendation": "2 | 2"
|
||||
},
|
||||
{
|
||||
"id": "g1_3",
|
||||
"name": "mud burrow"
|
||||
},
|
||||
{
|
||||
"id": "g1_4",
|
||||
"name": "the grelwood",
|
||||
"recommendation": "2 | 2"
|
||||
},
|
||||
{
|
||||
"id": "g1_5",
|
||||
"name": "red vale",
|
||||
"recommendation": "2 | 4"
|
||||
},
|
||||
{
|
||||
"id": "g1_6",
|
||||
"name": "grim tangle",
|
||||
"recommendation": "4 | 5"
|
||||
},
|
||||
{
|
||||
"id": "g1_7",
|
||||
"name": "cemetery of eternals",
|
||||
"recommendation": "5 | 6"
|
||||
},
|
||||
{
|
||||
"id": "g1_8",
|
||||
"name": "mausoleum of praetor",
|
||||
"recommendation": "7 | 7"
|
||||
},
|
||||
{
|
||||
"id": "g1_9",
|
||||
"name": "tomb of consort",
|
||||
"recommendation": "6 | 7"
|
||||
},
|
||||
{
|
||||
"id": "g1_10",
|
||||
"name": "root hollow"
|
||||
},
|
||||
{
|
||||
"id": "g1_11",
|
||||
"name": "hunting grounds",
|
||||
"recommendation": "7 | 8"
|
||||
},
|
||||
{
|
||||
"id": "g1_12",
|
||||
"name": "freythorn",
|
||||
"recommendation": "9 | 10"
|
||||
},
|
||||
{
|
||||
"id": "g1_13_1",
|
||||
"name": "ogham farmlands",
|
||||
"recommendation": "8 | 9"
|
||||
},
|
||||
{
|
||||
"id": "g1_13_2",
|
||||
"name": "ogham village",
|
||||
"recommendation": "10 | 11"
|
||||
},
|
||||
{
|
||||
"id": "g1_14",
|
||||
"name": "manor ramparts",
|
||||
"recommendation": "11 | 12"
|
||||
},
|
||||
{
|
||||
"id": "g1_15",
|
||||
"name": "ogham manor",
|
||||
"recommendation": "12 | 13+"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"id": "g2_1",
|
||||
"name": "vastiri outskirts",
|
||||
"recommendation": "13+ | 14"
|
||||
},
|
||||
{
|
||||
"id": "g2_town",
|
||||
"name": "ardura caravan"
|
||||
},
|
||||
{
|
||||
"id": "g2_2",
|
||||
"name": "traitor's passage",
|
||||
"recommendation": "16.5 | 17.5"
|
||||
},
|
||||
{
|
||||
"id": "g2_3a",
|
||||
"name": "halani gates (blocked)"
|
||||
},
|
||||
{
|
||||
"id": "g2_3",
|
||||
"name": "halani gates",
|
||||
"recommendation": "17.5 | 18.5"
|
||||
},
|
||||
{
|
||||
"id": "g2_4_1",
|
||||
"name": "keth",
|
||||
"recommendation": "18.5 | 19"
|
||||
},
|
||||
{
|
||||
"id": "g2_4_2",
|
||||
"name": "lost city",
|
||||
"recommendation": "19 | 20"
|
||||
},
|
||||
{
|
||||
"id": "g2_4_3",
|
||||
"name": "buried shrines",
|
||||
"recommendation": "20 | 21"
|
||||
},
|
||||
{
|
||||
"id": "g2_5_1",
|
||||
"name": "mastodon badlands",
|
||||
"recommendation": "21 | 21.5"
|
||||
},
|
||||
{
|
||||
"id": "g2_5_2",
|
||||
"name": "bone pits",
|
||||
"recommendation": "21.5 | 22.5"
|
||||
},
|
||||
{
|
||||
"id": "g2_6",
|
||||
"name": "valley of the titans",
|
||||
"recommendation": "22.5 | 23.5"
|
||||
},
|
||||
{
|
||||
"id": "g2_7",
|
||||
"name": "titan grotto",
|
||||
"recommendation": "23.5 | 24"
|
||||
},
|
||||
{
|
||||
"id": "g2_8",
|
||||
"name": "deshar",
|
||||
"recommendation": "24 | 25"
|
||||
},
|
||||
{
|
||||
"id": "g2_9_1",
|
||||
"name": "path of mourning",
|
||||
"recommendation": "25 | 26"
|
||||
},
|
||||
{
|
||||
"id": "g2_9_2",
|
||||
"name": "spires of deshar",
|
||||
"recommendation": "26 | 27"
|
||||
},
|
||||
{
|
||||
"id": "g2_10_1",
|
||||
"name": "mawdun quarry",
|
||||
"recommendation": "14 | 15.5"
|
||||
},
|
||||
{
|
||||
"id": "g2_10_2",
|
||||
"name": "mawdun mine",
|
||||
"recommendation": "15.5 | 16.5"
|
||||
},
|
||||
{
|
||||
"id": "g2_12_1",
|
||||
"name": "the dreadnought",
|
||||
"recommendation": "27 | 28.5"
|
||||
},
|
||||
{
|
||||
"id": "g2_13",
|
||||
"name": "trial of sekhemas"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"id": "g3_1",
|
||||
"name": "sandswept marsh",
|
||||
"recommendation": "28.5 | 29"
|
||||
},
|
||||
{
|
||||
"id": "g3_town",
|
||||
"name": "ziggurat encampment"
|
||||
},
|
||||
{
|
||||
"id": "g3_2_1",
|
||||
"name": "infested barrens",
|
||||
"recommendation": "31 | 32"
|
||||
},
|
||||
{
|
||||
"id": "g3_2_2",
|
||||
"name": "matlan waterways",
|
||||
"recommendation": "34 | 35"
|
||||
},
|
||||
{
|
||||
"id": "g3_4",
|
||||
"name": "venom crypts",
|
||||
"recommendation": "30 | 31"
|
||||
},
|
||||
{
|
||||
"id": "g3_3",
|
||||
"name": "jungle ruins",
|
||||
"recommendation": "29 | 30"
|
||||
},
|
||||
{
|
||||
"id": "g3_5",
|
||||
"name": "chimeral wetlands",
|
||||
"recommendation": "32 | 33"
|
||||
},
|
||||
{
|
||||
"id": "g3_6_1",
|
||||
"name": "jiquani's machinarium",
|
||||
"recommendation": "33 | 33"
|
||||
},
|
||||
{
|
||||
"id": "g3_6_2",
|
||||
"name": "jiquani's sanctum",
|
||||
"recommendation": "33 | 34"
|
||||
},
|
||||
{
|
||||
"id": "g3_7",
|
||||
"name": "azak bog",
|
||||
"recommendation": "35 | 36"
|
||||
},
|
||||
{
|
||||
"id": "g3_8",
|
||||
"name": "drowned city",
|
||||
"recommendation": "36 | 36"
|
||||
},
|
||||
{
|
||||
"id": "g3_9",
|
||||
"name": "molten vault",
|
||||
"recommendation": "37 | 37"
|
||||
},
|
||||
{
|
||||
"id": "g3_10_airlock",
|
||||
"name": "temple of chaos"
|
||||
},
|
||||
{
|
||||
"id": "g3_11",
|
||||
"name": "apex of filth",
|
||||
"recommendation": "36 | 37"
|
||||
},
|
||||
{
|
||||
"id": "g3_12",
|
||||
"name": "temple of kopec",
|
||||
"recommendation": "37 | 38"
|
||||
},
|
||||
{
|
||||
"id": "g3_14",
|
||||
"name": "utzaal",
|
||||
"recommendation": "38 | 39"
|
||||
},
|
||||
{
|
||||
"id": "g3_16",
|
||||
"name": "aggorat",
|
||||
"recommendation": "39 | 40"
|
||||
},
|
||||
{
|
||||
"id": "g3_17",
|
||||
"name": "black chambers",
|
||||
"recommendation": "40 | 41"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"id": "g4_town",
|
||||
"name": "kingsmarch"
|
||||
},
|
||||
{
|
||||
"id": "g4_1_1",
|
||||
"name": "isle of kin",
|
||||
"recommendation": "41 | 41.5"
|
||||
},
|
||||
{
|
||||
"id": "g4_1_2",
|
||||
"name": "volcanic warrens",
|
||||
"recommendation": "41.5 | 42"
|
||||
},
|
||||
{
|
||||
"id": "g4_2_1",
|
||||
"name": "kedge bay",
|
||||
"recommendation": "42 | 42.5"
|
||||
},
|
||||
{
|
||||
"id": "g4_2_2",
|
||||
"name": "journey's end",
|
||||
"recommendation": "42.5 | 43"
|
||||
},
|
||||
{
|
||||
"id": "g4_3_1",
|
||||
"name": "whakapanu island",
|
||||
"recommendation": "43 | 43"
|
||||
},
|
||||
{
|
||||
"id": "g4_3_2",
|
||||
"name": "singing caverns",
|
||||
"recommendation": "43 | 43"
|
||||
},
|
||||
{
|
||||
"id": "g4_4_1",
|
||||
"name": "eye of hinekora",
|
||||
"recommendation": "45 | 46"
|
||||
},
|
||||
{
|
||||
"id": "g4_4_2",
|
||||
"name": "halls of the dead",
|
||||
"recommendation": "46 | 47"
|
||||
},
|
||||
{
|
||||
"id": "g4_4_3",
|
||||
"name": "trial of the ancestors"
|
||||
},
|
||||
{
|
||||
"id": "g4_5_1",
|
||||
"name": "abandoned prison",
|
||||
"recommendation": "43 | 44"
|
||||
},
|
||||
{
|
||||
"id": "g4_5_2",
|
||||
"name": "solitary confinement",
|
||||
"recommendation": "44 | 44"
|
||||
},
|
||||
{
|
||||
"id": "g4_7",
|
||||
"name": "shrike island",
|
||||
"recommendation": "44 | 45"
|
||||
},
|
||||
{
|
||||
"id": "g4_8a",
|
||||
"name": "arastas"
|
||||
},
|
||||
{
|
||||
"id": "g4_8b",
|
||||
"name": "arastas (hostile)",
|
||||
"recommendation": "47 | 47"
|
||||
},
|
||||
{
|
||||
"id": "g4_10",
|
||||
"name": "the excavation",
|
||||
"recommendation": "47 | 48"
|
||||
},
|
||||
{
|
||||
"id": "g4_11_1a",
|
||||
"name": "ngakanu"
|
||||
},
|
||||
{
|
||||
"id": "g4_11_1b",
|
||||
"name": "ngakanu (hostile)",
|
||||
"recommendation": "48 | 48"
|
||||
},
|
||||
{
|
||||
"id": "g4_11_2",
|
||||
"name": "heart of the tribe",
|
||||
"recommendation": "49 | 49"
|
||||
},
|
||||
{
|
||||
"id": "g4_13",
|
||||
"name": "plunder's point"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"id": "p1_town",
|
||||
"name": "the refuge"
|
||||
},
|
||||
{
|
||||
"id": "p1_1",
|
||||
"name": "scorched farmlands"
|
||||
},
|
||||
{
|
||||
"id": "p1_2",
|
||||
"name": "stones of serle"
|
||||
},
|
||||
{
|
||||
"id": "p1_3",
|
||||
"name": "the blackwood"
|
||||
},
|
||||
{
|
||||
"id": "p1_4",
|
||||
"name": "holten"
|
||||
},
|
||||
{
|
||||
"id": "p1_5",
|
||||
"name": "wolvenhold"
|
||||
},
|
||||
{
|
||||
"id": "p1_6",
|
||||
"name": "holten estate"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"id": "p2_town",
|
||||
"name": "khari bazaar"
|
||||
},
|
||||
{
|
||||
"id": "p2_1",
|
||||
"name": "khari crossing"
|
||||
},
|
||||
{
|
||||
"id": "p2_2",
|
||||
"name": "pools of khatal"
|
||||
},
|
||||
{
|
||||
"id": "p2_3",
|
||||
"name": "sel khari sanctuary"
|
||||
},
|
||||
{
|
||||
"id": "p2_5",
|
||||
"name": "galai gates"
|
||||
},
|
||||
{
|
||||
"id": "p2_6",
|
||||
"name": "qimah"
|
||||
},
|
||||
{
|
||||
"id": "p2_7",
|
||||
"name": "qimah reservoir"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"id": "p3_town",
|
||||
"name": "the glade"
|
||||
},
|
||||
{
|
||||
"id": "p3_1",
|
||||
"name": "ashen forest"
|
||||
},
|
||||
{
|
||||
"id": "p3_2",
|
||||
"name": "kriar village"
|
||||
},
|
||||
{
|
||||
"id": "p3_3",
|
||||
"name": "glacial tarn"
|
||||
},
|
||||
{
|
||||
"id": "p3_4",
|
||||
"name": "howling caves"
|
||||
},
|
||||
{
|
||||
"id": "p3_5",
|
||||
"name": "kriar peaks"
|
||||
},
|
||||
{
|
||||
"id": "p3_6",
|
||||
"name": "etched ravine"
|
||||
},
|
||||
{
|
||||
"id": "p3_7",
|
||||
"name": "cuachic vault"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"id": "g_endgame_town",
|
||||
"name": "ziggurat refuge"
|
||||
}
|
||||
]
|
||||
]
|
||||
906
src-tauri/data/gems2.json
Normal file
@ -0,0 +1,906 @@
|
||||
{
|
||||
"skill": {
|
||||
"acidic concoction": [0,4],
|
||||
"ancestral cry": [13,1],
|
||||
"ancestral spirits": [0,4],
|
||||
"ancestral warrior totem": [13,1],
|
||||
"apocalypse": [0,4],
|
||||
"arc": [5,3],
|
||||
"arctic howl": [5,1],
|
||||
"armour breaker": [3,1],
|
||||
"armour piercing rounds": [1,1],
|
||||
"artillery ballista": [7,1],
|
||||
"ball lightning": [11,3],
|
||||
"barrage": [5,2],
|
||||
"bleeding concoction": [0,4],
|
||||
"blood hunt": [7,2],
|
||||
"bloodhound's mark": [9,2],
|
||||
"bone blast": [0,3],
|
||||
"bone cage": [3,3],
|
||||
"bone offering": [11,3],
|
||||
"boneshatter": [1,1],
|
||||
"bonestorm": [5,3],
|
||||
"bow shot": [0,4],
|
||||
"bursting fen toad": [0,4],
|
||||
"chaos bolt": [0,3],
|
||||
"charged staff": [9,3],
|
||||
"cluster grenade": [13,1],
|
||||
"comet": [11,3],
|
||||
"compose requiem": [0,1],
|
||||
"conductivity": [0,3],
|
||||
"consecrate": [0,1],
|
||||
"contagion": [1,3],
|
||||
"cross slash": [7,1],
|
||||
"crossbow shot": [0,4],
|
||||
"cull the weak": [3,2],
|
||||
"dark effigy": [9,3],
|
||||
"decompose": [0,3],
|
||||
"demon form": [0,4],
|
||||
"despair": [9,3],
|
||||
"detonate dead": [7,3],
|
||||
"detonate minion": [0,2],
|
||||
"detonating arrow": [9,2],
|
||||
"devour": [5,1],
|
||||
"disengage": [1,2],
|
||||
"earthquake": [1,1],
|
||||
"earthshatter": [7,1],
|
||||
"electrocuting arrow": [5,2],
|
||||
"elemental expression": [0,4],
|
||||
"elemental storm": [0,4],
|
||||
"elemental sundering": [11,2],
|
||||
"elemental surge": [0,4],
|
||||
"elemental weakness": [7,3],
|
||||
"ember fusillade": [5,3],
|
||||
"emergency reload": [11,1],
|
||||
"encase in jade": [0,4],
|
||||
"enfeeble": [3,3],
|
||||
"entangle": [1,1],
|
||||
"escape shot": [1,2],
|
||||
"essence drain": [3,3],
|
||||
"explosive concoction": [0,4],
|
||||
"explosive grenade": [1,1],
|
||||
"explosive shot": [7,1],
|
||||
"explosive spear": [1,2],
|
||||
"exsanguinate": [0,3],
|
||||
"eye of winter": [13,3],
|
||||
"falling thunder": [1,3],
|
||||
"fangs of frost": [3,2],
|
||||
"feast of flesh": [0,3],
|
||||
"ferocious roar": [7,1],
|
||||
"fireball": [3,3],
|
||||
"firebolt": [0,3],
|
||||
"firestorm": [11,3],
|
||||
"flame breath": [13,1],
|
||||
"flame wall": [1,3],
|
||||
"flameblast": [13,3],
|
||||
"flammability": [0,3],
|
||||
"flash grenade": [3,1],
|
||||
"flicker strike": [13,3],
|
||||
"forge hammer": [9,1],
|
||||
"fortifying cry": [9,1],
|
||||
"fragmentation rounds": [1,1],
|
||||
"freezing mark": [7,3],
|
||||
"freezing salvo": [3,2],
|
||||
"freezing shards": [0,3],
|
||||
"frost bomb": [1,3],
|
||||
"frost darts": [3,3],
|
||||
"frost wall": [9,3],
|
||||
"frostbolt": [5,3],
|
||||
"frozen locus": [1,3],
|
||||
"fulminating concoction": [0,4],
|
||||
"furious slam": [1,1],
|
||||
"fury of the mountain": [5,1],
|
||||
"galvanic field": [0,3],
|
||||
"galvanic shards": [5,1],
|
||||
"gas arrow": [7,2],
|
||||
"gas grenade": [5,1],
|
||||
"gathering storm": [13,3],
|
||||
"gemini surge": [0,1],
|
||||
"glacial bolt": [7,1],
|
||||
"glacial cascade": [1,3],
|
||||
"glacial lance": [7,2],
|
||||
"hailstorm rounds": [11,1],
|
||||
"hammer of the gods": [13,1],
|
||||
"hand of chayula": [9,3],
|
||||
"hexblast": [11,3],
|
||||
"high velocity rounds": [3,1],
|
||||
"his foul emergence": [0,3],
|
||||
"his scattering calamity": [0,3],
|
||||
"his vile intrusion": [0,3],
|
||||
"his winnowing flame": [0,3],
|
||||
"hypothermia": [0,3],
|
||||
"ice fragments": [0,4],
|
||||
"ice nova": [1,3],
|
||||
"ice shards": [5,1],
|
||||
"ice shot": [9,2],
|
||||
"ice strike": [5,3],
|
||||
"ice-tipped arrows": [5,2],
|
||||
"icestorm": [0,3],
|
||||
"incendiary shot": [3,1],
|
||||
"incinerate": [9,3],
|
||||
"inevitable agony": [0,4],
|
||||
"infernal cry": [3,1],
|
||||
"killing palm": [1,3],
|
||||
"leap slam": [7,1],
|
||||
"lightning arrow": [1,2],
|
||||
"lightning bolt": [0,3],
|
||||
"lightning conduit": [13,3],
|
||||
"lightning rod": [1,2],
|
||||
"lightning spear": [3,2],
|
||||
"lightning warp": [9,3],
|
||||
"living bomb": [3,3],
|
||||
"lunar assault": [1,1],
|
||||
"lunar blessing": [13,1],
|
||||
"mace strike": [0,4],
|
||||
"magnetic salvo": [13,2],
|
||||
"mana drain": [0,3],
|
||||
"mana tempest": [7,3],
|
||||
"mantra of destruction": [9,3],
|
||||
"maul": [0,4],
|
||||
"meditate": [0,4],
|
||||
"molten blast": [5,1],
|
||||
"molten crash": [0,1],
|
||||
"moment of vulnerability": [0,4],
|
||||
"mortar cannon": [11,1],
|
||||
"oil barrage": [9,1],
|
||||
"oil grenade": [9,1],
|
||||
"orb of storms": [3,3],
|
||||
"pain offering": [5,3],
|
||||
"parry": [0,4],
|
||||
"perfect strike": [5,1],
|
||||
"permafrost bolts": [1,1],
|
||||
"phantasmal arrow": [0,2],
|
||||
"pinnacle of power": [0,4],
|
||||
"plasma blast": [13,1],
|
||||
"poisonburst arrow": [1,2],
|
||||
"pounce": [3,1],
|
||||
"power siphon": [0,3],
|
||||
"primal strikes": [7,2],
|
||||
"profane ritual": [7,3],
|
||||
"quarterstaff strike": [0,4],
|
||||
"rain of arrows": [9,2],
|
||||
"raise shield": [0,4],
|
||||
"raise zombie": [5,3],
|
||||
"rake": [3,2],
|
||||
"rampage": [11,1],
|
||||
"rapid assault": [5,2],
|
||||
"rapid shot": [5,1],
|
||||
"reap": [0,3],
|
||||
"rend": [0,4],
|
||||
"resonating shield": [5,1],
|
||||
"ritual sacrifice": [0,4],
|
||||
"rolling magma": [3,1],
|
||||
"rolling slam": [1,1],
|
||||
"seismic cry": [11,1],
|
||||
"shattering concoction": [0,4],
|
||||
"shattering palm": [11,3],
|
||||
"shattering spite": [0,2],
|
||||
"shield charge": [3,1],
|
||||
"shield wall": [7,1],
|
||||
"shockburst rounds": [11,1],
|
||||
"shockchain arrow": [11,2],
|
||||
"shockwave totem": [3,1],
|
||||
"shred": [0,4],
|
||||
"siege ballista": [9,1],
|
||||
"siege cascade": [13,1],
|
||||
"sigil of power": [0,3],
|
||||
"siphoning strike": [7,3],
|
||||
"snap": [5,3],
|
||||
"snipe": [3,2],
|
||||
"sniper's mark": [7,2],
|
||||
"solar orb": [0,3],
|
||||
"soul offering": [13,3],
|
||||
"soulrend": [0,3],
|
||||
"spark": [1,3],
|
||||
"spear of solaris": [13,2],
|
||||
"spear stab": [0,4],
|
||||
"spear throw": [0,4],
|
||||
"spearfield": [5,2],
|
||||
"spell totem": [7,1],
|
||||
"spiral volley": [13,2],
|
||||
"staggering palm": [3,3],
|
||||
"stampede": [11,1],
|
||||
"storm lance": [5,2],
|
||||
"storm wave": [7,3],
|
||||
"stormblast bolts": [9,1],
|
||||
"stormcaller arrow": [3,2],
|
||||
"sunder": [9,1],
|
||||
"supercharged slam": [11,1],
|
||||
"tame beast": [9,2],
|
||||
"temper weapon": [0,4],
|
||||
"tempest bell": [3,3],
|
||||
"tempest flurry": [5,3],
|
||||
"temporal chains": [7,3],
|
||||
"thrashing vines": [9,1],
|
||||
"thunderous leap": [9,2],
|
||||
"thunderstorm": [5,1],
|
||||
"time freeze": [0,4],
|
||||
"time snap": [0,4],
|
||||
"tornado": [11,1],
|
||||
"tornado shot": [11,2],
|
||||
"toxic domain": [13,2],
|
||||
"toxic growth": [5,2],
|
||||
"twister": [1,2],
|
||||
"unbound avatar": [0,4],
|
||||
"unearth": [1,3],
|
||||
"valako's charge": [0,1],
|
||||
"vaulting impact": [5,3],
|
||||
"vine arrow": [3,2],
|
||||
"volatile dead": [0,3],
|
||||
"volcanic fissure": [7,1],
|
||||
"volcano": [1,1],
|
||||
"voltaic grenade": [7,1],
|
||||
"voltaic mark": [7,2],
|
||||
"vulnerability": [7,3],
|
||||
"walking calamity": [13,1],
|
||||
"wave of frost": [7,3],
|
||||
"whirling assault": [11,3],
|
||||
"whirling slash": [1,2],
|
||||
"whirlwind lance": [11,2],
|
||||
"wind blast": [3,3],
|
||||
"wind serpent's fury": [13,2],
|
||||
"wing blast": [3,1],
|
||||
"wither": [0,3]
|
||||
},
|
||||
"spirit": {
|
||||
"alchemist's boon": [14,2],
|
||||
"align fate": [0,4],
|
||||
"archmage": [14,3],
|
||||
"arctic armour": [4,3],
|
||||
"attrition": [4,1],
|
||||
"barkskin": [8,1],
|
||||
"barrier invocation": [8,3],
|
||||
"berserk": [14,1],
|
||||
"black powder blitz": [0,1],
|
||||
"blasphemy": [8,3],
|
||||
"blink": [8,3],
|
||||
"blood boil": [0,4],
|
||||
"briarpatch": [4,1],
|
||||
"cackling companions": [0,1],
|
||||
"called shots": [0,4],
|
||||
"cast on block": [0,1],
|
||||
"cast on charm use": [0,4],
|
||||
"cast on critical": [14,3],
|
||||
"cast on dodge": [14,3],
|
||||
"cast on elemental ailment": [14,3],
|
||||
"cast on melee kill": [0,1],
|
||||
"cast on melee stun": [0,1],
|
||||
"cast on minion death": [8,3],
|
||||
"charge infusion": [14,3],
|
||||
"combat frenzy": [8,2],
|
||||
"companion": [0,2],
|
||||
"convalescence": [4,3],
|
||||
"crackling palm": [0,3],
|
||||
"curse on block": [0,3],
|
||||
"defiance banner": [8,1],
|
||||
"discipline": [0,3],
|
||||
"dread banner": [14,1],
|
||||
"elemental conflux": [14,3],
|
||||
"elemental invocation": [8,3],
|
||||
"eternal rage": [14,1],
|
||||
"feral invocation": [14,1],
|
||||
"fire spell on hit": [0,4],
|
||||
"fulmination": [0,3],
|
||||
"future-past": [0,4],
|
||||
"ghost dance": [4,3],
|
||||
"grim feast": [4,3],
|
||||
"heart of ice": [0,3],
|
||||
"herald of ash": [4,1],
|
||||
"herald of blood": [4,1],
|
||||
"herald of ice": [4,3],
|
||||
"herald of plague": [8,2],
|
||||
"herald of thunder": [4,2],
|
||||
"impurity": [0,3],
|
||||
"into the breach": [0,4],
|
||||
"iron ward": [4,1],
|
||||
"kelari's malediction": [0,4],
|
||||
"kelari, the tainted sands": [0,4],
|
||||
"life remnants": [0,4],
|
||||
"lingering illusion": [8,3],
|
||||
"magma barrier": [4,1],
|
||||
"malice": [0,3],
|
||||
"mana remnants": [4,3],
|
||||
"manifest weapon": [0,4],
|
||||
"mirage archer": [8,2],
|
||||
"mirror of refraction": [0,3],
|
||||
"navira, the last mirage": [0,4],
|
||||
"overwhelming presence": [8,1],
|
||||
"plague bearer": [4,2],
|
||||
"purity of fire": [0,3],
|
||||
"purity of ice": [0,3],
|
||||
"purity of lightning": [0,3],
|
||||
"raging spirits": [4,3],
|
||||
"ravenous swarm": [4,3],
|
||||
"reaper's invocation": [14,3],
|
||||
"rhoa mount": [14,2],
|
||||
"ruzhan, the blazing sword": [0,4],
|
||||
"sacrifice": [14,3],
|
||||
"scavenged plating": [4,1],
|
||||
"shard scavenger": [8,1],
|
||||
"siphon elements": [8,3],
|
||||
"skeletal arsonist": [3,3],
|
||||
"skeletal brute": [13,3],
|
||||
"skeletal cleric": [13,3],
|
||||
"skeletal frost mage": [5,3],
|
||||
"skeletal reaver": [9,3],
|
||||
"skeletal sniper": [1,3],
|
||||
"skeletal storm mage": [11,3],
|
||||
"skeletal warrior": [0,3],
|
||||
"sorcery ward": [0,4],
|
||||
"spectre": [0,3],
|
||||
"spellslinger": [0,3],
|
||||
"summon infernal hound": [0,4],
|
||||
"supporting fire": [0,4],
|
||||
"temporal rift": [0,4],
|
||||
"thundergod's wrath": [0,1],
|
||||
"time of need": [8,1],
|
||||
"trail of caltrops": [8,2],
|
||||
"trinity": [14,3],
|
||||
"void illusion": [0,4],
|
||||
"war banner": [4,1],
|
||||
"wind dancer": [4,2],
|
||||
"withering presence": [4,3],
|
||||
"wolf pack": [8,1]
|
||||
},
|
||||
"support": {
|
||||
"abiding hex": [2,3],
|
||||
"accelerated growth": [2,3],
|
||||
"accelerated growth ii": [5,3],
|
||||
"acrimony": [2,3],
|
||||
"adhesive grenades i": [2,2],
|
||||
"adhesive grenades ii": [3,2],
|
||||
"adhesive grenades iii": [0,2],
|
||||
"admixture": [2,2],
|
||||
"advancing storm": [4,3],
|
||||
"aftershock i": [2,1],
|
||||
"aftershock ii": [4,1],
|
||||
"aftershock iii": [0,1],
|
||||
"ahn's citadel": [0,3],
|
||||
"ailith's chimes": [0,2],
|
||||
"alignment i": [3,2],
|
||||
"alignment ii": [4,2],
|
||||
"alignment iii": [5,2],
|
||||
"amanamu's tithe": [0,1],
|
||||
"ambrosia": [2,3],
|
||||
"ambrosia ii": [3,3],
|
||||
"ambush": [1,3],
|
||||
"ammo conservation i": [2,2],
|
||||
"ammo conservation ii": [4,2],
|
||||
"ammo conservation iii": [5,2],
|
||||
"ancestral aid": [0,1],
|
||||
"ancestral call i": [4,1],
|
||||
"ancestral call ii": [5,1],
|
||||
"ancestral call iii": [0,1],
|
||||
"arakaali's lust": [0,2],
|
||||
"arbiter's ignition": [0,3],
|
||||
"arcane surge": [1,3],
|
||||
"arjun's medal": [0,2],
|
||||
"armour break i": [2,1],
|
||||
"armour break ii": [3,1],
|
||||
"armour break iii": [4,1],
|
||||
"armour demolisher i": [1,1],
|
||||
"armour demolisher ii": [3,1],
|
||||
"armour explosion": [1,1],
|
||||
"arms length": [0,1],
|
||||
"astral projection": [3,3],
|
||||
"atalui's bloodletting": [0,1],
|
||||
"atziri's allure": [0,3],
|
||||
"atziri's impatience": [0,2],
|
||||
"auto reload": [0,1],
|
||||
"barbs i": [3,1],
|
||||
"barbs ii": [5,1],
|
||||
"barbs iii": [5,1],
|
||||
"battershout": [1,1],
|
||||
"behead i": [2,1],
|
||||
"behead ii": [3,1],
|
||||
"bhatair's vengeance": [0,3],
|
||||
"bidding i": [2,3],
|
||||
"bidding ii": [4,3],
|
||||
"bidding iii": [5,3],
|
||||
"biting frost i": [3,3],
|
||||
"biting frost ii": [5,3],
|
||||
"blazing critical": [4,3],
|
||||
"bleed i": [1,1],
|
||||
"bleed ii": [3,1],
|
||||
"bleed iii": [5,1],
|
||||
"bleed iv": [5,1],
|
||||
"blind i": [2,2],
|
||||
"blind ii": [5,2],
|
||||
"blindside": [1,2],
|
||||
"bloodlust": [0,1],
|
||||
"bone shrapnel": [2,3],
|
||||
"boundless energy i": [2,3],
|
||||
"boundless energy ii": [4,3],
|
||||
"bounty i": [2,2],
|
||||
"bounty ii": [4,2],
|
||||
"brambleslam": [5,1],
|
||||
"branching fissures i": [3,1],
|
||||
"branching fissures ii": [5,1],
|
||||
"break endurance": [0,1],
|
||||
"break posture": [0,2],
|
||||
"brink i": [2,1],
|
||||
"brink ii": [3,1],
|
||||
"brittle armour": [3,3],
|
||||
"brutality i": [1,1],
|
||||
"brutality ii": [2,1],
|
||||
"brutality iii": [5,1],
|
||||
"brutus' brain": [0,1],
|
||||
"burgeon i": [1,3],
|
||||
"burgeon ii": [4,3],
|
||||
"burning inscription": [4,3],
|
||||
"bursting plague": [2,2],
|
||||
"cadence": [2,2],
|
||||
"caltrops": [3,2],
|
||||
"cannibalism i": [3,1],
|
||||
"cannibalism ii": [4,1],
|
||||
"catalysing elements": [5,3],
|
||||
"catharsis": [2,3],
|
||||
"chain i": [1,2],
|
||||
"chain ii": [2,2],
|
||||
"chain iii": [5,2],
|
||||
"chaos attunement": [3,3],
|
||||
"chaos mastery": [5,3],
|
||||
"chaotic freeze": [3,3],
|
||||
"charge profusion i": [1,2],
|
||||
"charge profusion ii": [5,2],
|
||||
"charged mark": [5,2],
|
||||
"charged shots i": [3,2],
|
||||
"charged shots ii": [5,2],
|
||||
"charm bounty": [0,2],
|
||||
"cirel's cultivation": [0,1],
|
||||
"clarity i": [1,3],
|
||||
"clarity ii": [4,3],
|
||||
"clash": [3,1],
|
||||
"close combat i": [3,2],
|
||||
"close combat ii": [5,2],
|
||||
"cold attunement": [1,3],
|
||||
"cold exposure": [4,3],
|
||||
"cold mastery": [5,3],
|
||||
"cold penetration": [2,3],
|
||||
"combo finisher i": [1,2],
|
||||
"combo finisher ii": [3,2],
|
||||
"commandment": [0,3],
|
||||
"commiserate": [2,2],
|
||||
"compressed duration i": [1,1],
|
||||
"compressed duration ii": [3,1],
|
||||
"concentrated area": [1,3],
|
||||
"concoct i": [2,1],
|
||||
"concoct ii": [4,1],
|
||||
"concussive spells": [4,3],
|
||||
"considered casting": [2,3],
|
||||
"controlled destruction": [1,3],
|
||||
"controlled hazard": [4,2],
|
||||
"cool headed": [3,1],
|
||||
"cooldown recovery i": [3,2],
|
||||
"cooldown recovery ii": [4,2],
|
||||
"corpse conservation": [3,3],
|
||||
"corrosion": [1,2],
|
||||
"corrupting cry i": [3,1],
|
||||
"corrupting cry ii": [5,1],
|
||||
"coursing current": [1,3],
|
||||
"crackling barrier": [2,3],
|
||||
"crater": [2,1],
|
||||
"crazed minions": [4,3],
|
||||
"creeping chill": [1,3],
|
||||
"crescendo i": [1,3],
|
||||
"crescendo ii": [4,3],
|
||||
"crescendo iii": [5,3],
|
||||
"crystalline shards": [1,3],
|
||||
"culling strike i": [4,2],
|
||||
"culling strike ii": [5,2],
|
||||
"culmination i": [3,2],
|
||||
"culmination ii": [5,2],
|
||||
"cursed ground": [3,3],
|
||||
"danse macabre": [2,3],
|
||||
"daresso's passion": [0,1],
|
||||
"dauntless": [0,1],
|
||||
"daze": [0,2],
|
||||
"dazing cry": [0,1],
|
||||
"dazzle": [0,2],
|
||||
"deadly herald": [3,2],
|
||||
"deadly poison i": [2,2],
|
||||
"deadly poison ii": [4,2],
|
||||
"deathmarch": [2,3],
|
||||
"decaying hex": [3,3],
|
||||
"deep cuts i": [3,1],
|
||||
"deep cuts ii": [5,1],
|
||||
"deep freeze": [1,3],
|
||||
"defy i": [2,1],
|
||||
"defy ii": [5,1],
|
||||
"delayed gratification": [2,2],
|
||||
"delayed reaction": [4,2],
|
||||
"deliberation": [3,2],
|
||||
"derange": [4,3],
|
||||
"desperation": [0,1],
|
||||
"devastate": [0,1],
|
||||
"dialla's desire": [0,3],
|
||||
"direstrike i": [2,1],
|
||||
"direstrike ii": [4,1],
|
||||
"doedre's undoing": [0,3],
|
||||
"dominus' grasp": [0,2],
|
||||
"double barrel i": [1,1],
|
||||
"double barrel ii": [2,1],
|
||||
"double barrel iii": [5,1],
|
||||
"drain ailments": [3,3],
|
||||
"durability": [2,2],
|
||||
"echoing cry": [4,1],
|
||||
"efficiency i": [1,1],
|
||||
"efficiency ii": [4,1],
|
||||
"einhar's beastrite": [0,1],
|
||||
"electrocute": [2,2],
|
||||
"electromagnetism": [3,3],
|
||||
"elemental armament i": [1,1],
|
||||
"elemental armament ii": [2,1],
|
||||
"elemental armament iii": [5,1],
|
||||
"elemental army": [1,3],
|
||||
"elemental discharge": [3,3],
|
||||
"elemental focus": [3,3],
|
||||
"embitter": [2,3],
|
||||
"encroaching ground": [2,3],
|
||||
"enduring impact i": [1,1],
|
||||
"enduring impact ii": [4,1],
|
||||
"energy barrier": [0,3],
|
||||
"energy capacitor": [3,3],
|
||||
"energy retention": [3,3],
|
||||
"enraged warcry i": [4,1],
|
||||
"enraged warcry ii": [5,1],
|
||||
"escalating poison": [1,2],
|
||||
"esh's radiance": [0,3],
|
||||
"essence harvest": [3,3],
|
||||
"eternal flame i": [1,1],
|
||||
"eternal flame ii": [3,1],
|
||||
"eternal flame iii": [4,1],
|
||||
"excise": [2,3],
|
||||
"excoriate": [0,2],
|
||||
"execrate": [3,3],
|
||||
"execute i": [1,1],
|
||||
"execute ii": [4,1],
|
||||
"execute iii": [5,1],
|
||||
"expanse": [1,3],
|
||||
"exploit weakness": [2,1],
|
||||
"exposing cry": [4,1],
|
||||
"extraction": [3,3],
|
||||
"fan the flames": [2,1],
|
||||
"fan the flames ii": [4,1],
|
||||
"feeding frenzy i": [3,3],
|
||||
"feeding frenzy ii": [4,3],
|
||||
"ferocity": [0,2],
|
||||
"fiery death": [2,3],
|
||||
"fire attunement": [1,1],
|
||||
"fire exposure": [4,1],
|
||||
"fire mastery": [5,3],
|
||||
"fire penetration i": [2,1],
|
||||
"fire penetration ii": [5,1],
|
||||
"first blood": [0,1],
|
||||
"fist of war i": [1,1],
|
||||
"fist of war ii": [3,1],
|
||||
"fist of war iii": [5,1],
|
||||
"flame pillar": [0,1],
|
||||
"flamepierce": [0,1],
|
||||
"flow": [0,2],
|
||||
"fluke": [3,3],
|
||||
"focused curse": [2,3],
|
||||
"font of blood": [2,1],
|
||||
"font of mana": [2,3],
|
||||
"fork": [3,2],
|
||||
"fortress i": [1,3],
|
||||
"fortress ii": [3,3],
|
||||
"freeze": [2,3],
|
||||
"freezefork": [0,3],
|
||||
"frenzied riposte": [2,2],
|
||||
"fresh clip i": [2,1],
|
||||
"fresh clip ii": [4,1],
|
||||
"frost nexus": [2,3],
|
||||
"frostfire": [3,3],
|
||||
"frozen spite": [2,2],
|
||||
"gambleshot": [3,3],
|
||||
"garukhan's resolve": [0,2],
|
||||
"glacier": [2,3],
|
||||
"haemocrystals": [3,1],
|
||||
"hardy totems i": [2,1],
|
||||
"hardy totems ii": [4,1],
|
||||
"harmonic remnants i": [2,3],
|
||||
"harmonic remnants ii": [3,3],
|
||||
"hayoxi's fulmination": [0,3],
|
||||
"heavy swing": [4,1],
|
||||
"heft": [5,1],
|
||||
"heightened accuracy i": [1,2],
|
||||
"heightened accuracy ii": [3,2],
|
||||
"heightened charges": [2,2],
|
||||
"heightened curse": [1,3],
|
||||
"herbalism i": [2,1],
|
||||
"herbalism ii": [4,1],
|
||||
"hex bloom": [3,3],
|
||||
"hinder": [0,3],
|
||||
"hit and run": [4,2],
|
||||
"hobble": [0,2],
|
||||
"holy descent": [5,1],
|
||||
"hourglass": [1,3],
|
||||
"hulking minions": [4,3],
|
||||
"ice bite i": [1,3],
|
||||
"ice bite ii": [3,3],
|
||||
"icicle": [2,3],
|
||||
"ignite i": [1,1],
|
||||
"ignite ii": [3,1],
|
||||
"ignite iii": [5,1],
|
||||
"immolate": [3,1],
|
||||
"impact shockwave": [1,1],
|
||||
"impale": [4,2],
|
||||
"impending doom": [1,3],
|
||||
"incision": [4,1],
|
||||
"inexorable critical i": [3,3],
|
||||
"inexorable critical ii": [4,3],
|
||||
"infernal legion i": [2,1],
|
||||
"infernal legion ii": [4,1],
|
||||
"infernal legion iii": [5,1],
|
||||
"inhibitor": [2,2],
|
||||
"innervate": [1,2],
|
||||
"intense agony": [2,3],
|
||||
"ixchel's torment": [0,3],
|
||||
"jagged ground i": [2,1],
|
||||
"jagged ground ii": [5,1],
|
||||
"kalisa's crescendo": [0,3],
|
||||
"kaom's madness": [0,1],
|
||||
"khatal's rejuvenation": [0,3],
|
||||
"knockback": [3,1],
|
||||
"kulemak's dominion": [0,3],
|
||||
"kurgal's leash": [0,3],
|
||||
"last gasp": [1,3],
|
||||
"lasting ground": [0,1],
|
||||
"lasting shock": [2,2],
|
||||
"leverage": [0,2],
|
||||
"life bounty": [0,2],
|
||||
"life drain": [1,2],
|
||||
"life leech i": [2,1],
|
||||
"life leech ii": [3,1],
|
||||
"life leech iii": [5,1],
|
||||
"lifetap": [2,1],
|
||||
"lightning attunement": [1,2],
|
||||
"lightning exposure": [4,2],
|
||||
"lightning mastery": [5,3],
|
||||
"lightning penetration": [2,2],
|
||||
"living lightning": [2,3],
|
||||
"living lightning ii": [4,3],
|
||||
"lockdown": [0,2],
|
||||
"long fuse i": [4,1],
|
||||
"long fuse ii": [5,1],
|
||||
"longshot i": [2,2],
|
||||
"longshot ii": [3,2],
|
||||
"loyalty": [2,3],
|
||||
"magnetic remnants": [0,3],
|
||||
"magnified area i": [1,3],
|
||||
"magnified area ii": [3,3],
|
||||
"maim": [2,2],
|
||||
"malady": [0,2],
|
||||
"mana bounty": [0,2],
|
||||
"mana flare": [2,3],
|
||||
"mana leech": [2,3],
|
||||
"mark for death": [2,1],
|
||||
"mark for death ii": [4,1],
|
||||
"mark of siphoning": [1,3],
|
||||
"mark of siphoning ii": [4,3],
|
||||
"meat shield i": [1,1],
|
||||
"meat shield ii": [3,1],
|
||||
"minion instability": [2,3],
|
||||
"minion mastery": [5,3],
|
||||
"minion pact i": [1,3],
|
||||
"minion pact ii": [4,3],
|
||||
"mobility": [3,2],
|
||||
"momentum": [2,2],
|
||||
"morgana's tempest": [0,3],
|
||||
"multishot i": [1,2],
|
||||
"multishot ii": [2,2],
|
||||
"murderous intent": [0,2],
|
||||
"muster": [3,3],
|
||||
"mysticism i": [2,3],
|
||||
"mysticism ii": [4,3],
|
||||
"nadir": [0,3],
|
||||
"neural overload": [3,2],
|
||||
"nimble reload": [4,2],
|
||||
"nova projectiles i": [2,2],
|
||||
"nova projectiles ii": [4,2],
|
||||
"ois\u00edn's oath": [0,3],
|
||||
"opening move": [1,1],
|
||||
"outmaneuver": [0,2],
|
||||
"overabundance i": [1,2],
|
||||
"overabundance ii": [4,2],
|
||||
"overabundance iii": [0,2],
|
||||
"overcharge": [4,2],
|
||||
"overextend": [2,2],
|
||||
"overreach": [2,2],
|
||||
"paquate's pact": [0,1],
|
||||
"payload": [4,2],
|
||||
"perfected endurance": [4,2],
|
||||
"perfection": [4,2],
|
||||
"perpetual charge": [1,3],
|
||||
"persistent ground i": [2,1],
|
||||
"persistent ground ii": [3,1],
|
||||
"persistent ground iii": [5,1],
|
||||
"physical mastery": [5,3],
|
||||
"pierce i": [1,2],
|
||||
"pierce ii": [3,2],
|
||||
"pierce iii": [5,2],
|
||||
"pin i": [1,2],
|
||||
"pin ii": [2,2],
|
||||
"pin iii": [5,2],
|
||||
"pinpoint critical": [2,3],
|
||||
"poison i": [1,2],
|
||||
"poison ii": [3,2],
|
||||
"poison iii": [5,2],
|
||||
"poison spores": [3,2],
|
||||
"potent exposure": [2,3],
|
||||
"potential": [0,3],
|
||||
"practical magic i": [3,3],
|
||||
"practical magic ii": [5,3],
|
||||
"practiced combo": [3,2],
|
||||
"precision i": [1,2],
|
||||
"precision ii": [4,2],
|
||||
"premeditation": [0,1],
|
||||
"profanity i": [1,3],
|
||||
"profanity ii": [5,3],
|
||||
"projectile acceleration i": [1,2],
|
||||
"projectile acceleration ii": [4,2],
|
||||
"projectile acceleration iii": [5,2],
|
||||
"projectile deceleration i": [1,2],
|
||||
"projectile deceleration ii": [3,2],
|
||||
"prolonged duration i": [1,1],
|
||||
"prolonged duration ii": [3,1],
|
||||
"prolonged duration iii": [0,1],
|
||||
"punch through": [3,2],
|
||||
"pursuit i": [1,2],
|
||||
"pursuit ii": [3,2],
|
||||
"pursuit iii": [5,2],
|
||||
"quill burst": [3,1],
|
||||
"rage i": [1,1],
|
||||
"rage ii": [4,1],
|
||||
"rage iii": [5,1],
|
||||
"rageforged i": [4,1],
|
||||
"rageforged ii": [5,1],
|
||||
"raging cry": [2,1],
|
||||
"rakiata's flow": [0,2],
|
||||
"rally": [0,1],
|
||||
"rapid attacks i": [1,2],
|
||||
"rapid attacks ii": [4,2],
|
||||
"rapid attacks iii": [5,2],
|
||||
"rapid casting i": [1,3],
|
||||
"rapid casting ii": [4,3],
|
||||
"rapid casting iii": [5,3],
|
||||
"ratha's assault": [0,2],
|
||||
"rearm i": [3,2],
|
||||
"rearm ii": [5,2],
|
||||
"refraction i": [2,1],
|
||||
"refraction ii": [3,1],
|
||||
"refraction iii": [5,1],
|
||||
"reinforced totems i": [3,1],
|
||||
"reinforced totems ii": [5,1],
|
||||
"relentless rage": [0,1],
|
||||
"remnant potency i": [2,1],
|
||||
"remnant potency ii": [3,1],
|
||||
"remnant potency iii": [4,1],
|
||||
"rending apex": [3,1],
|
||||
"retaliate i": [2,1],
|
||||
"retaliate ii": [4,1],
|
||||
"retreat i": [1,2],
|
||||
"retreat ii": [3,2],
|
||||
"retreat iii": [5,2],
|
||||
"reverberate": [0,1],
|
||||
"ricochet i": [2,2],
|
||||
"ricochet ii": [4,2],
|
||||
"rigwald's ferocity": [0,2],
|
||||
"rime": [0,3],
|
||||
"rip": [3,1],
|
||||
"rising tempest": [2,3],
|
||||
"ritualistic curse": [3,3],
|
||||
"rupture": [3,1],
|
||||
"rusted spikes": [0,1],
|
||||
"ruthless": [0,1],
|
||||
"sacrificial lamb i": [1,3],
|
||||
"sacrificial lamb ii": [3,3],
|
||||
"sacrificial offering": [3,3],
|
||||
"salvo": [3,2],
|
||||
"searing flame i": [3,1],
|
||||
"searing flame ii": [4,1],
|
||||
"second wind i": [2,2],
|
||||
"second wind ii": [4,2],
|
||||
"second wind iii": [5,2],
|
||||
"see red": [0,1],
|
||||
"selfless remnants": [2,1],
|
||||
"shock": [1,2],
|
||||
"shock conduction i": [2,3],
|
||||
"shock conduction ii": [5,3],
|
||||
"shock siphon": [0,3],
|
||||
"shocking leap": [2,2],
|
||||
"short fuse i": [2,1],
|
||||
"short fuse ii": [4,1],
|
||||
"sione's temper": [0,3],
|
||||
"skittering stone i": [2,1],
|
||||
"skittering stone ii": [4,1],
|
||||
"slow potency": [1,2],
|
||||
"soul drain": [3,2],
|
||||
"spar": [0,1],
|
||||
"spectral volley": [4,2],
|
||||
"spell cascade": [1,3],
|
||||
"spell echo": [2,3],
|
||||
"splinter totem i": [3,1],
|
||||
"splinter totem ii": [5,1],
|
||||
"static shocks": [3,3],
|
||||
"steadfast i": [1,1],
|
||||
"steadfast ii": [3,1],
|
||||
"stoicism i": [3,1],
|
||||
"stoicism ii": [5,1],
|
||||
"stomping ground": [1,1],
|
||||
"stormchain": [0,2],
|
||||
"stormfire": [3,3],
|
||||
"streamlined rounds": [3,2],
|
||||
"strong hearted": [3,3],
|
||||
"stun i": [2,1],
|
||||
"stun ii": [4,1],
|
||||
"stun iii": [5,1],
|
||||
"supercritical": [3,3],
|
||||
"swift affliction i": [1,2],
|
||||
"swift affliction ii": [3,2],
|
||||
"swift affliction iii": [5,2],
|
||||
"syzygy": [4,1],
|
||||
"tacati's ire": [0,2],
|
||||
"tasalio's rhythm": [0,2],
|
||||
"tawhoa's tending": [0,1],
|
||||
"tear": [3,1],
|
||||
"tecrod's revenge": [0,3],
|
||||
"tectonic slams": [2,1],
|
||||
"thornskin i": [2,1],
|
||||
"thornskin ii": [4,1],
|
||||
"thrill of the kill": [1,3],
|
||||
"thrill of the kill ii": [3,3],
|
||||
"tireless": [1,1],
|
||||
"tremors": [0,1],
|
||||
"tul's stillness": [0,2],
|
||||
"tumult": [0,2],
|
||||
"uhtred's augury": [0,1],
|
||||
"uhtred's exodus": [0,1],
|
||||
"uhtred's omen": [0,1],
|
||||
"unabating": [0,1],
|
||||
"unbending": [0,3],
|
||||
"unbreakable": [0,1],
|
||||
"undermine": [0,1],
|
||||
"unerring power": [0,2],
|
||||
"unleash": [1,3],
|
||||
"unsteady tempo": [0,1],
|
||||
"untouchable": [0,2],
|
||||
"unyielding": [0,1],
|
||||
"upheaval i": [1,1],
|
||||
"upheaval ii": [5,1],
|
||||
"upwelling i": [2,3],
|
||||
"upwelling ii": [4,3],
|
||||
"urgent totems i": [2,1],
|
||||
"urgent totems ii": [3,1],
|
||||
"urgent totems iii": [4,1],
|
||||
"uruk's smelting": [0,1],
|
||||
"uul-netol's embrace": [0,1],
|
||||
"vanguard i": [1,1],
|
||||
"vanguard ii": [2,1],
|
||||
"varashta's blessing": [0,3],
|
||||
"verglas": [3,3],
|
||||
"vilenta's propulsion": [0,3],
|
||||
"vitality i": [1,1],
|
||||
"vitality ii": [4,1],
|
||||
"volatile power": [0,3],
|
||||
"volatility": [3,3],
|
||||
"volcanic eruption": [2,1],
|
||||
"volt": [2,2],
|
||||
"warm blooded": [3,2],
|
||||
"wildfire": [2,3],
|
||||
"wildshards i": [3,3],
|
||||
"wildshards ii": [5,3],
|
||||
"wind wave": [0,2],
|
||||
"window of opportunity i": [1,2],
|
||||
"window of opportunity ii": [5,2],
|
||||
"withering touch": [1,3],
|
||||
"xibaqua's rending": [0,3],
|
||||
"xoph's pyre": [0,1],
|
||||
"zarokh's refrain": [0,3],
|
||||
"zarokh's revolt": [0,3],
|
||||
"zenith i": [1,3],
|
||||
"zenith ii": [3,3],
|
||||
"zerphi's infamy": [0,1]
|
||||
}
|
||||
}
|
||||
794
src-tauri/data/guide2.json
Normal file
@ -0,0 +1,794 @@
|
||||
[
|
||||
[
|
||||
[
|
||||
"kill the_bloated_miller",
|
||||
"enter areaidg1_town ;; clearfell encampment"
|
||||
],
|
||||
[
|
||||
"(color:red)info: optional rewards (img:exa) (img:skill) (img:rune) (img:jeweller)",
|
||||
"(hint)__ to include them, enable (quest:optionals) toggle",
|
||||
"(color:lime)ready_for_0.5:_includes_pre-release_info",
|
||||
"(hint)__ (color:yellow)old/unconfirmed_clues_are_marked:_??",
|
||||
"how to: use world-map for guidance",
|
||||
"(img:quest_2) renly: (img:skill) || enter areaidg1_2 ;; clearfell"
|
||||
],
|
||||
[
|
||||
"optional: (img:skill) in (img:checkpoint) (color:cc99ff)camp || transmute: (color:cc99ff)league",
|
||||
"optional: (img:skill2) arena:boss in areaidg1_3, (img:support2) (img:quest_2) renly ;; mud burrow",
|
||||
"kill (img:checkpoint) beira || enter (img:checkpoint) areaidg1_4 ;; the grelwood"
|
||||
],
|
||||
[
|
||||
"optional: transmute: (color:cc99ff)league",
|
||||
"optional: (img:flasks) + (img:support) <witch>: (img:checkpoint) (color:cc99ff)hut || (img:skill) arena:bramble",
|
||||
"(color:ff00ff)follow_2: glow-roots to get (img:waypoint) ,",
|
||||
"(hint)__ (color:aqua)mushrooms to (img:in-out2) areaidg1_6: (img:waypoint) ;; the grim tangle",
|
||||
"follow river upstream: (img:checkpoint) areaidg1_5 ;; the red vale"
|
||||
],
|
||||
[
|
||||
"optional: (img:skill) (color:cc99ff)league || atk weapons: (color:cc99ff)racks",
|
||||
"clear (quest:3_obelisks) for (quest:3_runes)",
|
||||
"(img:portal) to areaidg1_town ;; clearfell encampment"
|
||||
],
|
||||
[
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:quest_2) renly: (quest:runed_spikes)",
|
||||
"(img:waypoint) to areaidg1_4 ;; the grelwood"
|
||||
],
|
||||
[
|
||||
"break the (color:cc99ff)3_runic_seals",
|
||||
"(img:portal) to areaidg1_town ;; clearfell encampment"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) una",
|
||||
"(img:waypoint) to areaidg1_6 ;; the grim tangle"
|
||||
],
|
||||
[
|
||||
"optional: (img:skill2) (color:cc99ff)league || (img:support) arena:rotten_druid",
|
||||
"find ?aldur vault?",
|
||||
"(hint)_ - (img:quest_2) (quest:league): unlocks (color:cc99ff)ward forging",
|
||||
"(hint)_ - to be safe, check the quest-log",
|
||||
"enter (img:checkpoint) areaidg1_7 ;; cemetery of the eternals"
|
||||
],
|
||||
[
|
||||
"activate the (img:waypoint) and (img:checkpoint)",
|
||||
"optional: (img:regal) (color:cc99ff)league || (img:ring) in (img:checkpoint) (color:cc99ff)ruin (edge?)",
|
||||
"enter (img:checkpoint) areaidg1_9 ;; tomb of the consort"
|
||||
],
|
||||
[
|
||||
"optional: amulet: (color:cc99ff)league",
|
||||
"optional: (img:support) <knight> in (img:checkpoint) (color:cc99ff)treasure",
|
||||
"kill (img:checkpoint) asinia for (quest:key_piece)",
|
||||
"enter areaidg1_7 ;; cemetery of the eternals"
|
||||
],
|
||||
[
|
||||
"optional: (img:regal) (color:cc99ff)league || (img:ring) in (img:checkpoint) (color:cc99ff)ruin (edge?)",
|
||||
"enter (img:checkpoint) areaidg1_8 ;; mausoleum of the praetor"
|
||||
],
|
||||
[
|
||||
"optional: random (img:rune): (color:cc99ff)league",
|
||||
"kill (img:checkpoint) draven for (quest:key_piece)",
|
||||
"enter areaidg1_7 ;; cemetery of the eternals"
|
||||
],
|
||||
{
|
||||
"condition": [
|
||||
"league-start",
|
||||
"yes"
|
||||
],
|
||||
"lines": [
|
||||
"(img:checkpoint) -travel to (img:waypoint), open (color:cc99ff)gate",
|
||||
"(hint)__ when it opens, (color:cc99ff)esc:_respawn",
|
||||
"kill lachlann for (quest:ring)",
|
||||
"(img:portal) areaidg1_town ;; clearfell encampment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"condition": [
|
||||
"league-start",
|
||||
"yes"
|
||||
],
|
||||
"lines": [
|
||||
"(img:quest_2) una: (quest:hooded_one)",
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:waypoint) to areaidg1_11 ;; hunting grounds"
|
||||
]
|
||||
},
|
||||
{
|
||||
"condition": [
|
||||
"league-start",
|
||||
"no"
|
||||
],
|
||||
"lines": [
|
||||
"(img:checkpoint) -travel to (img:waypoint), open (color:cc99ff)gate",
|
||||
"(hint)__ when it opens, (color:cc99ff)esc:_respawn",
|
||||
"kill lachlann || leave (quest:ring)",
|
||||
"enter areaidg1_11 ;; hunting grounds"
|
||||
]
|
||||
},
|
||||
[
|
||||
"optional: (img:exa) (color:cc99ff)league || (img:support) <dryad> (edge?)",
|
||||
"clear (img:checkpoint) (color:cc99ff)ritual (near center?)",
|
||||
"(hint)__ on the way, look for (color:aqua)tracks or (color:cc99ff)road",
|
||||
"follow cleared ritual: areaidg1_12 ;; freythorn",
|
||||
"(hint)__ keep looking for (color:aqua)tracks or (color:cc99ff)road"
|
||||
],
|
||||
[
|
||||
"(img:waypoint) to areaidg1_11 ;; hunting grounds"
|
||||
],
|
||||
[
|
||||
"(img:checkpoint) to (color:cc99ff)ritual, (color:ff00ff)find/follow_2:",
|
||||
"(hint)_ - (color:aqua)tracks: kill (img:checkpoint) crowbell (quest:(book))",
|
||||
"(hint)_ - road+sign: get (img:checkpoint) at the end",
|
||||
"enter (img:checkpoint) areaidg1_13_1 ;; ogham farmlands"
|
||||
],
|
||||
[
|
||||
"optional: (img:skill2) (color:cc99ff)league || (img:skill2) in (img:checkpoint) (color:cc99ff)crop_circle",
|
||||
"?road leads straight? to (img:checkpoint) (color:cc99ff)hut (quest:(lute))",
|
||||
"find & enter (img:checkpoint) areaidg1_13_2 ;; ogham village",
|
||||
"(hint)__ ?one of the roads after (color:cc99ff)hut leads there?"
|
||||
],
|
||||
[
|
||||
"(img:waypoint) to areaidg1_town (img:town) ;; clearfell encampment",
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:quest_2) una: (quest:book) || (img:waypoint) to areaidg1_12 ;; freythorn"
|
||||
],
|
||||
[
|
||||
"optional: (img:support) (color:cc99ff)league",
|
||||
"find & clear (color:cc99ff)3 (img:checkpoint) (color:cc99ff)rituals",
|
||||
"(hint)__ cleared rituals point to the next",
|
||||
"clear (img:checkpoint) arena:boss_ritual (quest:(skull))",
|
||||
"(img:portal) to areaidg1_town ;; clearfell encampment"
|
||||
],
|
||||
[
|
||||
"leaguestart: (img:quest_2) finn: (color:cc99ff)ele-res_charm",
|
||||
"(img:waypoint) to areaidg1_13_2 ;; ogham village"
|
||||
],
|
||||
[
|
||||
"leaguestart: optional: (img:artificer) (color:cc99ff)league",
|
||||
"leaguestart: follow road: (img:checkpoint) (color:cc99ff)workshop",
|
||||
"(hint)__ get (quest:tools) + <chest>: (img:artificer) + (img:b-rune)",
|
||||
"twinkrun: optional: (img:artificer) (color:cc99ff)league || (img:b-rune) + (img:artificer) in (img:checkpoint) (color:cc99ff)shop",
|
||||
"(hint)__ follow road to (img:checkpoint) (color:cc99ff)workshop",
|
||||
"?follow road?: (img:checkpoint) arena:executioner",
|
||||
"leaguestart: (img:quest_2) leitis, (img:portal) areaidg1_town ;; clearfell encampment",
|
||||
"twinkrun: (img:quest_2) leitis || enter areaidg1_14 ;; manor ramparts"
|
||||
],
|
||||
{
|
||||
"condition": [
|
||||
"league-start",
|
||||
"yes"
|
||||
],
|
||||
"lines": [
|
||||
"(img:quest_2) leitis: (img:skill) || (img:quest_2) renly: (img:rune) + (quest:bench)",
|
||||
"optional: check vendors",
|
||||
"(img:waypoint) to areaidg1_14 ;; manor ramparts"
|
||||
]
|
||||
},
|
||||
[
|
||||
"optional: (img:skill2) (color:cc99ff)league || (img:support) in (img:checkpoint) (color:cc99ff)gallows (edge?)",
|
||||
"inner edge leads to areaidg1_15 ;; ogham manor"
|
||||
],
|
||||
[
|
||||
"optional: alchemy orb: (color:cc99ff)league",
|
||||
"floor 1: kill (img:checkpoint) candlemass",
|
||||
"floor 3: kill (img:checkpoint) geonor",
|
||||
"leaguestart: enter areaidg1_town ;; clearfell encampment",
|
||||
"twinkrun: (img:portal) areaidg1_town ;; clearfell encampment"
|
||||
],
|
||||
[
|
||||
"leaguestart: optional: check vendors",
|
||||
"twinkrun: (img:quest_2) leitis: (img:skill)",
|
||||
"twinkrun: optional: (img:quest_2) renly: (img:rune)",
|
||||
"(img:quest_2) hooded one: areaidg2_1 ;; vastiri outskirts"
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
"optional: ? (img:exa) / (img:spirit2) ? (color:cc99ff)league || (img:support2) in (img:checkpoint) (color:cc99ff)raider_camp",
|
||||
"find & kill (img:checkpoint) rathbreaker || (img:portal) out",
|
||||
"(hint)__ ?search for long passage along edge?",
|
||||
"(img:quest_2) zarka: (img:skill) || enter areaidg2_town ;; the ardura caravan"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) hooded one",
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:quest_2) asala",
|
||||
"(color:cc99ff)desert_map: areaidg2_10_1 ;; mawdun quarry"
|
||||
],
|
||||
[
|
||||
"optional: ? (img:spirit2) / (img:exa) ? (color:cc99ff)league || (img:artificer) in (img:checkpoint) (color:cc99ff)cache (edge?)",
|
||||
"reach (img:checkpoint) areaidg2_10_2 ;; mawdun mine"
|
||||
],
|
||||
[
|
||||
"optional: (img:support) (color:cc99ff)league",
|
||||
"find & kill (img:checkpoint) rudja",
|
||||
"(hint)__ ?usually (img:0) / (img:1) in the zone?",
|
||||
"(img:quest_2) risu || (img:portal) areaidg2_town ;; the ardura caravan"
|
||||
],
|
||||
[
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:quest_2) risu || (img:quest_2) asala",
|
||||
"(color:cc99ff)desert_map: areaidg2_2 ;; traitor's passage"
|
||||
],
|
||||
[
|
||||
"optional: (img:artificer) (color:cc99ff)league || (img:skill2) in (img:checkpoint) (color:cc99ff)bell_chest",
|
||||
"reach (img:checkpoint) intersection",
|
||||
"(quest:ascend) early?: follow (color:aqua)parchment",
|
||||
"(hint)__ kill (img:checkpoint) balbala for (quest:trial_key)",
|
||||
"follow blank walls: areaidg2_3 ;; the halani gates"
|
||||
],
|
||||
[
|
||||
"optional: (img:exa) (color:cc99ff)league || <atk_weapon> in (img:checkpoint) (color:cc99ff)tent",
|
||||
"kill jamanra || use (img:arena) stairs",
|
||||
"find (img:checkpoint) || (img:portal) areaidg2_town ;; the ardura caravan"
|
||||
],
|
||||
[
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:quest_2) zarka: (img:skill) || (img:quest_2) asala",
|
||||
"if you want to (quest:ascend) early:",
|
||||
"(hint)__ (color:cc99ff)desert_map: areaidg2_13 ;; trial of the sekhemas",
|
||||
"(color:cc99ff)desert_map: areaidg2_4_1 ;; keth"
|
||||
],
|
||||
[
|
||||
"optional: (img:gcp) (color:cc99ff)league || (color:66b2ff)amulet: ?check 2 (img:arena) stairs?",
|
||||
"(color:ff00ff)kill_2: (color:cc99ff)snake_mobs (color:lime)(relic) || (img:checkpoint) arena:kabala",
|
||||
"(hint)__ arena:kabala: near areas with more (color:cc99ff)snakes",
|
||||
"find & enter (img:checkpoint) areaidg2_4_2 ;; the lost city"
|
||||
],
|
||||
[
|
||||
"optional: alch orb: (color:cc99ff)league",
|
||||
"optional: (img:spirit2) in (img:checkpoint) (color:cc99ff)tomb || (color:66b2ff)jewel from <scarab>",
|
||||
"enter (img:checkpoint) areaidg2_4_3 ;; buried shrines",
|
||||
"(hint)_ - ?1st corridor shows gen. direction?",
|
||||
"(hint)_ - ?find (img:checkpoint) (color:aqua)v-shaped_bridge along edge?"
|
||||
],
|
||||
[
|
||||
"optional: (img:jeweller) (color:cc99ff)league || (color:66b2ff)res (img:ring) + (img:rune) in (img:checkpoint) (color:cc99ff)offering",
|
||||
"find (img:arena) heart_of_keth, kill azarian",
|
||||
"(img:quest_2) halani: (quest:cinders) || (img:quest_2) halani: (quest:essence)",
|
||||
"(img:portal) to areaidg2_town ;; the ardura caravan"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) zarka: (img:support)",
|
||||
"leaguestart: optional: check vendors",
|
||||
"(color:cc99ff)desert_map: areaidg2_5_1 ;; mastodon badlands"
|
||||
],
|
||||
[
|
||||
"optional: (img:regal) (color:cc99ff)league || (img:support) in (img:checkpoint) (color:cc99ff)fossil || j-bone: (color:cc99ff)abyss",
|
||||
"(img:in-out2) lightless_passage for (img:waypoint)",
|
||||
"(hint)__ follow cracks in ground || clear this &",
|
||||
"(hint)__ the follow-up if you want to (quest:abyss_craft)",
|
||||
"enter (img:checkpoint) areaidg2_5_2 ;; the bone pits"
|
||||
],
|
||||
[
|
||||
"optional: (img:exa) (color:cc99ff)league",
|
||||
"(color:ff00ff)kill_2: (color:cc99ff)hyenas (color:lime)(relic) || arena:ekbab (color:lime)(tusks)",
|
||||
"(hint)__ arena:ekbab: ?near areas with more (color:cc99ff)hyenas?",
|
||||
"(img:portal) to areaidg2_town ;; the ardura caravan"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) zarka: (img:support)",
|
||||
"leaguestart: optional: check vendors",
|
||||
"(color:cc99ff)desert_map: areaidg2_6 ;; valley of the titans"
|
||||
],
|
||||
[
|
||||
"optional: (color:ff8111)unique_item: (color:cc99ff)league || rib: (color:cc99ff)abyss",
|
||||
"traverse (color:cc99ff)3_canyons for (quest:3) (img:checkpoint) (quest:seals)",
|
||||
"?find (img:waypoint): insert (quest:relics) nearby?",
|
||||
"enter areaidg2_7 ;; the titan grotto"
|
||||
],
|
||||
[
|
||||
"optional: chance shard: (color:cc99ff)league",
|
||||
"optional: random (img:rune) in (img:checkpoint) (color:cc99ff)titan_sword",
|
||||
"kill zalmarath for (quest:ruby)",
|
||||
"(img:portal) to areaidg2_town ;; the ardura caravan"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) zarka: (img:support) + (quest:horn) || (img:quest_2) asala",
|
||||
"(color:cc99ff)desert_map: areaidg2_2 ;; traitor's passage",
|
||||
"(img:quest_2) (color:cc99ff)sound_the_horn",
|
||||
"(img:quest_2) asala || (color:cc99ff)desert_map: areaidg2_8 ;; deshar"
|
||||
],
|
||||
[
|
||||
"optional: (img:artificer) ?in (img:checkpoint) (color:cc99ff)path nearby? || (img:rune) (color:cc99ff)league",
|
||||
"late (quest:ascension?): find (img:checkpoint) (color:cc99ff)corpses",
|
||||
"(hint)__ kill (color:yellow)2_vultures for (color:cc99ff)djinn_barya",
|
||||
"find (quest:letter) on (img:quest_2) (quest:fallen_dekhara)",
|
||||
"enter (img:checkpoint) areaidg2_9_1 ;; path of mourning"
|
||||
],
|
||||
[
|
||||
"leaguestart: optional: dmg issues?: (img:waypoint) to town",
|
||||
"(hint)__ give (quest:letter) to (color:cc99ff)shambrin: (quest:book)",
|
||||
"optional: (img:support) + <items> in (img:checkpoint) (color:cc99ff)hushed_urn",
|
||||
"reach (img:checkpoint) areaidg2_9_2 ;; the spires of deshar"
|
||||
],
|
||||
[
|
||||
"optional: (img:gcp) (color:cc99ff)league",
|
||||
"(color:ff00ff)2_tasks: (color:cc99ff)sisters_statue || arena:tor_gul",
|
||||
"(hint)_ - (img:checkpoint) (color:cc99ff)statue: ?g-pattern on the map?",
|
||||
"(hint)_ - (img:checkpoint) arena:tor_gul: ?far end of the zone?",
|
||||
"(img:portal) to areaidg2_town ;; the ardura caravan"
|
||||
],
|
||||
[
|
||||
"leaguestart: optional: check vendors",
|
||||
"give (quest:letter) to (color:cc99ff)shambrin: (quest:book)",
|
||||
"late (quest:ascension?): (img:quest_2) zarka || (img:quest_2) asala",
|
||||
"(hint)__ (color:cc99ff)desert_map: areaidg2_13 ;; trial of the sekhemas",
|
||||
"(color:cc99ff)desert_map: areaidg2_12_1 ;; the dreadnought"
|
||||
],
|
||||
[
|
||||
"kill jamanra || (img:quest_2) asala",
|
||||
"(img:portal) to areaidg2_town ;; the ardura caravan"
|
||||
],
|
||||
[
|
||||
"go (img:7): (img:quest_2) hooded one",
|
||||
"(hint)_ - wait for transformation",
|
||||
"(hint)_ - (color:cc99ff)esc:_character_selection",
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:quest_2) asala: areaidg3_1 ;; sandswept marsh"
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
"optional: (img:skill2) arena:rootdredge || (img:ring) in (img:checkpoint) (color:cc99ff)hang._tree",
|
||||
"optional: (img:support2) (color:cc99ff)league || (img:jeweller) in (img:checkpoint) (color:cc99ff)camp ?near exit?",
|
||||
"reach (img:checkpoint) areaidg3_town ;; ziggurat encampment"
|
||||
],
|
||||
[
|
||||
"leaguestart: (img:quest_2) oswald",
|
||||
"leaguestart: optional: check vendors",
|
||||
"enter areaidg3_3 ;; jungle ruins"
|
||||
],
|
||||
[
|
||||
"optional: alchemy orb: (color:cc99ff)league",
|
||||
"optional: <belt>: (img:checkpoint) (color:cc99ff)grave || <glove>: (img:checkpoint) (color:cc99ff)camp chest",
|
||||
"(color:ff00ff)2_tasks: get (img:waypoint) || (img:checkpoint) arena:monkey (center)",
|
||||
"(hint)__ on the way, look around for (color:cc99ff)snakes",
|
||||
"follow (color:cc99ff)snakes to (img:checkpoint) areaidg3_4 ;; venom crypts"
|
||||
],
|
||||
[
|
||||
"optional: (img:ring) (color:cc99ff)league || (img:support) in (img:checkpoint) (color:cc99ff)crypt",
|
||||
"optional: collarbone: (color:cc99ff)abyss",
|
||||
"find (img:checkpoint) (quest:corpse) for (quest:venom)",
|
||||
"(hint)__ near areas with more (color:cc99ff)humans",
|
||||
"use (img:arena) exit to areaidg3_3 ;; jungle ruins"
|
||||
],
|
||||
[
|
||||
"optional: alchemy orb: (color:cc99ff)league",
|
||||
"optional: <belt>: (img:checkpoint) (color:cc99ff)grave || <glove>: (img:checkpoint) (color:cc99ff)camp chest",
|
||||
"enter (img:checkpoint) areaidg3_2_1 nearby ;; infested barrens"
|
||||
],
|
||||
[
|
||||
"optional: (img:exa) (color:cc99ff)league || <boots>: (img:checkpoint) (color:cc99ff)camp chest",
|
||||
"(hint)__ npc in (color:cc99ff)camp can reveal (img:arena) exit",
|
||||
"find & enter (img:checkpoint) (color:fec076)mystic_refuge",
|
||||
"(hint)__ (img:quest_2) (quest:league): unlocks (color:ff8111)unique forging",
|
||||
"enter (img:checkpoint) areaidg3_5 ;; chimeral wetlands"
|
||||
],
|
||||
[
|
||||
"(img:waypoint) to areaidg3_town (img:town) ;; ziggurat encampment"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) servi: (color:lime)perma_buff + (img:artificer)",
|
||||
"(hint)__ (color:ff0000)cannot_be_changed_later",
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:waypoint) to areaidg3_5 ;; chimeral wetlands"
|
||||
],
|
||||
[
|
||||
"optional: (img:skill) (color:cc99ff)league || <helm>: (img:checkpoint) (color:cc99ff)camp chest",
|
||||
"optional: (color:66b2ff)amulet: (img:checkpoint) (color:cc99ff)toxic_bloom",
|
||||
"(color:ff00ff)find_2: (img:checkpoint) arena:chimera near (img:arena) exit,",
|
||||
"(hint)__ (img:in-out2) areaidg3_10_airlock for (img:waypoint) ;; temple of chaos",
|
||||
"enter areaidg3_6_1 ;; jiquani's machinarium"
|
||||
],
|
||||
[
|
||||
"optional: (img:artificer) (color:cc99ff)league",
|
||||
"find (quest:core) for (color:cc99ff)altar || (quest:2_cores) for:",
|
||||
"(hint)_ - (img:checkpoint) arena:blackjaw: ?room on side edge?",
|
||||
"(hint)_ - (img:checkpoint) (color:cc99ff)locked_exit: follow red line",
|
||||
"enter areaidg3_6_2 ;; jiquani's sanctum"
|
||||
],
|
||||
[
|
||||
"optional: (img:exa) (color:cc99ff)league || (img:checkpoint) (color:cc99ff)corruption_altar",
|
||||
"find (quest:2_cores), start (quest:2_gens.) (v-shape?)",
|
||||
"(hint)__ then (color:cc99ff)esc:_respawn, (img:checkpoint) -travel to start",
|
||||
"activate (quest:core) || kill zicoatl for (quest:core)",
|
||||
"(img:waypoint) to areaidg3_3 ;; jungle ruins"
|
||||
],
|
||||
[
|
||||
"activate (img:quest_2) (color:cc99ff)stone_altar",
|
||||
"enter areaidg3_2_2 ;; the matlan waterways"
|
||||
],
|
||||
[
|
||||
"optional: (img:spirit2) (color:cc99ff)league || <caster_weap>: (img:checkpoint) (color:cc99ff)hut",
|
||||
"reach (color:cc99ff)reservoir_mechanism",
|
||||
"enter (img:checkpoint) areaidg3_7 ;; azak bog"
|
||||
],
|
||||
[
|
||||
"optional: (img:rune) (color:cc99ff)league || (img:quest_2) (color:cc99ff)flame_ritual",
|
||||
"(hint)__ temp (color:red)25%_fire_res, ?near center?",
|
||||
"kill (img:checkpoint) ignagduk for (quest:2_items)",
|
||||
"(img:portal) to areaidg3_town ;; ziggurat encampment"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) servi: (color:66b2ff)ailment_charm",
|
||||
"leaguestart: optional: check vendors",
|
||||
"(quest:ascension): (img:waypoint) to areaidg3_10_airlock ;; temple of chaos",
|
||||
"(hint)__ (color:cc99ff)build-dependent: can be done later",
|
||||
"go (img:3): (img:quest_2) alva, enter areaidg3_8 ;; the drowned city"
|
||||
],
|
||||
[
|
||||
"optional: (img:support) (color:cc99ff)league",
|
||||
"leaguestart: (img:in-out2) areaidg3_9 for (img:waypoint) ;; the molten vault",
|
||||
"reach (img:checkpoint) areaidg3_11 ;; apex of filth"
|
||||
],
|
||||
[
|
||||
"optional: vaal orb: (color:cc99ff)league",
|
||||
"optional: qual flasks in (img:checkpoint) (color:cc99ff)cauldron",
|
||||
"(hint)__ find/loot (quest:3_mushrooms)",
|
||||
"kill queen_of_filth for (quest:idol)",
|
||||
"(img:portal) to areaidg3_town ;; ziggurat encampment"
|
||||
],
|
||||
[
|
||||
"leaguestart: (img:waypoint) to areaidg3_9, kill mektul ;; the molten vault",
|
||||
"(hint)_ - (color:ff8111)item: (color:cc99ff)league || (img:quest_2) oswald: (quest:reforge), (img:skill), (img:artificer)",
|
||||
"(hint)_ - skippable for later if preferred",
|
||||
"go (img:3), open (color:cc99ff)door: areaidg3_12 ;; temple of kopec"
|
||||
],
|
||||
[
|
||||
"optional: (img:spirit2) (color:cc99ff)league",
|
||||
"find corner (img:arena) stairs, kill ketzuli",
|
||||
"(img:quest_2) alva: areaidg3_town ;; ziggurat encampment",
|
||||
"(hint)__ opt. skip: (img:portal) when she says \"wait\""
|
||||
],
|
||||
[
|
||||
"enter the (color:cc99ff)gateway",
|
||||
"follow stairs to areaidg3_14 ;; utzaal"
|
||||
],
|
||||
[
|
||||
"optional: (time-lost) jewel: (color:cc99ff)league",
|
||||
"(color:ff00ff)kill_2: (color:cc99ff)goliaths (color:lime)(heart) || (img:checkpoint) arena:napuatzi",
|
||||
"(hint)_ - (quest:heart) also drops next zone: 2nd half",
|
||||
"(hint)_ - war-cry sound leads to arena:napuatzi",
|
||||
"enter (img:checkpoint) areaidg3_16 ;; aggorat"
|
||||
],
|
||||
[
|
||||
"optional: (img:skill2) (color:cc99ff)league",
|
||||
"find plaza and (img:checkpoint) || farm (quest:heart)?",
|
||||
"?go (img:0) ?, use (quest:heart) in (img:checkpoint) (color:cc99ff)sacrifice",
|
||||
"find & enter (img:checkpoint) areaidg3_17 ;; the black chambers",
|
||||
"(hint)__ ?straight (img:6) or (img:2) of (color:cc99ff)sacrifice?"
|
||||
],
|
||||
[
|
||||
"optional: vaal orb: (color:cc99ff)league",
|
||||
"find & kill (img:checkpoint) doryani",
|
||||
"(hint)__ ?zone: mazes connected by bridges?",
|
||||
"(img:portal) to areaidg3_town ;; ziggurat encampment"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) doryani: (quest:cut-scene)",
|
||||
"(img:quest_2) doryani",
|
||||
"(img:quest_2) alva: (color:cc99ff)travel_to areaidg4_town ;; kingsmarch"
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
"(color:red)info: seasonal (quest:quest) rotations",
|
||||
"(hint)__ every arena:boss-fight has \"free (img:quest_2) matiki\"",
|
||||
"(img:quest_2) doryani || (img:quest_2) alva: (quest:charter)",
|
||||
"leaguestart: optional: check vendors || extra vendors in:",
|
||||
"(hint)__ areaidg4_8a: <jewelry/caster>, areaidg4_11_1a: <atk> ;; arastas ;; ngakanu",
|
||||
"(img:quest_2) makoru (ship): areaidg4_1_1 ;; isle of kin"
|
||||
],
|
||||
[
|
||||
"optional: (img:gcp) (color:cc99ff)league || (img:skill) (img:support2) in (img:checkpoint) (color:cc99ff)beast_pen",
|
||||
"optional: (img:jeweller) in (img:checkpoint) (color:cc99ff)delve || (img:quest_2) (quest:map): (img:checkpoint) (color:cc99ff)sailor",
|
||||
"optional: greater (img:b-rune) from (img:checkpoint) arena:blind_beast",
|
||||
"reach (img:checkpoint) areaidg4_1_2 ;; volcanic warrens"
|
||||
],
|
||||
[
|
||||
"optional: (img:support) (color:cc99ff)league || (color:ff0000)fire / (color:ffff00)light (img:ring) in (img:checkpoint) (color:cc99ff)nest",
|
||||
"(hint)_ - element = (color:cc99ff)golem_killed_last",
|
||||
"(hint)_ - has qual, can roll multiple ele-mods",
|
||||
"follow lava upstream to (img:checkpoint) arena:krutog",
|
||||
"free (img:quest_2) matiki || makoru (ship): areaidg4_2_1 ;; kedge bay"
|
||||
],
|
||||
[
|
||||
"optional: (img:exa) (color:cc99ff)league || (img:quest_2) (quest:map): (img:checkpoint) (color:cc99ff)stash",
|
||||
"optional: alch orb: (img:checkpoint) (color:cc99ff)tidal_cay",
|
||||
"reach areaidg4_2_2 ;; journey's end"
|
||||
],
|
||||
[
|
||||
"optional: alch orb: (color:cc99ff)league",
|
||||
"activate the (img:waypoint) _|| (img:quest_2) tujen",
|
||||
"kill (img:checkpoint) hartlin || (img:portal) areaidg4_town ;; kingsmarch"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) dannig: (quest:verisium_spikes)",
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:waypoint) to areaidg4_2_2 ;; journey's end"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) freya || kill omniphobia",
|
||||
"makoru (ship): areaidg4_3_1 ;; whakapanu island",
|
||||
"(img:portal) to areaidg4_town ;; kingsmarch"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) tujen: (quest:book) + (img:skill)",
|
||||
"enter (img:portal) areaidg4_3_1 ;; whakapanu island"
|
||||
],
|
||||
[
|
||||
"optional: (img:artificer) (color:cc99ff)league || (img:support) in (img:checkpoint) (color:cc99ff)crab_cavern",
|
||||
"optional: ?0.5?: (img:checkpoint) arena:shark || (img:quest_2) (quest:map): (img:checkpoint) (color:cc99ff)pirate",
|
||||
"(hint)__ hand in (quest:shark_fin) in areaidg4_11_1a ;; ngakanu",
|
||||
"reach (img:checkpoint) areaidg4_3_2 ;; singing caverns"
|
||||
],
|
||||
[
|
||||
"optional: (color:66b2ff)charm: (color:cc99ff)league",
|
||||
"optional: <all-res_amu>: get (quest:pearl) in (img:checkpoint) (color:cc99ff)clam",
|
||||
"(hint)__ bring it to (img:quest_2) rog in areaidg4_town (img:town) ;; kingsmarch",
|
||||
"kill (img:checkpoint) diamora || free (img:quest_2) matiki",
|
||||
"makoru (ship): areaidg4_5_1 ;; abandoned prison"
|
||||
],
|
||||
[
|
||||
"optional: (img:exa) (color:cc99ff)league",
|
||||
"(quest:buff): 30% inc. flask recovery",
|
||||
"(hint)_ - kill (color:cc99ff)necromancers for (quest:key)",
|
||||
"(hint)_ - find (img:checkpoint) (color:cc99ff)chapel, activate (quest:statue)",
|
||||
"enter (img:checkpoint) areaidg4_5_2 ;; solitary confinement"
|
||||
],
|
||||
[
|
||||
"optional: random (img:rune): (color:cc99ff)league",
|
||||
"kill (img:checkpoint) prisoner || free (img:quest_2) matiki",
|
||||
"makoru (ship): areaidg4_7 ;; shrike island"
|
||||
],
|
||||
[
|
||||
"optional: (img:support) (color:cc99ff)league || (img:quest_2) (quest:map): (img:checkpoint) (color:cc99ff)corpse_nest",
|
||||
"kill (img:checkpoint) scourge_of_the_skies",
|
||||
"free (img:quest_2) matiki || (img:portal) areaidg4_town ;; kingsmarch"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) hooded one",
|
||||
"leaguestart: optional: check vendors",
|
||||
"optional: makoru (ship): areaidg4_13 ;; plunder's point",
|
||||
"(hint)__ requires 4 (quest:map_pieces)",
|
||||
"makoru (ship): areaidg4_4_1 ;; eye of hinekora"
|
||||
],
|
||||
[
|
||||
"optional: chaos orb: (color:cc99ff)league",
|
||||
"(img:quest_2) matiki || (img:quest_2) (color:cc99ff)well || complete 3 tests",
|
||||
"(hint)__ (img:spirit2): (color:cc99ff)waterfall between 2nd/3rd test",
|
||||
"(img:quest_2) (color:lime)pay_respects in (img:checkpoint) (color:cc99ff)silent_hall",
|
||||
"enter (img:checkpoint) areaidg4_4_2 ;; halls of the dead"
|
||||
],
|
||||
[
|
||||
"optional: random (img:rune): (color:cc99ff)league",
|
||||
"complete 3 (img:checkpoint) (color:cc99ff)tests for (quest:tattoos)",
|
||||
"(hint)__ (img:quest_2) totems: turn in (quest:tattoos)",
|
||||
"defeat (img:checkpoint) (color:ff8111)yama for (quest:silver_coin)",
|
||||
"enter areaidg4_4_3 ;; trial of the ancestors"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) navali: (quest:tattoo_of_hinekora)",
|
||||
"makoru (ship): areaidg4_8b ;; arastas (hostile)"
|
||||
],
|
||||
[
|
||||
"follow (img:quest_2) lorandis || go outside",
|
||||
"optional: (img:skill2) (color:cc99ff)league || 3 (img:exa) + 3 (img:regal) in (color:cc99ff)2 (img:checkpoint) (color:cc99ff)bells",
|
||||
"kill torvian || enter areaidg4_10 ;; the excavation"
|
||||
],
|
||||
[
|
||||
"optional: <amulet>: (color:cc99ff)league",
|
||||
"kill (img:checkpoint) benedictus || enter site",
|
||||
"(color:cc99ff)cut-scene || (img:portal) to areaidg4_town ;; kingsmarch",
|
||||
"(hint)__ (color:cc99ff)skip: when quest says \"speak to",
|
||||
"(hint)__ the hooded one,\" (color:cc99ff)esc:_respawn"
|
||||
],
|
||||
[
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:quest_2) rhodri (ship): areaidg4_11_1b ;; ngakanu"
|
||||
],
|
||||
[
|
||||
"greater (img:jeweller): (color:cc99ff)league",
|
||||
"reach (img:checkpoint) areaidg4_11_2 ;; heart of the tribe"
|
||||
],
|
||||
[
|
||||
"optional: (img:spirit2) (color:cc99ff)league",
|
||||
"find & defeat arena:tavakai",
|
||||
"(img:quest_2) tavakai || (img:portal) areaidg4_town ;; kingsmarch"
|
||||
],
|
||||
[
|
||||
"(color:yellow)updated:_non-linear_interludes",
|
||||
"(hint)__ new order prioritizes (quest:points/buffs)",
|
||||
"(color:red)if_you're_using_the_timer:",
|
||||
"(hint)__ (img:quest_2) hooded one: (color:cc99ff)travel_to_ogham",
|
||||
"(hint)__ (so the timer stays synced up)",
|
||||
"(img:quest_2) hooded one: (color:cc99ff)travel_to_vastiri",
|
||||
"enter areaidp2_town ;; the khari bazaar"
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
"leaguestart: optional: check vendors",
|
||||
"go (img:1) to areaidp2_1 ;; the khari crossing"
|
||||
],
|
||||
[
|
||||
"optional: (img:gcp) (color:cc99ff)league || <jewelry/caster> (color:cc99ff)vendor",
|
||||
"(hint)__ (color:cc99ff)vendor: south between the next 2 (img:checkpoint)",
|
||||
"go (img:0) + (img:7) to (img:checkpoint) (color:cc99ff)stairway: get (quest:gift)",
|
||||
"(color:cc99ff)esc:_respawn",
|
||||
"follow (img:6) edge to (img:checkpoint) areaidp2_5 ;; galai gates"
|
||||
],
|
||||
[
|
||||
"activate (img:waypoint)",
|
||||
"go back to areaidp2_1 ;; the khari crossing"
|
||||
],
|
||||
[
|
||||
"optional: (img:gcp) (color:cc99ff)league",
|
||||
"(img:checkpoint) -travel to (color:cc99ff)town_entrance",
|
||||
"go (img:2), kill (img:checkpoint) anundr_&_akthi",
|
||||
"(img:portal) to areaidp2_town ;; khari bazaar"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) risu: (quest:book)",
|
||||
"go (img:5) to areaidp2_1 ;; the khari crossing"
|
||||
],
|
||||
[
|
||||
"go (img:6) to (img:checkpoint) areaidp2_2 ;; pools of khatal"
|
||||
],
|
||||
[
|
||||
"(img:waypoint) to areaidp2_town (img:town) ;; khari bazaar",
|
||||
"(img:quest_2) hooded one: (color:cc99ff)travel_to_kriar",
|
||||
"enter areaidp3_town ;; the glade"
|
||||
],
|
||||
[
|
||||
"enter areaidp3_1 ;; ashen forest"
|
||||
],
|
||||
[
|
||||
"optional: <belt>: (color:cc99ff)league || (img:skill) in (img:checkpoint) (color:cc99ff)monument",
|
||||
"reach (img:checkpoint) areaidp3_2 ;; kriar village"
|
||||
],
|
||||
[
|
||||
"optional: greater (img:rune): (color:cc99ff)league",
|
||||
"kill (img:checkpoint) lythara for (quest:skull)",
|
||||
"enter areaidp3_3 ;; glacial tarn"
|
||||
],
|
||||
[
|
||||
"optional: greater augment: (color:cc99ff)league",
|
||||
"follow (img:2) edge: (img:checkpoint) areaidp3_4 ;; howling caves"
|
||||
],
|
||||
[
|
||||
"optional: chaos orb: (color:cc99ff)league",
|
||||
"kill (img:checkpoint) yeti (color:lime)(tusks) || (img:portal) areaidp3_town ;; the glade"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) hilda: (quest:book)",
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:waypoint) to areaidp3_3 ;; glacial tarn"
|
||||
],
|
||||
[
|
||||
"optional: greater augment: (color:cc99ff)league",
|
||||
"follow (img:7) edge, kill (img:checkpoint) rakkar",
|
||||
"enter areaidp3_5 ;; kriar peaks"
|
||||
],
|
||||
[
|
||||
"optional: greater transmute: (color:cc99ff)league",
|
||||
"optional: (color:ff8111)item from (img:quest_2) elder madox",
|
||||
"(hint)__ passage marked by (color:cc99ff)owls (edge)",
|
||||
"reach (img:checkpoint) areaidp3_6 ;; etched ravine"
|
||||
],
|
||||
[
|
||||
"optional: (img:exa) (color:cc99ff)league",
|
||||
"reach areaidp3_7 ;; the cuachic vault"
|
||||
],
|
||||
[
|
||||
"optional: vaal: (color:cc99ff)league || locked vaults (quest:(cores))",
|
||||
"kill (img:checkpoint) zolin_&_zelina",
|
||||
"(img:quest_2) doryani || (img:portal) areaidp3_town ;; the glade"
|
||||
],
|
||||
[
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:waypoint) to areaidp2_2 ;; pools of khatal"
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
"optional: alch orb: (color:cc99ff)league",
|
||||
"reach (img:checkpoint) areaidp2_3 ;; sel khari sanctuary"
|
||||
],
|
||||
[
|
||||
"optional: chance orb: (color:cc99ff)league",
|
||||
"optional: <ring/amu/jewel>: loot (quest:2_baryas)",
|
||||
"(hint)__ put into (img:checkpoint) (color:cc99ff)pedestals (side edges)",
|
||||
"find & kill (img:checkpoint) elzarah",
|
||||
"(img:quest_2) asala || (img:portal) to areaidp2_town ;; the khari bazaar"
|
||||
],
|
||||
[
|
||||
"(img:waypoint) to areaidp2_5 ;; the galai gates"
|
||||
],
|
||||
[
|
||||
"optional: greater augment: (color:cc99ff)league",
|
||||
"kill (img:checkpoint) vornas || enter areaidp2_6 ;; qimah"
|
||||
],
|
||||
[
|
||||
"optional: (img:exa) (color:cc99ff)league || (img:checkpoint) strongboxes",
|
||||
"(quest:buff): (img:checkpoint) (color:cc99ff)7_pillars (corners/edges)",
|
||||
"(hint)_ - follow (img:0) edge to the (img:arena) (color:cc99ff)exit",
|
||||
"(hint)_ - not there? it's (img:3) of (img:arena) exit_/_start",
|
||||
"(img:quest_2) jado || enter (img:checkpoint) areaidp2_7 ;; qimah reservoir"
|
||||
],
|
||||
[
|
||||
"optional: greater transmute: (color:cc99ff)league",
|
||||
"optional: currency: find (quest:2_vials) + (color:cc99ff)2 (img:checkpoint) (color:cc99ff)wells",
|
||||
"(hint)__ 1st drops, 2nd in (img:checkpoint) (color:cc99ff)side-room <chest>",
|
||||
"kill (img:checkpoint) azmadi || (img:quest_2) grand barya",
|
||||
"(img:quest_2) jado || (img:portal) to areaidp2_town ;; the khari bazaar"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) hooded one: (color:cc99ff)travel_to_ogham",
|
||||
"(color:red)timer-users: (img:waypoint) to areaidp1_town ;; the refuge"
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
"leaguestart: optional: check vendors",
|
||||
"enter (img:checkpoint) areaidp1_1 ;; scorched farmlands"
|
||||
],
|
||||
[
|
||||
"optional: (img:skill) on (img:checkpoint) (color:cc99ff)corpse by road || (img:support) (color:cc99ff)league",
|
||||
"get the (img:checkpoint) next to (color:cc99ff)wall_of_darkness",
|
||||
"kill witches, enter (img:checkpoint) areaidp1_2 ;; stones of serle"
|
||||
],
|
||||
[
|
||||
"optional: (img:exa) (color:cc99ff)league",
|
||||
"find 6 (img:checkpoint) (color:cc99ff)megaliths || kill siora",
|
||||
"(img:quest_2) una",
|
||||
"go back to areaidp1_1 ;; scorched farmlands",
|
||||
"(hint)__ (color:red)wait_for_quest-state: \"return to\""
|
||||
],
|
||||
[
|
||||
"(img:checkpoint) -travel to (color:cc99ff)wall_of_darkness",
|
||||
"enter (img:checkpoint) areaidp1_3 ;; the blackwood"
|
||||
],
|
||||
[
|
||||
"optional: omens in (color:cc99ff)omen_altars",
|
||||
"optional: greater transmute: (color:cc99ff)league",
|
||||
"reach (img:checkpoint) areaidp1_4 ;; holten"
|
||||
],
|
||||
[
|
||||
"optional: greater (img:rune): (color:cc99ff)league",
|
||||
"optional: (img:checkpoint) (color:cc99ff)rune_vendor: (img:6) of (color:cc99ff)bridge",
|
||||
"(hint)__ (color:cc99ff)bridge is on way to areaidp1_5 ;; wolvenhold",
|
||||
"follow (img:1) edge: (img:checkpoint) areaidp1_5 ;; wolvenhold"
|
||||
],
|
||||
[
|
||||
"optional: greater augment: (color:cc99ff)league",
|
||||
"kill (img:checkpoint) oswin for (quest:book)",
|
||||
"(hint)__ usually (img:1) or (img:7)",
|
||||
"go back to (img:checkpoint) areaidp1_4 ;; holten"
|
||||
],
|
||||
[
|
||||
"optional: greater (img:rune): (color:cc99ff)league",
|
||||
"(img:0) edge leads to (img:checkpoint) areaidp1_6 ;; holten estate"
|
||||
],
|
||||
[
|
||||
"optional: (img:artificer) (color:cc99ff)league",
|
||||
"kill (img:checkpoint) elswyth_&_wulfric",
|
||||
"(img:portal) to areaidp1_town ;; the refuge"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) renly",
|
||||
"leaguestart: optional: check vendors",
|
||||
"(img:waypoint) to areaidg4_town ;; kingsmarch"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) hooded one: (quest:book)",
|
||||
"(img:quest_2) hooded one: (color:cc99ff)travel_to_oriath",
|
||||
"enter areaidg_endgame_town ;; the ziggurat refuge"
|
||||
],
|
||||
[
|
||||
"(img:quest_2) alva || (img:quest_2) doryani",
|
||||
"leaguestart: use the (color:cc99ff)map_device",
|
||||
"<the_act-tracker_ends_here>"
|
||||
]
|
||||
]
|
||||
]
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
212
src-tauri/src/config.rs
Normal file
@ -0,0 +1,212 @@
|
||||
use crate::timer::RunTimer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A manually-created profile bound to one in-game character (matched by name
|
||||
/// against the log). Each profile keeps its own guide progress, branch and timer.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct Profile {
|
||||
pub name: String,
|
||||
pub current_step: usize,
|
||||
pub league_start: bool,
|
||||
/// Show optional loot/quests/encounters (lines prefixed "optional:").
|
||||
pub show_optionals: bool,
|
||||
/// Per-profile campaign timer (with its own per-act splits).
|
||||
pub timer: RunTimer,
|
||||
}
|
||||
|
||||
impl Default for Profile {
|
||||
fn default() -> Self {
|
||||
Profile {
|
||||
name: String::new(),
|
||||
current_step: 0,
|
||||
league_start: true,
|
||||
show_optionals: true,
|
||||
timer: RunTimer::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistent user configuration. Stored as JSON in the platform config dir.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// Absolute path to the game's Client.txt log file.
|
||||
pub log_path: Option<String>,
|
||||
/// Substring used to detect whether the game window is focused (matched against the active window title).
|
||||
pub poe_window_match: String,
|
||||
/// Only show overlays while the game window is focused.
|
||||
pub overlay_only_when_focused: bool,
|
||||
|
||||
// --- leveling tracker overlay geometry (logical pixels) ---
|
||||
pub overlay_x: i32,
|
||||
pub overlay_y: i32,
|
||||
pub overlay_width: u32,
|
||||
pub overlay_font_size: u32,
|
||||
|
||||
// --- global hotkeys (accelerator strings, e.g. "Alt+X") ---
|
||||
pub hotkey_next: String,
|
||||
pub hotkey_prev: String,
|
||||
pub hotkey_toggle: String,
|
||||
pub hotkey_timer_pause: String,
|
||||
pub hotkey_layout: String,
|
||||
|
||||
// --- zone layout viewer (interactive overlay, ported from the AHK act-decoder) ---
|
||||
/// Enable the zone-layout viewer window + its hotkey.
|
||||
pub feature_layouts: bool,
|
||||
/// Saved position of the layout window (logical pixels).
|
||||
pub layout_x: i32,
|
||||
pub layout_y: i32,
|
||||
/// Size (px) of the main layout image rendered in the viewer.
|
||||
pub layout_size: u32,
|
||||
|
||||
// --- campaign timer ---
|
||||
pub timer_enabled: bool,
|
||||
pub timer_pause_in_town: bool,
|
||||
/// Auto-pause the timer after no mouse movement for `timer_afk_seconds`.
|
||||
pub timer_afk_enabled: bool,
|
||||
pub timer_afk_seconds: u32,
|
||||
/// Auto-pause the timer while the game window isn't focused.
|
||||
pub timer_pause_unfocused: bool,
|
||||
|
||||
// --- leveling tracker state ---
|
||||
/// Live step for the active character (mirrors `progress[active_character]`).
|
||||
pub current_step: usize,
|
||||
/// Show optional loot/quests (mirrors the active profile's `show_optionals`).
|
||||
pub show_optionals: bool,
|
||||
/// Follow the league-start branch of the guide (vs. the non-league-start branch).
|
||||
pub league_start: bool,
|
||||
/// How many upcoming steps to display below the current one.
|
||||
pub lookahead: usize,
|
||||
/// Show the per-area level recommendations in the overlay.
|
||||
pub show_recommendation: bool,
|
||||
|
||||
// --- character profiles & per-profile progress ---
|
||||
/// Name of the character whose profile is currently active.
|
||||
pub active_character: Option<String>,
|
||||
/// Manually-created character profiles.
|
||||
pub profiles: Vec<Profile>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
log_path: None,
|
||||
poe_window_match: "Path of Exile".to_string(),
|
||||
overlay_only_when_focused: true,
|
||||
overlay_x: 40,
|
||||
overlay_y: 120,
|
||||
overlay_width: 460,
|
||||
overlay_font_size: 15,
|
||||
hotkey_next: "Alt+X".to_string(),
|
||||
hotkey_prev: "Alt+Z".to_string(),
|
||||
hotkey_toggle: "Alt+Shift+X".to_string(),
|
||||
hotkey_timer_pause: "Alt+P".to_string(),
|
||||
hotkey_layout: "Alt+C".to_string(),
|
||||
feature_layouts: true,
|
||||
layout_x: 600,
|
||||
layout_y: 160,
|
||||
layout_size: 360,
|
||||
timer_enabled: true,
|
||||
timer_pause_in_town: true,
|
||||
timer_afk_enabled: true,
|
||||
timer_afk_seconds: 60,
|
||||
timer_pause_unfocused: true,
|
||||
current_step: 0,
|
||||
show_optionals: true,
|
||||
league_start: true,
|
||||
lookahead: 2,
|
||||
show_recommendation: true,
|
||||
active_character: None,
|
||||
profiles: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Create a profile for `name` if none exists. Returns true if it was added.
|
||||
pub fn create_profile(&mut self, name: &str, league_start: bool) -> bool {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || self.profiles.iter().any(|p| p.name == name) {
|
||||
return false;
|
||||
}
|
||||
self.profiles.push(Profile {
|
||||
name: name.to_string(),
|
||||
current_step: 0,
|
||||
league_start,
|
||||
show_optionals: true,
|
||||
timer: RunTimer::default(),
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove a profile by name. If it was active, clears the active character.
|
||||
pub fn delete_profile(&mut self, name: &str) {
|
||||
self.profiles.retain(|p| p.name != name);
|
||||
if self.active_character.as_deref() == Some(name) {
|
||||
self.active_character = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `name` the active profile, loading its step and branch into the live
|
||||
/// fields. Returns true if the active profile changed (caller may need to
|
||||
/// rebuild the guide if the league-start branch differs).
|
||||
pub fn select_profile(&mut self, name: &str) -> bool {
|
||||
if self.active_character.as_deref() == Some(name) {
|
||||
return false;
|
||||
}
|
||||
// Persist the outgoing profile's live state first.
|
||||
self.sync_progress();
|
||||
let Some(p) = self.profiles.iter().find(|p| p.name == name) else {
|
||||
return false;
|
||||
};
|
||||
self.current_step = p.current_step;
|
||||
self.league_start = p.league_start;
|
||||
self.show_optionals = p.show_optionals;
|
||||
self.active_character = Some(name.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
/// Mirror the live step/branch/optionals back into the active profile so they persist.
|
||||
pub fn sync_progress(&mut self) {
|
||||
let (step, league, optionals) = (self.current_step, self.league_start, self.show_optionals);
|
||||
if let Some(a) = self.active_character.clone() {
|
||||
if let Some(p) = self.profiles.iter_mut().find(|p| p.name == a) {
|
||||
p.current_step = step;
|
||||
p.league_start = league;
|
||||
p.show_optionals = optionals;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn config_dir() -> PathBuf {
|
||||
let mut dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
dir.push("exile-ui");
|
||||
dir
|
||||
}
|
||||
|
||||
pub fn config_path() -> PathBuf {
|
||||
let mut p = config_dir();
|
||||
p.push("config.json");
|
||||
p
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Config {
|
||||
let path = config_path();
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
|
||||
Err(_) => Config::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let dir = config_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
if let Ok(text) = serde_json::to_string_pretty(self) {
|
||||
let _ = std::fs::write(config_path(), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
224
src-tauri/src/leveltracker.rs
Normal file
@ -0,0 +1,224 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Game data is embedded at compile time. These files come from the original
|
||||
// Lailloken/Exile-UI data set (PoE2 variants).
|
||||
const AREAS_JSON: &str = include_str!("../data/areas2.json");
|
||||
const GUIDE_JSON: &str = include_str!("../data/guide2.json");
|
||||
const GEMS_JSON: &str = include_str!("../data/gems2.json");
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct Area {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Recommended character level when entering this area (raw "min | max" string).
|
||||
pub recommendation: Option<String>,
|
||||
/// Index of the area group this belongs to (acts: 0..n).
|
||||
pub group: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct Step {
|
||||
/// Lines of the step, in the original markup language.
|
||||
pub lines: Vec<String>,
|
||||
/// Guide section index (roughly corresponds to an act).
|
||||
pub section: usize,
|
||||
/// Area the player should travel to during this step, if detectable.
|
||||
pub target_area: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct GuideData {
|
||||
pub steps: Vec<Step>,
|
||||
/// areaID -> Area metadata.
|
||||
pub areas: HashMap<String, Area>,
|
||||
/// Ordered list of area-group "names" (first travelable area id of each group) for labels.
|
||||
pub section_count: usize,
|
||||
}
|
||||
|
||||
/// Resolve the area an `areaid…` token points at within a step.
|
||||
fn extract_target_area(lines: &[String]) -> Option<String> {
|
||||
let mut last: Option<String> = None;
|
||||
let mut preferred: Option<String> = None;
|
||||
for line in lines {
|
||||
let lower = line.to_lowercase();
|
||||
let is_travel = lower.contains("enter ")
|
||||
|| lower.contains("waypoint")
|
||||
|| lower.contains("portal")
|
||||
|| lower.contains("to areaid");
|
||||
let mut search = line.as_str();
|
||||
while let Some(pos) = search.find("areaid") {
|
||||
let rest = &search[pos + "areaid".len()..];
|
||||
let id: String = rest
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
|
||||
.collect();
|
||||
if !id.is_empty() {
|
||||
last = Some(id.clone());
|
||||
if is_travel {
|
||||
preferred = Some(id);
|
||||
}
|
||||
}
|
||||
// advance past this token
|
||||
let consumed = pos + "areaid".len() + rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').count();
|
||||
if consumed >= search.len() {
|
||||
break;
|
||||
}
|
||||
search = &search[consumed..];
|
||||
}
|
||||
}
|
||||
preferred.or(last)
|
||||
}
|
||||
|
||||
/// Normalize a guide entry into (lines, optional condition (key, value)).
|
||||
fn parse_entry(entry: &serde_json::Value) -> (Vec<String>, Option<(String, String)>) {
|
||||
if let Some(arr) = entry.as_array() {
|
||||
let lines = arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
(lines, None)
|
||||
} else if let Some(obj) = entry.as_object() {
|
||||
let lines = obj
|
||||
.get("lines")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let condition = obj.get("condition").and_then(|v| v.as_array()).and_then(|a| {
|
||||
match (a.first().and_then(|x| x.as_str()), a.get(1).and_then(|x| x.as_str())) {
|
||||
(Some(k), Some(v)) => Some((k.to_string(), v.to_string())),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
(lines, condition)
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(league_start: bool) -> GuideData {
|
||||
// --- areas ---
|
||||
let mut areas: HashMap<String, Area> = HashMap::new();
|
||||
if let Ok(groups) = serde_json::from_str::<Vec<Vec<serde_json::Value>>>(AREAS_JSON) {
|
||||
for (gi, group) in groups.iter().enumerate() {
|
||||
for a in group {
|
||||
let id = a.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = a.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let recommendation = a
|
||||
.get("recommendation")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
areas.insert(
|
||||
id.clone(),
|
||||
Area {
|
||||
id,
|
||||
name,
|
||||
recommendation,
|
||||
group: gi,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- guide ---
|
||||
// Structure: [ section[ entry, ... ], ... ] where each entry is either an
|
||||
// array of line strings, or an object { "condition": [key, value], "lines": [...] }.
|
||||
// Conditional entries currently only encode "league-start" branches.
|
||||
let mut steps: Vec<Step> = Vec::new();
|
||||
let mut section_count = 0;
|
||||
if let Ok(sections) = serde_json::from_str::<Vec<Vec<serde_json::Value>>>(GUIDE_JSON) {
|
||||
section_count = sections.len();
|
||||
for (si, section) in sections.into_iter().enumerate() {
|
||||
for entry in section {
|
||||
let (lines, condition) = parse_entry(&entry);
|
||||
// Skip the branch that doesn't apply to the chosen play mode.
|
||||
if let Some((key, val)) = &condition {
|
||||
if key == "league-start" {
|
||||
let wants_yes = val == "yes";
|
||||
if wants_yes != league_start {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if lines.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let target_area = extract_target_area(&lines);
|
||||
steps.push(Step {
|
||||
lines,
|
||||
section: si,
|
||||
target_area,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GuideData {
|
||||
steps,
|
||||
areas,
|
||||
section_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw gems data (skill/spirit/support -> name -> [level, quality-ish]) passed to the UI as-is.
|
||||
pub fn gems_raw() -> serde_json::Value {
|
||||
serde_json::from_str(GEMS_JSON).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_travel_target() {
|
||||
let lines = vec![
|
||||
"kill some_boss".to_string(),
|
||||
"(img:portal) to areaidg1_town ;; clearfell encampment".to_string(),
|
||||
];
|
||||
assert_eq!(extract_target_area(&lines).as_deref(), Some("g1_town"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_travel_line_over_mention() {
|
||||
let lines = vec![
|
||||
"optional: boss in areaidg1_3".to_string(),
|
||||
"enter areaidg1_4 ;; the grelwood".to_string(),
|
||||
];
|
||||
assert_eq!(extract_target_area(&lines).as_deref(), Some("g1_4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_and_areas_load() {
|
||||
let g = load(true);
|
||||
assert!(!g.steps.is_empty(), "guide steps should load");
|
||||
assert!(g.areas.contains_key("g1_1"), "areas should contain g1_1");
|
||||
assert!(g.section_count >= 6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the step index whose target area equals `area_id`, preferring the match
|
||||
/// closest to `current`. Returns None if no step targets that area.
|
||||
pub fn step_for_area(steps: &[Step], area_id: &str, current: usize) -> Option<usize> {
|
||||
let mut best: Option<usize> = None;
|
||||
let mut best_dist = usize::MAX;
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
if let Some(t) = &step.target_area {
|
||||
if t == area_id {
|
||||
let dist = if i >= current { i - current } else { current - i };
|
||||
if dist < best_dist {
|
||||
best_dist = dist;
|
||||
best = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
766
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,766 @@
|
||||
mod config;
|
||||
mod leveltracker;
|
||||
mod logwatch;
|
||||
mod poe;
|
||||
mod timer;
|
||||
|
||||
use config::Config;
|
||||
use leveltracker::GuideData;
|
||||
use timer::RunTimer;
|
||||
use serde::Serialize;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalSize, WebviewUrl,
|
||||
WebviewWindowBuilder,
|
||||
};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
|
||||
/// Live game state derived from the client log.
|
||||
#[derive(Serialize, Clone, Default)]
|
||||
pub struct Status {
|
||||
pub character: String,
|
||||
pub class: String,
|
||||
pub level: u32,
|
||||
pub area_id: String,
|
||||
pub area_name: String,
|
||||
pub area_level: u32,
|
||||
pub area_seed: String,
|
||||
pub act: usize,
|
||||
pub game_focused: bool,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<Config>,
|
||||
pub guide: Mutex<GuideData>,
|
||||
pub status: Mutex<Status>,
|
||||
pub timer: Mutex<RunTimer>,
|
||||
// While the user drags the layout window by its title bar: Some((dx, dy)) is
|
||||
// the offset from the window's top-left to the cursor; a background thread
|
||||
// follows the mouse via xdotool (Tauri/KWin won't reposition this window).
|
||||
pub layout_drag: Mutex<Option<(i32, i32)>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config(state: tauri::State<AppState>) -> Config {
|
||||
state.config.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_config(app: AppHandle, state: tauri::State<AppState>, new: Config) {
|
||||
let league_changed = {
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
let changed = cfg.league_start != new.league_start;
|
||||
*cfg = new;
|
||||
cfg.sync_progress(); // keep the active profile in sync with live fields
|
||||
cfg.save();
|
||||
changed
|
||||
};
|
||||
|
||||
if league_changed {
|
||||
rebuild_guide(&app);
|
||||
}
|
||||
|
||||
register_shortcuts(&app);
|
||||
apply_overlay_geometry(&app);
|
||||
let cfg = state.config.lock().unwrap().clone();
|
||||
let _ = app.emit("config://update", cfg);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_status(state: tauri::State<AppState>) -> Status {
|
||||
state.status.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_guide(state: tauri::State<AppState>) -> GuideData {
|
||||
state.guide.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_gems() -> serde_json::Value {
|
||||
leveltracker::gems_raw()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn detect_logs() -> Vec<String> {
|
||||
poe::detect_log_paths()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_timer(state: tauri::State<AppState>) -> RunTimer {
|
||||
state.timer.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn timer_start(app: AppHandle, state: tauri::State<AppState>) {
|
||||
let act = state.status.lock().unwrap().act.max(1);
|
||||
let name = now_string();
|
||||
{
|
||||
let mut t = state.timer.lock().unwrap();
|
||||
t.start(act, name);
|
||||
}
|
||||
store_live_timer(&state);
|
||||
emit_timer(&app);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn timer_pause(app: AppHandle, state: tauri::State<AppState>) {
|
||||
{
|
||||
let mut t = state.timer.lock().unwrap();
|
||||
t.toggle_pause();
|
||||
}
|
||||
store_live_timer(&state);
|
||||
emit_timer(&app);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn timer_reset(app: AppHandle, state: tauri::State<AppState>) {
|
||||
state.timer.lock().unwrap().reset();
|
||||
store_live_timer(&state);
|
||||
emit_timer(&app);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn step_delta(app: AppHandle, state: tauri::State<AppState>, delta: i64) {
|
||||
let max = state.guide.lock().unwrap().steps.len().saturating_sub(1);
|
||||
let new = {
|
||||
let cur = state.config.lock().unwrap().current_step as i64;
|
||||
(cur + delta).clamp(0, max as i64) as usize
|
||||
};
|
||||
set_step(&app, new);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn step_goto(app: AppHandle, new: usize) {
|
||||
set_step(&app, new);
|
||||
}
|
||||
|
||||
// --- character profiles ---
|
||||
|
||||
#[tauri::command]
|
||||
fn create_profile(app: AppHandle, state: tauri::State<AppState>, name: String, league_start: bool) {
|
||||
let added = {
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
let added = cfg.create_profile(&name, league_start);
|
||||
if added {
|
||||
cfg.save();
|
||||
}
|
||||
added
|
||||
};
|
||||
if added {
|
||||
// Newly created profiles become the active one.
|
||||
select_profile(app, state, name);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_profile(app: AppHandle, state: tauri::State<AppState>, name: String) {
|
||||
{
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
cfg.delete_profile(&name);
|
||||
cfg.save();
|
||||
}
|
||||
let cfg = state.config.lock().unwrap().clone();
|
||||
let _ = app.emit("config://update", cfg);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_profile(app: AppHandle, state: tauri::State<AppState>, name: String) {
|
||||
// Bank the outgoing profile's live timer before switching.
|
||||
store_live_timer(&state);
|
||||
|
||||
let league_changed = {
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
let prev_league = cfg.league_start;
|
||||
let switched = cfg.select_profile(&name);
|
||||
if switched {
|
||||
cfg.save();
|
||||
}
|
||||
switched && cfg.league_start != prev_league
|
||||
};
|
||||
|
||||
// Load the incoming profile's timer into the live state.
|
||||
{
|
||||
let new_timer = {
|
||||
let cfg = state.config.lock().unwrap();
|
||||
cfg.active_character
|
||||
.as_ref()
|
||||
.and_then(|a| cfg.profiles.iter().find(|p| &p.name == a))
|
||||
.map(|p| p.timer.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
*state.timer.lock().unwrap() = new_timer;
|
||||
}
|
||||
|
||||
if league_changed {
|
||||
rebuild_guide(&app);
|
||||
}
|
||||
let (step, cfg) = {
|
||||
let cfg = state.config.lock().unwrap();
|
||||
(cfg.current_step, cfg.clone())
|
||||
};
|
||||
let _ = app.emit("tracker://step", step);
|
||||
let _ = app.emit("config://update", cfg);
|
||||
emit_timer(&app);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_overlay_locked(app: AppHandle, locked: bool) {
|
||||
if let Some(w) = app.get_webview_window("overlay") {
|
||||
let _ = w.set_ignore_cursor_events(locked);
|
||||
if locked {
|
||||
// Removing the drag bar on re-lock shifts the content up, and the
|
||||
// transparent WebKitGTK surface doesn't clear its previous frame,
|
||||
// leaving a ghost copy of the old layout (hide/show doesn't fix it —
|
||||
// WebKit keeps its backing buffer across map cycles). Forcing a real
|
||||
// resize reallocates and clears that surface. Two synchronous
|
||||
// set_size calls would be coalesced by GTK (the window never truly
|
||||
// changes size), so grow by 1px now and shrink back after a GTK loop
|
||||
// cycle. A resize keeps the top-left corner, so the dragged position
|
||||
// is preserved (unlike hide/show, which lets the WM re-place it).
|
||||
if let Ok(sz) = w.inner_size() {
|
||||
let _ = w.set_size(PhysicalSize::new(sz.width, sz.height + 1));
|
||||
let app2 = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
if let Some(w2) = app2.get_webview_window("overlay") {
|
||||
let _ = w2.set_size(sz);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = app.emit("overlay://locked", locked);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn toggle_overlay(app: AppHandle) {
|
||||
// Visibility is a frontend concern; just notify the overlay view.
|
||||
let _ = app.emit("overlay://toggle", ());
|
||||
}
|
||||
|
||||
/// Show/hide the interactive zone-layout viewer window. It is a real (focusable,
|
||||
/// non-click-through) window, so visibility is the OS window's own show/hide. The
|
||||
/// view itself polls the current area (its `listen` subscription doesn't fire
|
||||
/// while the window is created hidden), so showing it is enough to see the zone.
|
||||
#[tauri::command]
|
||||
fn toggle_layout(app: AppHandle, state: tauri::State<AppState>) {
|
||||
if !state.config.lock().unwrap().feature_layouts {
|
||||
return;
|
||||
}
|
||||
if let Some(w) = app.get_webview_window("layout") {
|
||||
if w.is_visible().unwrap_or(false) {
|
||||
persist_layout_position(&app, &state);
|
||||
let _ = w.hide();
|
||||
} else {
|
||||
let (x, y) = {
|
||||
let cfg = state.config.lock().unwrap();
|
||||
(cfg.layout_x, cfg.layout_y)
|
||||
};
|
||||
let _ = w.set_position(LogicalPosition::new(x, y));
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
// KWin tends to clamp/centre a freshly shown decorationless window;
|
||||
// force the saved position via xdotool, same as the overlay.
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(60));
|
||||
let _ = std::process::Command::new("xdotool")
|
||||
.args([
|
||||
"search",
|
||||
"--name",
|
||||
"Exile UI Layouts",
|
||||
"windowmove",
|
||||
&x.to_string(),
|
||||
&y.to_string(),
|
||||
])
|
||||
.status();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the layout window to fit its rendered content (called by the frontend
|
||||
/// whenever the viewer's content size changes).
|
||||
#[tauri::command]
|
||||
fn set_layout_size(app: AppHandle, width: u32, height: u32) {
|
||||
if let Some(w) = app.get_webview_window("layout") {
|
||||
let _ = w.set_size(LogicalSize::new(width.max(80), height.max(80)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the layout window's current position into config (called by the
|
||||
/// frontend after the user drags it).
|
||||
#[tauri::command]
|
||||
fn save_layout_geometry(app: AppHandle, state: tauri::State<AppState>) {
|
||||
persist_layout_position(&app, &state);
|
||||
}
|
||||
|
||||
/// Begin dragging the layout window by its title bar. The frontend's
|
||||
/// `data-tauri-drag-region` / `setPosition` don't move this window under KWin, so
|
||||
/// a background thread follows the mouse with xdotool until `end_layout_drag`.
|
||||
#[tauri::command]
|
||||
fn start_layout_drag(app: AppHandle, state: tauri::State<AppState>) {
|
||||
let Some(w) = app.get_webview_window("layout") else {
|
||||
return;
|
||||
};
|
||||
let Some((mx, my)) = crate::poe::mouse_position() else {
|
||||
return;
|
||||
};
|
||||
let Ok(pos) = w.outer_position() else {
|
||||
return;
|
||||
};
|
||||
// Cursor offset within the window (physical px == screen px at scale 1).
|
||||
*state.layout_drag.lock().unwrap() = Some((mx - pos.x, my - pos.y));
|
||||
|
||||
// Resolve the X window id once so the loop only spawns one xdotool per tick.
|
||||
let id = std::process::Command::new("xdotool")
|
||||
.args(["search", "--name", "Exile UI Layouts"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.last()
|
||||
.map(|s| s.trim().to_string())
|
||||
});
|
||||
let Some(id) = id.filter(|s| !s.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
let off = match *app.state::<AppState>().layout_drag.lock().unwrap() {
|
||||
Some(o) => o,
|
||||
None => break,
|
||||
};
|
||||
// Safety net so a missed pointerup can't pin the window to the cursor.
|
||||
if start.elapsed().as_secs() > 30 {
|
||||
*app.state::<AppState>().layout_drag.lock().unwrap() = None;
|
||||
break;
|
||||
}
|
||||
if let Some((mx, my)) = crate::poe::mouse_position() {
|
||||
let _ = std::process::Command::new("xdotool")
|
||||
.args([
|
||||
"windowmove",
|
||||
&id,
|
||||
&(mx - off.0).to_string(),
|
||||
&(my - off.1).to_string(),
|
||||
])
|
||||
.status();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(16));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop dragging the layout window and persist its final position.
|
||||
#[tauri::command]
|
||||
fn end_layout_drag(app: AppHandle, state: tauri::State<AppState>) {
|
||||
*state.layout_drag.lock().unwrap() = None;
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
persist_layout_position(&app, &state);
|
||||
}
|
||||
|
||||
fn persist_layout_position(app: &AppHandle, state: &AppState) {
|
||||
if let Some(w) = app.get_webview_window("layout") {
|
||||
let scale = w.scale_factor().unwrap_or(1.0);
|
||||
if let Ok(pos) = w.outer_position() {
|
||||
let lp = pos.to_logical::<i32>(scale);
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
cfg.layout_x = lp.x;
|
||||
cfg.layout_y = lp.y;
|
||||
cfg.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the overlay window's height to fit its content (called by the frontend
|
||||
/// whenever the rendered overlay's height changes). Keeps the window only as tall
|
||||
/// as needed so it can be placed anywhere without the WM clamping it on-screen.
|
||||
#[tauri::command]
|
||||
fn set_overlay_height(app: AppHandle, state: tauri::State<AppState>, height: u32) {
|
||||
if let Some(w) = app.get_webview_window("overlay") {
|
||||
let width = state.config.lock().unwrap().overlay_width;
|
||||
let _ = w.set_size(LogicalSize::new(width, height.max(1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the overlay to its saved position via xdotool. Tauri/tao's set_position
|
||||
/// is clamped on-screen by KWin (and unreliable at startup), so we drive the X11
|
||||
/// window move directly — it honors off-edge positions for the short window.
|
||||
#[tauri::command]
|
||||
fn restore_overlay_position(state: tauri::State<AppState>) {
|
||||
let (x, y) = {
|
||||
let cfg = state.config.lock().unwrap();
|
||||
(cfg.overlay_x, cfg.overlay_y)
|
||||
};
|
||||
let _ = std::process::Command::new("xdotool")
|
||||
.args([
|
||||
"search",
|
||||
"--name",
|
||||
"Exile UI Overlay",
|
||||
"windowmove",
|
||||
&x.to_string(),
|
||||
&y.to_string(),
|
||||
])
|
||||
.status();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_overlay_geometry(app: AppHandle, state: tauri::State<AppState>) {
|
||||
// Persist the overlay window's current position/size back into config.
|
||||
if let Some(w) = app.get_webview_window("overlay") {
|
||||
let scale = w.scale_factor().unwrap_or(1.0);
|
||||
if let (Ok(pos), Ok(size)) = (w.outer_position(), w.inner_size()) {
|
||||
let lp = pos.to_logical::<i32>(scale);
|
||||
let ls = size.to_logical::<u32>(scale);
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
cfg.overlay_x = lp.x;
|
||||
cfg.overlay_y = lp.y;
|
||||
cfg.overlay_width = ls.width;
|
||||
cfg.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn emit_timer(app: &AppHandle) {
|
||||
let t = app.state::<AppState>().timer.lock().unwrap().clone();
|
||||
let _ = app.emit("timer://update", t);
|
||||
}
|
||||
|
||||
/// Persist the live timer into the active profile and save the config. Replaces
|
||||
/// the old standalone timer.json so each profile keeps its own timer + splits.
|
||||
fn store_live_timer(state: &AppState) {
|
||||
let t = { state.timer.lock().unwrap().clone() };
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
if let Some(a) = cfg.active_character.clone() {
|
||||
if let Some(p) = cfg.profiles.iter_mut().find(|p| p.name == a) {
|
||||
p.timer = t;
|
||||
}
|
||||
}
|
||||
cfg.save();
|
||||
}
|
||||
|
||||
pub(crate) fn persist_timer(app: &AppHandle) {
|
||||
store_live_timer(&app.state::<AppState>());
|
||||
}
|
||||
|
||||
/// A friendly local timestamp used to label a run.
|
||||
pub(crate) fn now_string() -> String {
|
||||
std::process::Command::new("date")
|
||||
.arg("+%Y/%m/%d %H:%M")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("run {}", secs)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_step(app: &AppHandle, new: usize) {
|
||||
let state = app.state::<AppState>();
|
||||
{
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
cfg.current_step = new;
|
||||
cfg.sync_progress();
|
||||
cfg.save();
|
||||
}
|
||||
let _ = app.emit("tracker://step", new);
|
||||
}
|
||||
|
||||
/// Rebuild the guide for the active profile's league-start branch and clamp the
|
||||
/// cursor; emits a reload so both windows refresh.
|
||||
fn rebuild_guide(app: &AppHandle) {
|
||||
let state = app.state::<AppState>();
|
||||
let league = state.config.lock().unwrap().league_start;
|
||||
let rebuilt = leveltracker::load(league);
|
||||
let max = rebuilt.steps.len().saturating_sub(1);
|
||||
*state.guide.lock().unwrap() = rebuilt;
|
||||
let step = {
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
if cfg.current_step > max {
|
||||
cfg.current_step = max;
|
||||
cfg.sync_progress();
|
||||
cfg.save();
|
||||
}
|
||||
cfg.current_step
|
||||
};
|
||||
let _ = app.emit("guide://reload", ());
|
||||
let _ = app.emit("tracker://step", step);
|
||||
}
|
||||
|
||||
/// Auto-switch to the profile matching the character read from the log, if one
|
||||
/// exists. Manually-created profiles only — an unknown character is ignored.
|
||||
pub(crate) fn activate_profile_if_exists(app: &AppHandle, name: &str) {
|
||||
let state = app.state::<AppState>();
|
||||
let exists = {
|
||||
let cfg = state.config.lock().unwrap();
|
||||
cfg.active_character.as_deref() != Some(name)
|
||||
&& cfg.profiles.iter().any(|p| p.name == name)
|
||||
};
|
||||
if exists {
|
||||
select_profile(app.clone(), state, name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn register_shortcuts(app: &AppHandle) {
|
||||
let gs = app.global_shortcut();
|
||||
let _ = gs.unregister_all();
|
||||
let cfg = app.state::<AppState>().config.lock().unwrap().clone();
|
||||
for accel in [
|
||||
&cfg.hotkey_next,
|
||||
&cfg.hotkey_prev,
|
||||
&cfg.hotkey_toggle,
|
||||
&cfg.hotkey_timer_pause,
|
||||
&cfg.hotkey_layout,
|
||||
] {
|
||||
if let Ok(sc) = Shortcut::from_str(accel) {
|
||||
let _ = gs.register(sc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_overlay_geometry(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("overlay") {
|
||||
let cfg = app.state::<AppState>().config.lock().unwrap().clone();
|
||||
let _ = w.set_position(LogicalPosition::new(cfg.overlay_x, cfg.overlay_y));
|
||||
// Width is user-controlled; height tracks the content (set_overlay_height)
|
||||
// so a tall window isn't clamped on-screen when placed near a screen edge.
|
||||
let scale = w.scale_factor().unwrap_or(1.0);
|
||||
let h = w
|
||||
.inner_size()
|
||||
.map(|s| s.to_logical::<u32>(scale).height)
|
||||
.unwrap_or(200);
|
||||
let _ = w.set_size(LogicalSize::new(cfg.overlay_width, h));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_shortcut(app: &AppHandle, shortcut: &Shortcut) {
|
||||
let cfg = app.state::<AppState>().config.lock().unwrap().clone();
|
||||
if Shortcut::from_str(&cfg.hotkey_next).ok().as_ref() == Some(shortcut) {
|
||||
step_delta_internal(app, 1);
|
||||
} else if Shortcut::from_str(&cfg.hotkey_prev).ok().as_ref() == Some(shortcut) {
|
||||
step_delta_internal(app, -1);
|
||||
} else if Shortcut::from_str(&cfg.hotkey_toggle).ok().as_ref() == Some(shortcut) {
|
||||
toggle_overlay(app.clone());
|
||||
} else if Shortcut::from_str(&cfg.hotkey_timer_pause).ok().as_ref() == Some(shortcut) {
|
||||
let state = app.state::<AppState>();
|
||||
{
|
||||
let mut t = state.timer.lock().unwrap();
|
||||
t.toggle_pause();
|
||||
}
|
||||
store_live_timer(&state);
|
||||
emit_timer(app);
|
||||
} else if Shortcut::from_str(&cfg.hotkey_layout).ok().as_ref() == Some(shortcut) {
|
||||
toggle_layout(app.clone(), app.state::<AppState>());
|
||||
}
|
||||
}
|
||||
|
||||
fn step_delta_internal(app: &AppHandle, delta: i64) {
|
||||
let state = app.state::<AppState>();
|
||||
let max = state.guide.lock().unwrap().steps.len().saturating_sub(1);
|
||||
let new = {
|
||||
let cur = state.config.lock().unwrap().current_step as i64;
|
||||
(cur + delta).clamp(0, max as i64) as usize
|
||||
};
|
||||
set_step(app, new);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// WebKitGTK's DMABUF/GBM renderer fails on a number of Linux GPU/driver
|
||||
// setups, leaving the webview blank ("Failed to create GBM buffer").
|
||||
// Disabling it forces a software/GL path that renders reliably. Users can
|
||||
// still override by exporting the variable themselves.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
|
||||
// Game overlays need self-positioning, always-on-top, global hotkeys
|
||||
// and active-window detection — all of which Wayland forbids for
|
||||
// clients. Running under XWayland restores them (and PoE2 itself runs
|
||||
// under XWayland via Proton, so both share one X11 space). Force the
|
||||
// X11 GDK backend whenever an X server (real or XWayland) is reachable,
|
||||
// unless the user explicitly chose a backend.
|
||||
if std::env::var_os("GDK_BACKEND").is_none() && std::env::var_os("DISPLAY").is_some() {
|
||||
std::env::set_var("GDK_BACKEND", "x11");
|
||||
}
|
||||
}
|
||||
|
||||
let config = Config::load();
|
||||
let guide = leveltracker::load(config.league_start);
|
||||
// The live timer mirrors the active profile's timer.
|
||||
let live_timer = config
|
||||
.active_character
|
||||
.as_ref()
|
||||
.and_then(|a| config.profiles.iter().find(|p| &p.name == a))
|
||||
.map(|p| p.timer.clone())
|
||||
.unwrap_or_default();
|
||||
let state = AppState {
|
||||
config: Mutex::new(config),
|
||||
guide: Mutex::new(guide),
|
||||
status: Mutex::new(Status::default()),
|
||||
timer: Mutex::new(live_timer),
|
||||
layout_drag: Mutex::new(None),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.on_window_event(|window, event| {
|
||||
// The overlay window lives for the whole session (hidden/click-through),
|
||||
// so Tauri never auto-exits when the user closes the main window.
|
||||
// Closing "main" should tear the whole app down, overlay included.
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
if window.label() == "main" {
|
||||
window.app_handle().exit(0);
|
||||
}
|
||||
}
|
||||
})
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(|app, shortcut, event| {
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
handle_shortcut(app, shortcut);
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.manage(state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_config,
|
||||
save_config,
|
||||
get_status,
|
||||
get_guide,
|
||||
get_gems,
|
||||
detect_logs,
|
||||
get_timer,
|
||||
timer_start,
|
||||
timer_pause,
|
||||
timer_reset,
|
||||
step_delta,
|
||||
step_goto,
|
||||
create_profile,
|
||||
delete_profile,
|
||||
select_profile,
|
||||
set_overlay_locked,
|
||||
toggle_overlay,
|
||||
save_overlay_geometry,
|
||||
set_overlay_height,
|
||||
restore_overlay_position,
|
||||
toggle_layout,
|
||||
set_layout_size,
|
||||
save_layout_geometry,
|
||||
start_layout_drag,
|
||||
end_layout_drag,
|
||||
])
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
|
||||
// Auto-detect a log path on first run if none configured.
|
||||
{
|
||||
let state = handle.state::<AppState>();
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
if cfg.log_path.is_none() {
|
||||
if let Some(p) = poe::detect_log_paths().into_iter().next() {
|
||||
cfg.log_path = Some(p);
|
||||
cfg.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the transparent, click-through overlay window.
|
||||
let (ox, oy, ow) = {
|
||||
let state = handle.state::<AppState>();
|
||||
let cfg = state.config.lock().unwrap();
|
||||
(cfg.overlay_x, cfg.overlay_y, cfg.overlay_width)
|
||||
};
|
||||
let overlay = WebviewWindowBuilder::new(
|
||||
&handle,
|
||||
"overlay",
|
||||
WebviewUrl::App("index.html".into()),
|
||||
)
|
||||
.title("Exile UI Overlay")
|
||||
// Small initial height; the frontend resizes it to fit the rendered
|
||||
// content (set_overlay_height). A short window won't be clamped
|
||||
// on-screen by the WM when restored near a screen edge.
|
||||
.inner_size(ow as f64, 200.0)
|
||||
.position(ox as f64, oy as f64)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(true)
|
||||
.shadow(false)
|
||||
// Must be created visible: the GTK/Gdk window only exists once
|
||||
// realized, and set_ignore_cursor_events() needs it to apply the
|
||||
// X11 input-shape (otherwise tao panics on an unrealized window).
|
||||
.visible(true)
|
||||
.build();
|
||||
// The OS window stays visible and click-through for its whole
|
||||
// lifetime; actual show/hide is handled in the frontend (a
|
||||
// transparent, empty overlay is invisible anyway). This avoids
|
||||
// GTK window-realization races around the input shape.
|
||||
if let Ok(w) = overlay {
|
||||
let _ = w.set_ignore_cursor_events(true);
|
||||
// Position is restored by the frontend (restore_overlay_position)
|
||||
// once it has shrunk the window to its content height — doing it
|
||||
// earlier lets the WM clamp the still-tall window on-screen.
|
||||
}
|
||||
|
||||
// Create the interactive zone-layout viewer window, hidden until the
|
||||
// user toggles it with its hotkey. It is focusable and not
|
||||
// click-through (you click to pick/refine the matching layout).
|
||||
// Unlike the overlay it is NOT transparent: it's an opaque panel, and
|
||||
// transparent WebKitGTK windows don't reliably repaint here (the view
|
||||
// stays blank/ghosted), whereas an opaque window composites fine.
|
||||
let (lx, ly, lsz) = {
|
||||
let state = handle.state::<AppState>();
|
||||
let cfg = state.config.lock().unwrap();
|
||||
(cfg.layout_x, cfg.layout_y, cfg.layout_size)
|
||||
};
|
||||
let _ = WebviewWindowBuilder::new(
|
||||
&handle,
|
||||
"layout",
|
||||
WebviewUrl::App("index.html".into()),
|
||||
)
|
||||
.title("Exile UI Layouts")
|
||||
.inner_size(lsz as f64 + 24.0, lsz as f64 + 110.0)
|
||||
.position(lx as f64, ly as f64)
|
||||
.decorations(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(true)
|
||||
.shadow(false)
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
register_shortcuts(&handle);
|
||||
logwatch::spawn(handle.clone());
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
408
src-tauri/src/logwatch.rs
Normal file
@ -0,0 +1,408 @@
|
||||
use crate::{leveltracker, AppState};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
/// Parsed character info from an "is now level" line.
|
||||
struct CharInfo {
|
||||
character: String,
|
||||
class: String,
|
||||
level: u32,
|
||||
}
|
||||
|
||||
fn parse_level_line(line: &str) -> Option<CharInfo> {
|
||||
if !line.contains("is now level") {
|
||||
return None;
|
||||
}
|
||||
// Take everything after the last ':' (the " : <msg>" separator; timestamps
|
||||
// contain ':' too, but the message itself has none after it).
|
||||
let after = line.rsplit_once(':').map(|(_, b)| b).unwrap_or(line).trim();
|
||||
// after looks like: "CharName (Class) is now level 12"
|
||||
let level: u32 = after
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|c| c.is_ascii_digit() || *c == ' ')
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()?;
|
||||
let open = after.find('(');
|
||||
let close = after.find(')');
|
||||
let (character, class) = match (open, close) {
|
||||
(Some(o), Some(c)) if c > o => (
|
||||
after[..o].trim().to_string(),
|
||||
after[o + 1..c].trim().to_string(),
|
||||
),
|
||||
_ => (after.split_whitespace().next().unwrap_or("").to_string(), String::new()),
|
||||
};
|
||||
Some(CharInfo {
|
||||
character,
|
||||
class,
|
||||
level,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parsed area info from a "Generating level" line.
|
||||
struct AreaInfo {
|
||||
id: String,
|
||||
level: u32,
|
||||
seed: String,
|
||||
}
|
||||
|
||||
fn parse_area_line(line: &str) -> Option<AreaInfo> {
|
||||
let gen = line.find("Generating level ")?;
|
||||
let rest = &line[gen + "Generating level ".len()..];
|
||||
// rest: "12 area \"g1_5\" with seed 1234567"
|
||||
let level: u32 = rest
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse()
|
||||
.ok()?;
|
||||
let area_kw = rest.find("area \"")?;
|
||||
let after = &rest[area_kw + "area \"".len()..];
|
||||
let id_end = after.find('"')?;
|
||||
// The game capitalizes the area-id prefix (e.g. "G1_6") while the bundled
|
||||
// guide/area data uses lowercase ids, so normalize for matching.
|
||||
let mut id = after[..id_end].to_lowercase();
|
||||
// known bugged PoE2 area ids carry a trailing underscore
|
||||
if id == "c_g2_9_2_" || id == "c_g3_16_" {
|
||||
id.pop();
|
||||
}
|
||||
let seed = rest
|
||||
.find("with seed ")
|
||||
.map(|p| rest[p + "with seed ".len()..].trim().to_string())
|
||||
.unwrap_or_default();
|
||||
Some(AreaInfo {
|
||||
id,
|
||||
level,
|
||||
seed,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_line(app: &AppHandle, line: &str) {
|
||||
let state = app.state::<AppState>();
|
||||
|
||||
if let Some(ci) = parse_level_line(line) {
|
||||
{
|
||||
let mut s = state.status.lock().unwrap();
|
||||
s.character = ci.character.clone();
|
||||
s.class = ci.class.clone();
|
||||
s.level = ci.level;
|
||||
}
|
||||
emit_status(app);
|
||||
// If a profile exists for the character we're playing, make it active so
|
||||
// its saved progress is tracked.
|
||||
if !ci.character.is_empty() {
|
||||
crate::activate_profile_if_exists(app, &ci.character);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ai) = parse_area_line(line) {
|
||||
let (area_name, act) = {
|
||||
let guide = state.guide.lock().unwrap();
|
||||
match guide.areas.get(&ai.id) {
|
||||
Some(a) => (a.name.clone(), a.group + 1),
|
||||
None => (String::new(), 0),
|
||||
}
|
||||
};
|
||||
{
|
||||
let mut s = state.status.lock().unwrap();
|
||||
s.area_id = ai.id.clone();
|
||||
s.area_name = area_name;
|
||||
s.area_level = ai.level;
|
||||
s.area_seed = ai.seed.clone();
|
||||
s.act = act;
|
||||
}
|
||||
emit_status(app);
|
||||
|
||||
// Campaign timer: auto-start / advance acts / finish on area change.
|
||||
let timer_enabled = state.config.lock().unwrap().timer_enabled;
|
||||
let timer_changed = {
|
||||
let mut t = state.timer.lock().unwrap();
|
||||
t.on_area(&ai.id, act, timer_enabled, crate::now_string)
|
||||
};
|
||||
if timer_changed {
|
||||
crate::persist_timer(app);
|
||||
let t = state.timer.lock().unwrap().clone();
|
||||
let _ = app.emit("timer://update", t);
|
||||
}
|
||||
|
||||
// Auto-advance the leveling guide when entering a step's target area.
|
||||
let new_step = {
|
||||
let guide = state.guide.lock().unwrap();
|
||||
let cur = state.config.lock().unwrap().current_step;
|
||||
leveltracker::step_for_area(&guide.steps, &ai.id, cur)
|
||||
.map(|i| (i + 1).min(guide.steps.len().saturating_sub(1)))
|
||||
};
|
||||
if let Some(ns) = new_step {
|
||||
let changed = {
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
if cfg.current_step != ns {
|
||||
cfg.current_step = ns;
|
||||
cfg.save();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
let _ = app.emit("tracker://step", ns);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_status(app: &AppHandle) {
|
||||
let state = app.state::<AppState>();
|
||||
let status = state.status.lock().unwrap().clone();
|
||||
let _ = app.emit("status://update", status);
|
||||
}
|
||||
|
||||
/// Read the tail of the file once to establish the current character/area.
|
||||
fn initial_scan(app: &AppHandle, file: &mut File) {
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let start = len.saturating_sub(512 * 1024);
|
||||
if file.seek(SeekFrom::Start(start)).is_err() {
|
||||
return;
|
||||
}
|
||||
let mut buf = String::new();
|
||||
if file.read_to_string(&mut buf).is_err() {
|
||||
// fall through; non-UTF8 bytes shouldn't happen for this log
|
||||
}
|
||||
let mut last_level: Option<String> = None;
|
||||
let mut last_area: Option<String> = None;
|
||||
for line in buf.lines() {
|
||||
if line.contains("is now level") {
|
||||
last_level = Some(line.to_string());
|
||||
}
|
||||
if line.contains("Generating level ") {
|
||||
last_area = Some(line.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(l) = last_level {
|
||||
handle_line(app, &l);
|
||||
}
|
||||
if let Some(a) = last_area {
|
||||
handle_line(app, &a);
|
||||
}
|
||||
let _ = file.seek(SeekFrom::End(0));
|
||||
}
|
||||
|
||||
/// Spawn the background thread that tails the configured log file and the
|
||||
/// active-window poller that controls overlay visibility.
|
||||
pub fn spawn(app: AppHandle) {
|
||||
std::thread::spawn(move || {
|
||||
let mut file: Option<File> = None;
|
||||
let mut pos: u64 = 0;
|
||||
let mut current_path: Option<String> = None;
|
||||
let mut leftover = String::new();
|
||||
let mut last_tick = std::time::Instant::now();
|
||||
let mut last_save = std::time::Instant::now();
|
||||
let mut reassert_ticks: u8 = 0;
|
||||
// AFK detection: poll the mouse position; if it hasn't moved for the
|
||||
// configured delay, the timer auto-pauses.
|
||||
let mut last_mouse: Option<(i32, i32)> = None;
|
||||
let mut last_move = std::time::Instant::now();
|
||||
let mut mouse_ticks: u8 = 0;
|
||||
|
||||
loop {
|
||||
// (Re)open the file if the configured path changed or it appeared.
|
||||
let want_path = {
|
||||
let state = app.state::<AppState>();
|
||||
let cfg = state.config.lock().unwrap();
|
||||
cfg.log_path.clone()
|
||||
};
|
||||
|
||||
if want_path != current_path {
|
||||
current_path = want_path.clone();
|
||||
file = None;
|
||||
leftover.clear();
|
||||
if let Some(p) = &want_path {
|
||||
if let Ok(mut f) = File::open(p) {
|
||||
initial_scan(&app, &mut f);
|
||||
pos = f.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
file = Some(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read any newly appended bytes.
|
||||
if let Some(f) = file.as_mut() {
|
||||
if let Ok(meta) = f.metadata() {
|
||||
let len = meta.len();
|
||||
if len < pos {
|
||||
// file was truncated/rotated
|
||||
pos = 0;
|
||||
let _ = f.seek(SeekFrom::Start(0));
|
||||
leftover.clear();
|
||||
}
|
||||
if len > pos {
|
||||
if f.seek(SeekFrom::Start(pos)).is_ok() {
|
||||
let mut buf = Vec::new();
|
||||
if f.read_to_end(&mut buf).is_ok() {
|
||||
pos = len;
|
||||
leftover.push_str(&String::from_utf8_lossy(&buf));
|
||||
// process complete lines
|
||||
while let Some(nl) = leftover.find('\n') {
|
||||
let line: String =
|
||||
leftover.drain(..=nl).collect::<String>();
|
||||
let line = line.trim_end_matches(['\n', '\r']);
|
||||
if !line.is_empty() {
|
||||
handle_line(&app, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update_focus(&app);
|
||||
|
||||
// Keep the overlay above a self-raising game. Clicking inside PoE2
|
||||
// (especially fullscreen / borderless under Wayland+XWayland) raises
|
||||
// the game window in the stack and buries the overlay; the WM only
|
||||
// honors our always-on-top at the moment it's set. Re-assert it
|
||||
// every ~600ms while the game is focused so the overlay pops back.
|
||||
reassert_ticks = reassert_ticks.wrapping_add(1);
|
||||
if reassert_ticks >= 2 {
|
||||
reassert_ticks = 0;
|
||||
if app.state::<AppState>().status.lock().unwrap().game_focused {
|
||||
if let Some(w) = app.get_webview_window("overlay") {
|
||||
// Toggling forces the WM to restack ABOVE without
|
||||
// stealing focus from the game.
|
||||
let _ = w.set_always_on_top(false);
|
||||
let _ = w.set_always_on_top(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the mouse roughly once a second to track activity.
|
||||
mouse_ticks = mouse_ticks.wrapping_add(1);
|
||||
if mouse_ticks >= 3 {
|
||||
mouse_ticks = 0;
|
||||
if let Some(p) = crate::poe::mouse_position() {
|
||||
if Some(p) != last_mouse {
|
||||
last_mouse = Some(p);
|
||||
last_move = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
let afk = {
|
||||
let state = app.state::<AppState>();
|
||||
let focused = state.status.lock().unwrap().game_focused;
|
||||
let cfg = state.config.lock().unwrap();
|
||||
let idle = cfg.timer_afk_enabled
|
||||
&& last_move.elapsed().as_secs() >= cfg.timer_afk_seconds as u64;
|
||||
let unfocused = cfg.timer_pause_unfocused && !focused;
|
||||
idle || unfocused
|
||||
};
|
||||
|
||||
tick_timer(&app, &mut last_tick, &mut last_save, afk);
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_area_generation() {
|
||||
// real PoE2 0.5 logs capitalize the prefix ("G1_5"); we normalize it.
|
||||
let line = "2026/05/29 08:26:16 789879 2caa22d2 [DEBUG Client 312] Generating level 12 area \"G1_5\" with seed 1839472";
|
||||
let a = parse_area_line(line).expect("should parse");
|
||||
assert_eq!(a.id, "g1_5");
|
||||
assert_eq!(a.level, 12);
|
||||
assert_eq!(a.seed, "1839472");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixes_bugged_area_id() {
|
||||
let line = "Generating level 30 area \"c_g3_16_\" with seed 42";
|
||||
let a = parse_area_line(line).expect("should parse");
|
||||
assert_eq!(a.id, "c_g3_16");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_level_up() {
|
||||
let line = "2024/12/06 21:05:00 123 abc [INFO Client 19340] : MyChar (Sorceress) is now level 2";
|
||||
let c = parse_level_line(line).expect("should parse");
|
||||
assert_eq!(c.character, "MyChar");
|
||||
assert_eq!(c.class, "Sorceress");
|
||||
assert_eq!(c.level, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unrelated_lines() {
|
||||
assert!(parse_area_line("just some text").is_none());
|
||||
assert!(parse_level_line("connected to instance").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulate elapsed time into the campaign timer and emit updates.
|
||||
fn tick_timer(
|
||||
app: &AppHandle,
|
||||
last_tick: &mut std::time::Instant,
|
||||
last_save: &mut std::time::Instant,
|
||||
afk: bool,
|
||||
) {
|
||||
let now = std::time::Instant::now();
|
||||
let delta = now.duration_since(*last_tick).as_secs_f64();
|
||||
*last_tick = now;
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
let (enabled, pause_in_town) = {
|
||||
let cfg = state.config.lock().unwrap();
|
||||
(cfg.timer_enabled, cfg.timer_pause_in_town)
|
||||
};
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
let area_id = state.status.lock().unwrap().area_id.clone();
|
||||
let changed = {
|
||||
let mut t = state.timer.lock().unwrap();
|
||||
t.tick(delta, &area_id, pause_in_town, afk)
|
||||
};
|
||||
if changed {
|
||||
let t = state.timer.lock().unwrap().clone();
|
||||
let _ = app.emit("timer://update", t);
|
||||
// Persist roughly every 15s as a crash backup.
|
||||
if now.duration_since(*last_save).as_secs() >= 15 {
|
||||
*last_save = now;
|
||||
crate::persist_timer(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect game focus and show/hide the overlay accordingly.
|
||||
fn update_focus(app: &AppHandle) {
|
||||
let (match_str, only_when_focused) = {
|
||||
let state = app.state::<AppState>();
|
||||
let cfg = state.config.lock().unwrap();
|
||||
(cfg.poe_window_match.clone(), cfg.overlay_only_when_focused)
|
||||
};
|
||||
|
||||
let title = crate::poe::active_window_title().unwrap_or_default();
|
||||
let focused = title.contains(&match_str);
|
||||
|
||||
let changed = {
|
||||
let state = app.state::<AppState>();
|
||||
let mut s = state.status.lock().unwrap();
|
||||
let c = s.game_focused != focused;
|
||||
s.game_focused = focused;
|
||||
c
|
||||
};
|
||||
|
||||
if changed {
|
||||
let _ = app.emit("focus://update", focused);
|
||||
}
|
||||
let _ = only_when_focused; // visibility is decided in the overlay frontend
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
exile_ui_lib::run()
|
||||
}
|
||||
123
src-tauri/src/poe.rs
Normal file
@ -0,0 +1,123 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// Candidate relative paths to the client log under a game install directory.
|
||||
const LOG_RELATIVE: &[&str] = &["logs/Client.txt", "logs/client.txt"];
|
||||
|
||||
/// Common install roots (relative to $HOME) where Path of Exile 2 may live.
|
||||
const GAME_ROOTS: &[&str] = &[
|
||||
".local/share/Steam/steamapps/common/Path of Exile 2",
|
||||
".steam/steam/steamapps/common/Path of Exile 2",
|
||||
".steam/root/steamapps/common/Path of Exile 2",
|
||||
".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Path of Exile 2",
|
||||
"Games/Path of Exile 2",
|
||||
// PoE1 fallbacks
|
||||
".local/share/Steam/steamapps/common/Path of Exile",
|
||||
".steam/steam/steamapps/common/Path of Exile",
|
||||
];
|
||||
|
||||
/// Try to locate existing Client.txt files in well-known locations.
|
||||
pub fn detect_log_paths() -> Vec<String> {
|
||||
let mut found = Vec::new();
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => return found,
|
||||
};
|
||||
|
||||
// 1) Hard-coded common roots.
|
||||
for root in GAME_ROOTS {
|
||||
for rel in LOG_RELATIVE {
|
||||
let mut p = home.join(root);
|
||||
p.push(rel);
|
||||
if p.is_file() {
|
||||
found.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Parse Steam's libraryfolders.vdf for additional library locations.
|
||||
for vdf in steam_library_paths(&home) {
|
||||
for sub in ["Path of Exile 2", "Path of Exile"] {
|
||||
for rel in LOG_RELATIVE {
|
||||
let mut p = vdf.join("steamapps/common").join(sub);
|
||||
p.push(rel);
|
||||
if p.is_file() {
|
||||
let s = p.to_string_lossy().to_string();
|
||||
if !found.contains(&s) {
|
||||
found.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
found
|
||||
}
|
||||
|
||||
fn steam_library_paths(home: &PathBuf) -> Vec<PathBuf> {
|
||||
let mut libs = Vec::new();
|
||||
let candidates = [
|
||||
".local/share/Steam/steamapps/libraryfolders.vdf",
|
||||
".steam/steam/steamapps/libraryfolders.vdf",
|
||||
];
|
||||
for c in candidates {
|
||||
let p = home.join(c);
|
||||
if let Ok(text) = std::fs::read_to_string(&p) {
|
||||
// crude VDF scan: capture every "path" "…" value
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("\"path\"") {
|
||||
if let Some(start) = line[6..].find('"') {
|
||||
let rest = &line[6 + start + 1..];
|
||||
if let Some(end) = rest.find('"') {
|
||||
libs.push(PathBuf::from(&rest[..end]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
libs
|
||||
}
|
||||
|
||||
/// Current global mouse pointer position (X11/XWayland), used for AFK detection.
|
||||
/// XScreenSaver idle isn't available under XWayland, so we poll the pointer.
|
||||
pub fn mouse_position() -> Option<(i32, i32)> {
|
||||
let out = Command::new("xdotool")
|
||||
.arg("getmouselocation")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
// format: "x:3814 y:8 screen:0 window:..."
|
||||
let mut x = None;
|
||||
let mut y = None;
|
||||
for tok in s.split_whitespace() {
|
||||
if let Some(v) = tok.strip_prefix("x:") {
|
||||
x = v.parse().ok();
|
||||
} else if let Some(v) = tok.strip_prefix("y:") {
|
||||
y = v.parse().ok();
|
||||
}
|
||||
}
|
||||
Some((x?, y?))
|
||||
}
|
||||
|
||||
/// Returns the title of the currently focused X11 window, if obtainable.
|
||||
pub fn active_window_title() -> Option<String> {
|
||||
let out = Command::new("xdotool")
|
||||
.args(["getactivewindow", "getwindowname"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if out.status.success() {
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
232
src-tauri/src/timer.rs
Normal file
@ -0,0 +1,232 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Campaign speedrun timer: tracks total time and per-act splits, mirroring the
|
||||
/// behavior of the original tool's leveling-tracker timer.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct RunTimer {
|
||||
/// A run is in progress (started, not reset).
|
||||
pub active: bool,
|
||||
/// The run reached the endgame and stopped accumulating.
|
||||
pub finished: bool,
|
||||
/// User-requested pause (distinct from automatic town/hideout pause).
|
||||
pub manual_pause: bool,
|
||||
/// Whether the timer is currently auto-paused (town/hideout) — derived, but
|
||||
/// stored so the UI can show why it's paused.
|
||||
pub auto_paused: bool,
|
||||
/// Whether the timer is currently auto-paused due to inactivity (AFK).
|
||||
pub afk_paused: bool,
|
||||
/// Seconds accumulated in completed acts.
|
||||
pub total_seconds: f64,
|
||||
/// Seconds accumulated in the current act.
|
||||
pub act_seconds: f64,
|
||||
/// 1-based current act.
|
||||
pub current_act: usize,
|
||||
/// Per-act splits, indexed by act number (0 unused).
|
||||
pub splits: Vec<f64>,
|
||||
/// Human label for the run (start date/time).
|
||||
pub run_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for RunTimer {
|
||||
fn default() -> Self {
|
||||
RunTimer {
|
||||
active: false,
|
||||
finished: false,
|
||||
manual_pause: false,
|
||||
auto_paused: false,
|
||||
afk_paused: false,
|
||||
total_seconds: 0.0,
|
||||
act_seconds: 0.0,
|
||||
current_act: 1,
|
||||
splits: vec![0.0; 16],
|
||||
run_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An area id that should auto-pause the timer (towns, hideouts, login).
|
||||
pub fn is_safe_zone(area_id: &str) -> bool {
|
||||
let a = area_id.to_lowercase();
|
||||
a.is_empty() || a == "login" || a.contains("town") || a.contains("hideout")
|
||||
}
|
||||
|
||||
/// Detect the campaign starting zone (to auto-start a run).
|
||||
fn is_campaign_start(area_id: &str) -> bool {
|
||||
let a = area_id.to_lowercase();
|
||||
a == "g1_1" || a == "1_1_1"
|
||||
}
|
||||
|
||||
fn is_endgame(area_id: &str) -> bool {
|
||||
area_id.to_lowercase().contains("endgame")
|
||||
}
|
||||
|
||||
impl RunTimer {
|
||||
fn ensure_act(&mut self, act: usize) {
|
||||
if self.splits.len() <= act {
|
||||
self.splits.resize(act + 1, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin a fresh run.
|
||||
pub fn start(&mut self, act: usize, name: String) {
|
||||
*self = RunTimer::default();
|
||||
self.active = true;
|
||||
self.current_act = act.max(1);
|
||||
self.run_name = Some(name);
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
*self = RunTimer::default();
|
||||
}
|
||||
|
||||
pub fn toggle_pause(&mut self) {
|
||||
if self.active && !self.finished {
|
||||
self.manual_pause = !self.manual_pause;
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the elapsed time by `delta` seconds, honoring pause rules.
|
||||
/// `afk` is true when no input was detected for the configured delay.
|
||||
/// Returns true if the UI should be refreshed (time advanced, or a
|
||||
/// pause state just changed).
|
||||
pub fn tick(&mut self, delta: f64, area_id: &str, pause_in_town: bool, afk: bool) -> bool {
|
||||
if !self.active || self.finished || self.manual_pause {
|
||||
let changed = self.auto_paused || self.afk_paused;
|
||||
self.auto_paused = false;
|
||||
self.afk_paused = false;
|
||||
return changed;
|
||||
}
|
||||
// AFK takes precedence over town pause for the displayed reason.
|
||||
if afk {
|
||||
let was = self.afk_paused;
|
||||
self.afk_paused = true;
|
||||
self.auto_paused = false;
|
||||
return !was;
|
||||
}
|
||||
let was_afk = self.afk_paused;
|
||||
self.afk_paused = false;
|
||||
let safe = pause_in_town && is_safe_zone(area_id);
|
||||
let was = self.auto_paused;
|
||||
self.auto_paused = safe;
|
||||
if safe {
|
||||
// Emit once on entering the paused state, then stay quiet.
|
||||
return !was || was_afk;
|
||||
}
|
||||
self.act_seconds += delta;
|
||||
true
|
||||
}
|
||||
|
||||
/// React to an area change. May auto-start a run, advance acts, or finish.
|
||||
/// `enabled` is the timer feature toggle. Returns true if state changed.
|
||||
pub fn on_area(&mut self, area_id: &str, act: usize, enabled: bool, start_name: impl Fn() -> String) -> bool {
|
||||
if !enabled {
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
|
||||
// Auto-start when entering the campaign start with no active run.
|
||||
if !self.active && is_campaign_start(area_id) {
|
||||
self.start(act.max(1), start_name());
|
||||
return true;
|
||||
}
|
||||
if !self.active || self.finished {
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Endgame reached: bank the final act and stop.
|
||||
if is_endgame(area_id) {
|
||||
self.ensure_act(self.current_act);
|
||||
self.splits[self.current_act] = self.act_seconds;
|
||||
self.total_seconds += self.act_seconds;
|
||||
self.act_seconds = 0.0;
|
||||
self.finished = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Advanced to a later act: bank the current act's split.
|
||||
if act > self.current_act {
|
||||
self.ensure_act(self.current_act);
|
||||
self.splits[self.current_act] = self.act_seconds;
|
||||
self.total_seconds += self.act_seconds;
|
||||
self.act_seconds = 0.0;
|
||||
self.current_act = act;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn name() -> String {
|
||||
"run".to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_starts_at_campaign_start() {
|
||||
let mut t = RunTimer::default();
|
||||
assert!(t.on_area("g1_1", 1, true, name));
|
||||
assert!(t.active);
|
||||
assert_eq!(t.current_act, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_start_when_disabled() {
|
||||
let mut t = RunTimer::default();
|
||||
assert!(!t.on_area("g1_1", 1, false, name));
|
||||
assert!(!t.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticks_and_pauses_in_town() {
|
||||
let mut t = RunTimer::default();
|
||||
t.start(1, name());
|
||||
t.tick(5.0, "g1_5", true, false);
|
||||
assert!((t.act_seconds - 5.0).abs() < 1e-6);
|
||||
// entering a town pauses accumulation
|
||||
t.tick(5.0, "g1_town", true, false);
|
||||
assert!((t.act_seconds - 5.0).abs() < 1e-6);
|
||||
assert!(t.auto_paused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn afk_pauses_accumulation() {
|
||||
let mut t = RunTimer::default();
|
||||
t.start(1, name());
|
||||
t.tick(5.0, "g1_5", true, false);
|
||||
assert!((t.act_seconds - 5.0).abs() < 1e-6);
|
||||
// afk: time should not accumulate
|
||||
t.tick(5.0, "g1_5", true, true);
|
||||
assert!((t.act_seconds - 5.0).abs() < 1e-6);
|
||||
assert!(t.afk_paused);
|
||||
// resume on activity
|
||||
t.tick(5.0, "g1_5", true, false);
|
||||
assert!((t.act_seconds - 10.0).abs() < 1e-6);
|
||||
assert!(!t.afk_paused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banks_split_on_act_change() {
|
||||
let mut t = RunTimer::default();
|
||||
t.start(1, name());
|
||||
t.tick(10.0, "g1_5", true, false);
|
||||
t.on_area("g2_1", 2, true, name);
|
||||
assert_eq!(t.current_act, 2);
|
||||
assert!((t.splits[1] - 10.0).abs() < 1e-6);
|
||||
assert!((t.total_seconds - 10.0).abs() < 1e-6);
|
||||
assert_eq!(t.act_seconds, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finishes_at_endgame() {
|
||||
let mut t = RunTimer::default();
|
||||
t.start(3, name());
|
||||
t.tick(7.0, "g3_1", true, false);
|
||||
t.on_area("g_endgame_town", 8, true, name);
|
||||
assert!(t.finished);
|
||||
assert!(!t.tick(5.0, "g_endgame_town", true, false)); // no accumulation after finish
|
||||
}
|
||||
}
|
||||
36
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Exile UI",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.exileui.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Exile UI",
|
||||
"width": 820,
|
||||
"height": 740
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["appimage", "deb"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
24
src/app.css
Normal file
@ -0,0 +1,24 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #14110d;
|
||||
--panel: #1d1812;
|
||||
--border: #3a2f22;
|
||||
--text: #d8cdbb;
|
||||
--muted: #8c8170;
|
||||
--accent: #c8a24a;
|
||||
--accent2: #66b2ff;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: "Fontin SmallCaps", "Segoe UI", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
13
src/app.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Exile UI</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
374
src/lib/Layouts.svelte
Normal file
@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getStatus,
|
||||
getConfig,
|
||||
toggleLayout,
|
||||
setLayoutSize,
|
||||
startLayoutDrag,
|
||||
endLayoutDrag,
|
||||
type Status,
|
||||
type Config,
|
||||
} from "$lib/api";
|
||||
import {
|
||||
loadManifest,
|
||||
childrenOf,
|
||||
imageUrl,
|
||||
isDeadEnd,
|
||||
rotationBlocked,
|
||||
type LayoutManifest,
|
||||
} from "$lib/layouts";
|
||||
|
||||
let manifest = $state<LayoutManifest>({});
|
||||
let status = $state<Status | null>(null);
|
||||
let config = $state<Config | null>(null);
|
||||
|
||||
// Decision-tree cursor + per-zone view state (reset whenever the zone changes).
|
||||
let path = $state("");
|
||||
let excluded = $state<string[]>([]);
|
||||
let rot = $state(0); // degrees, multiples of 90
|
||||
let flipH = $state(false);
|
||||
let flipV = $state(false);
|
||||
|
||||
let lastArea = "";
|
||||
function syncArea(s: Status | null) {
|
||||
const id = s?.area_id ?? "";
|
||||
if (id !== lastArea) {
|
||||
lastArea = id;
|
||||
path = "";
|
||||
excluded = [];
|
||||
rot = 0;
|
||||
flipH = false;
|
||||
flipV = false;
|
||||
}
|
||||
}
|
||||
|
||||
const areaId = $derived(status?.area_id ?? "");
|
||||
const areaName = $derived(status?.area_name || areaId || "—");
|
||||
const paths = $derived(manifest[areaId] ?? []);
|
||||
const hasZone = $derived(paths.length > 0);
|
||||
const candidates = $derived(
|
||||
childrenOf(paths, path).filter((p) => !excluded.includes(p)),
|
||||
);
|
||||
const resolved = $derived(path !== "" && childrenOf(paths, path).length === 0);
|
||||
const blocked = $derived(rotationBlocked(areaId));
|
||||
const transform = $derived(
|
||||
`rotate(${rot}deg) scaleX(${flipH ? -1 : 1}) scaleY(${flipV ? -1 : 1})`,
|
||||
);
|
||||
const crumbs = $derived(path === "" ? [] : path.split("_"));
|
||||
|
||||
function pick(p: string) {
|
||||
path = p;
|
||||
}
|
||||
function back() {
|
||||
const seg = path.split("_");
|
||||
seg.pop();
|
||||
path = seg.join("_");
|
||||
}
|
||||
function exclude(p: string) {
|
||||
if (!excluded.includes(p)) excluded = [...excluded, p];
|
||||
}
|
||||
function rotate(dir: number) {
|
||||
rot = (((rot + dir * 90) % 360) + 360) % 360;
|
||||
}
|
||||
function resetOrientation() {
|
||||
rot = 0;
|
||||
flipH = false;
|
||||
flipV = false;
|
||||
}
|
||||
|
||||
// Keep the OS window sized to the rendered content.
|
||||
let boxW = $state(0);
|
||||
let boxH = $state(0);
|
||||
$effect(() => {
|
||||
if (boxW > 0 && boxH > 0) setLayoutSize(Math.ceil(boxW), Math.ceil(boxH));
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let alive = true;
|
||||
// The layout window is created hidden, and its `listen` event subscription
|
||||
// never resolves in that state (unlike the always-visible overlay), so live
|
||||
// status events don't arrive here. The `get_status` command does work, so we
|
||||
// poll it and only react when the area actually changes (cheap; no flicker).
|
||||
(async () => {
|
||||
manifest = await loadManifest();
|
||||
config = await getConfig();
|
||||
while (alive) {
|
||||
const s = await getStatus();
|
||||
if (!status || s.area_id !== status.area_id) {
|
||||
status = s;
|
||||
syncArea(s);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
});
|
||||
|
||||
const imgSize = $derived(config?.layout_size ?? 360);
|
||||
|
||||
// Drag the window by its title bar. WebKitGTK's `data-tauri-drag-region` and
|
||||
// Tauri's `setPosition` don't move this window under KWin, so the Rust side
|
||||
// follows the mouse with xdotool between start/end (driven by pointerdown/up,
|
||||
// which fire reliably — only pointermove is flaky here).
|
||||
function startDrag(e: PointerEvent) {
|
||||
if (e.button !== 0 || (e.target as HTMLElement).closest("button")) return;
|
||||
e.preventDefault();
|
||||
startLayoutDrag();
|
||||
window.addEventListener("pointerup", endDrag, { once: true });
|
||||
window.addEventListener("blur", endDrag, { once: true });
|
||||
}
|
||||
function endDrag() {
|
||||
window.removeEventListener("pointerup", endDrag);
|
||||
window.removeEventListener("blur", endDrag);
|
||||
endLayoutDrag();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="viewer" bind:clientWidth={boxW} bind:clientHeight={boxH}>
|
||||
<div class="bar" role="toolbar" tabindex="-1" onpointerdown={startDrag}>
|
||||
<span class="title">🗺 {areaName}</span>
|
||||
<button class="close" title="Fermer (hotkey)" onclick={() => toggleLayout()}>✕</button>
|
||||
</div>
|
||||
|
||||
{#if !hasZone}
|
||||
<div class="empty">Pas de layout répertorié pour cette zone.</div>
|
||||
{:else}
|
||||
<!-- Main image: the deepest committed pick, with the zone's orientation. -->
|
||||
<div class="stage" style="width:{imgSize}px;height:{imgSize}px;">
|
||||
{#if path !== ""}
|
||||
<img src={imageUrl(areaId, path)} alt={path} style="transform:{transform};" />
|
||||
{:else}
|
||||
<div class="hint">Clique le layout qui correspond à ce que tu vois en jeu.</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Orientation controls (the whole zone is placed at a random rotation). -->
|
||||
{#if !blocked}
|
||||
<div class="ctrls">
|
||||
<button title="Pivoter à gauche" onclick={() => rotate(-1)}>⟲</button>
|
||||
<button title="Pivoter à droite" onclick={() => rotate(1)}>⟳</button>
|
||||
<button class:on={flipH} title="Miroir horizontal" onclick={() => (flipH = !flipH)}>⇋</button>
|
||||
<button class:on={flipV} title="Miroir vertical" onclick={() => (flipV = !flipV)}>⇅</button>
|
||||
<button title="Réinitialiser l'orientation" onclick={resetOrientation}>↺</button>
|
||||
<span class="deg">{rot}°</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Breadcrumb / back navigation. -->
|
||||
{#if path !== ""}
|
||||
<div class="crumbs">
|
||||
<button class="link" onclick={() => (path = "")}>début</button>
|
||||
{#each crumbs as seg, i}
|
||||
<span class="sep">›</span>
|
||||
<button
|
||||
class="link"
|
||||
onclick={() => (path = crumbs.slice(0, i + 1).join("_"))}
|
||||
>{seg === "x" ? "?" : seg}</button>
|
||||
{/each}
|
||||
<button class="back" onclick={back}>◀ retour</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Candidate thumbnails at the current branch. -->
|
||||
{#if candidates.length}
|
||||
<div class="label">{resolved ? "" : path === "" ? "Layouts possibles :" : "Affine :"}</div>
|
||||
<div class="thumbs">
|
||||
{#each candidates as c}
|
||||
<button
|
||||
class="thumb"
|
||||
class:dead={isDeadEnd(c)}
|
||||
title={isDeadEnd(c) ? "Aucun de ceux-ci / non répertorié" : c}
|
||||
onclick={() => pick(c)}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
exclude(c);
|
||||
}}
|
||||
>
|
||||
<img src={imageUrl(areaId, c)} alt={c} style="transform:{transform};" />
|
||||
<span class="cap">{isDeadEnd(c) ? "?" : c.split("_").slice(-1)[0]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tip">clic = choisir · clic droit = exclure</div>
|
||||
{:else if resolved}
|
||||
<div class="label done">Layout identifié.</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.viewer {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
background: #0f0c08;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
width: max-content;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 6px 3px 10px;
|
||||
background: var(--accent);
|
||||
color: #14110d;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
.bar .title {
|
||||
font-weight: 700;
|
||||
font-size: 0.9em;
|
||||
text-transform: capitalize;
|
||||
flex: 1;
|
||||
}
|
||||
.bar .close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #14110d;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.empty {
|
||||
padding: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
}
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 8px auto 4px;
|
||||
background: #000;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.stage .hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
}
|
||||
.ctrls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.ctrls button {
|
||||
background: #2a2218;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.ctrls button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ctrls button.on {
|
||||
border-color: var(--accent);
|
||||
background: rgba(200, 162, 74, 0.18);
|
||||
}
|
||||
.ctrls .deg {
|
||||
color: var(--muted);
|
||||
font-size: 0.8em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.crumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.crumbs .sep {
|
||||
color: var(--muted);
|
||||
}
|
||||
.crumbs .link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--accent2);
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.crumbs .back {
|
||||
margin-left: auto;
|
||||
background: #2a2218;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.label {
|
||||
padding: 2px 10px 0;
|
||||
font-size: 0.82em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.label.done {
|
||||
color: #7cfc00;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.thumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
max-width: 460px;
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thumb:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.thumb.dead {
|
||||
opacity: 0.6;
|
||||
border-style: dashed;
|
||||
}
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.thumb .cap {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: var(--accent);
|
||||
font-size: 0.75em;
|
||||
padding: 0 4px;
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
.tip {
|
||||
padding: 0 8px 8px;
|
||||
font-size: 0.72em;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
239
src/lib/Overlay.svelte
Normal file
@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getGuide,
|
||||
getConfig,
|
||||
getStatus,
|
||||
onStep,
|
||||
onStatus,
|
||||
onConfig,
|
||||
onFocus,
|
||||
onOverlayToggle,
|
||||
onOverlayLocked,
|
||||
onGuideReload,
|
||||
getTimer,
|
||||
onTimer,
|
||||
fmtTime,
|
||||
setOverlayHeight,
|
||||
restoreOverlayPosition,
|
||||
type GuideData,
|
||||
type Config,
|
||||
type Status,
|
||||
type RunTimer,
|
||||
} from "$lib/api";
|
||||
import StepView from "$lib/StepView.svelte";
|
||||
|
||||
let guide = $state<GuideData | null>(null);
|
||||
let config = $state<Config | null>(null);
|
||||
let status = $state<Status | null>(null);
|
||||
let timer = $state<RunTimer | null>(null);
|
||||
let focused = $state(false);
|
||||
let userHidden = $state(false);
|
||||
let locked = $state(true);
|
||||
// Measured height of the rendered overlay box; drives the OS window height so
|
||||
// the window is only as tall as its content (else the WM clamps it on-screen
|
||||
// and the overlay jumps to the middle when placed/restored near a screen edge).
|
||||
let boxHeight = $state(0);
|
||||
|
||||
// Whether the overlay content should be shown. When unlocked (editing the
|
||||
// position) it is always shown; otherwise it follows the focus/toggle rules.
|
||||
const visible = $derived.by(() => {
|
||||
if (!config) return false;
|
||||
if (!locked) return true;
|
||||
if (userHidden) return false;
|
||||
return config.overlay_only_when_focused ? focused : true;
|
||||
});
|
||||
|
||||
const current = $derived(config?.current_step ?? 0);
|
||||
const currentStep = $derived(guide?.steps[current] ?? null);
|
||||
const lookahead = $derived.by(() => {
|
||||
if (!guide || !config) return [];
|
||||
const n = config.lookahead;
|
||||
return guide.steps.slice(current + 1, current + 1 + n);
|
||||
});
|
||||
|
||||
// Recommended level for the area this step heads to.
|
||||
const recommendation = $derived.by(() => {
|
||||
if (!guide || !currentStep?.target_area) return null;
|
||||
return guide.areas[currentStep.target_area]?.recommendation ?? null;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const unlisten: Array<() => void> = [];
|
||||
(async () => {
|
||||
guide = await getGuide();
|
||||
config = await getConfig();
|
||||
status = await getStatus();
|
||||
timer = await getTimer();
|
||||
focused = status.game_focused;
|
||||
unlisten.push(
|
||||
await onStep((i) => {
|
||||
if (config) config = { ...config, current_step: i };
|
||||
}),
|
||||
);
|
||||
unlisten.push(await onStatus((s) => (status = s)));
|
||||
unlisten.push(await onConfig((c) => (config = c)));
|
||||
unlisten.push(await onFocus((f) => (focused = f)));
|
||||
unlisten.push(await onOverlayToggle(() => (userHidden = !userHidden)));
|
||||
unlisten.push(await onOverlayLocked((l) => (locked = l)));
|
||||
unlisten.push(await onGuideReload(async () => (guide = await getGuide())));
|
||||
unlisten.push(await onTimer((t) => (timer = t)));
|
||||
})();
|
||||
return () => unlisten.forEach((u) => u());
|
||||
});
|
||||
|
||||
// Keep the OS window height matched to the rendered content.
|
||||
let positionRestored = false;
|
||||
$effect(() => {
|
||||
if (visible && boxHeight > 0) {
|
||||
setOverlayHeight(Math.ceil(boxHeight) + 2);
|
||||
// Once the window is shrunk to content, restore its saved position (the WM
|
||||
// would clamp a still-tall window on-screen if we did this any earlier).
|
||||
if (!positionRestored) {
|
||||
positionRestored = true;
|
||||
setTimeout(() => restoreOverlayPosition(), 150);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if guide && config && visible}
|
||||
<div class="overlay" class:editing={!locked} style="font-size:{config.overlay_font_size}px;" bind:clientHeight={boxHeight}>
|
||||
{#if !locked}
|
||||
<div class="dragbar" data-tauri-drag-region>⠿ déplacer · reverrouille dans les réglages pour sauver</div>
|
||||
{/if}
|
||||
<div class="header">
|
||||
<span class="act">Act {currentStep ? currentStep.section + 1 : "?"}</span>
|
||||
{#if status}
|
||||
<span class="lvl">Lv {status.level || "?"}</span>
|
||||
{#if status.area_name}<span class="area">{status.area_name}</span>{/if}
|
||||
{/if}
|
||||
{#if recommendation && config.show_recommendation}
|
||||
<span class="rec">rec. {recommendation}</span>
|
||||
{/if}
|
||||
<span class="prog">{current + 1}/{guide.steps.length}</span>
|
||||
</div>
|
||||
|
||||
{#if config.timer_enabled && timer && timer.active}
|
||||
<div class="timer" class:paused={timer.manual_pause || timer.auto_paused || timer.afk_paused}>
|
||||
<span class="t-total">⏱ {fmtTime(timer.total_seconds + timer.act_seconds)}</span>
|
||||
<span class="t-act">acte {timer.current_act} · {fmtTime(timer.act_seconds)}</span>
|
||||
{#if timer.finished}
|
||||
<span class="t-flag done">terminé</span>
|
||||
{:else if timer.manual_pause}
|
||||
<span class="t-flag">pause</span>
|
||||
{:else if timer.afk_paused}
|
||||
<span class="t-flag">{focused ? "AFK" : "fenêtre"}</span>
|
||||
{:else if timer.auto_paused}
|
||||
<span class="t-flag">ville</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if timer.splits.some((s) => s > 0)}
|
||||
<div class="splits">
|
||||
{#each timer.splits as s, i}
|
||||
{#if s > 0}<span class="split">A{i} {fmtTime(s)}</span>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if currentStep}
|
||||
<StepView step={currentStep} areas={guide.areas} current={true} showOptionals={config.show_optionals} />
|
||||
{/if}
|
||||
{#each lookahead as step}
|
||||
<StepView {step} areas={guide.areas} showOptionals={config.show_optionals} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
background: rgba(15, 12, 8, 0.82);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.overlay.editing {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(200, 162, 74, 0.4);
|
||||
}
|
||||
.dragbar {
|
||||
background: var(--accent);
|
||||
color: #14110d;
|
||||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
padding: 2px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.header .act {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.header .lvl {
|
||||
color: #cfa;
|
||||
}
|
||||
.header .area {
|
||||
color: var(--accent2);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.header .rec {
|
||||
color: var(--muted);
|
||||
}
|
||||
.header .prog {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
}
|
||||
.timer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.timer .t-total {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.timer .t-act {
|
||||
color: var(--text);
|
||||
}
|
||||
.timer.paused .t-total,
|
||||
.timer.paused .t-act {
|
||||
color: var(--muted);
|
||||
}
|
||||
.timer .t-flag {
|
||||
margin-left: auto;
|
||||
color: #e0c84a;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.timer .t-flag.done {
|
||||
color: #7CFC00;
|
||||
}
|
||||
.splits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 2px 10px 4px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.78em;
|
||||
}
|
||||
.splits .split {
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
488
src/lib/Settings.svelte
Normal file
@ -0,0 +1,488 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getConfig,
|
||||
saveConfig,
|
||||
getStatus,
|
||||
getGuide,
|
||||
detectLogs,
|
||||
stepDelta,
|
||||
setOverlayLocked,
|
||||
toggleOverlay,
|
||||
toggleLayout,
|
||||
saveOverlayGeometry,
|
||||
getTimer,
|
||||
timerStart,
|
||||
timerPause,
|
||||
timerReset,
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
selectProfile,
|
||||
onStatus,
|
||||
onStep,
|
||||
onConfig,
|
||||
onGuideReload,
|
||||
onTimer,
|
||||
fmtTime,
|
||||
type Config,
|
||||
type Status,
|
||||
type GuideData,
|
||||
type RunTimer,
|
||||
} from "$lib/api";
|
||||
|
||||
let config = $state<Config | null>(null);
|
||||
let status = $state<Status | null>(null);
|
||||
let guide = $state<GuideData | null>(null);
|
||||
let timer = $state<RunTimer | null>(null);
|
||||
let detected = $state<string[]>([]);
|
||||
let overlayUnlocked = $state(false);
|
||||
|
||||
// --- character profiles ---
|
||||
let newProfileName = $state("");
|
||||
let newProfileLeagueStart = $state(true);
|
||||
|
||||
async function refreshDetect() {
|
||||
detected = await detectLogs();
|
||||
}
|
||||
|
||||
async function addProfile() {
|
||||
const name = newProfileName.trim();
|
||||
if (!name) return;
|
||||
await createProfile(name, newProfileLeagueStart);
|
||||
newProfileName = "";
|
||||
// config (profiles + active_character) is pushed back via config://update
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (config) await saveConfig($state.snapshot(config));
|
||||
}
|
||||
|
||||
async function toggleLock() {
|
||||
overlayUnlocked = !overlayUnlocked;
|
||||
await setOverlayLocked(!overlayUnlocked); // locked = click-through
|
||||
if (!overlayUnlocked) await saveOverlayGeometry();
|
||||
if (overlayUnlocked) config = await getConfig();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unlisten: Array<() => void> = [];
|
||||
(async () => {
|
||||
config = await getConfig();
|
||||
status = await getStatus();
|
||||
guide = await getGuide();
|
||||
timer = await getTimer();
|
||||
await refreshDetect();
|
||||
unlisten.push(await onStatus((s) => (status = s)));
|
||||
unlisten.push(
|
||||
await onStep((i) => {
|
||||
if (config) config = { ...config, current_step: i };
|
||||
}),
|
||||
);
|
||||
unlisten.push(await onConfig((c) => (config = c)));
|
||||
unlisten.push(await onGuideReload(async () => (guide = await getGuide())));
|
||||
unlisten.push(await onTimer((t) => (timer = t)));
|
||||
})();
|
||||
return () => unlisten.forEach((u) => u());
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>Exile UI <small>· Act-Tracker (PoE2)</small></h1>
|
||||
|
||||
{#if status}
|
||||
<section class="status">
|
||||
<div><b>{status.character || "—"}</b> ({status.class || "?"}) · Lv {status.level || "?"}</div>
|
||||
<div>
|
||||
Zone : <span class="hl">{status.area_name || status.area_id || "—"}</span>
|
||||
{#if status.area_level}· niveau zone {status.area_level}{/if}
|
||||
</div>
|
||||
<div class="muted">
|
||||
Jeu focus : {status.game_focused ? "✔️" : "✖️"} · areaID : {status.area_id || "—"}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if config && guide}
|
||||
<section>
|
||||
<h2>Guide</h2>
|
||||
<div class="row">
|
||||
<button onclick={() => stepDelta(-1)}>◀ Précédent</button>
|
||||
<span class="prog">Étape {config.current_step + 1} / {guide.steps.length}</span>
|
||||
<button onclick={() => stepDelta(1)}>Suivant ▶</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button onclick={() => toggleOverlay()}>Afficher/Masquer overlay</button>
|
||||
<button onclick={toggleLock}>
|
||||
{overlayUnlocked ? "🔒 Verrouiller (click-through)" : "🔓 Déverrouiller (déplacer)"}
|
||||
</button>
|
||||
</div>
|
||||
{#if overlayUnlocked}
|
||||
<p class="muted">Overlay déverrouillé : déplace/redimensionne la fenêtre, puis reverrouille pour sauvegarder.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Profils <small>· un profil = un personnage</small></h2>
|
||||
<p class="muted">
|
||||
Crée un profil par personnage (le nom doit correspondre au nom du perso
|
||||
en jeu). La progression du guide est mémorisée <b>par profil</b>, et le
|
||||
profil devient actif tout seul quand le log détecte ce personnage.
|
||||
</p>
|
||||
|
||||
<div class="status">
|
||||
Profil actif :
|
||||
<span class="hl">{config.active_character || "— aucun —"}</span>
|
||||
{#if status?.character && status.character !== config.active_character}
|
||||
· perso en jeu : <span class="hl">{status.character}</span>
|
||||
{#if !config.profiles.some((p) => p.name === status?.character)}
|
||||
<span class="muted">(pas de profil)</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if config.profiles.length}
|
||||
<div class="char-list">
|
||||
{#each config.profiles as p}
|
||||
<div class="char" class:active={config.active_character === p.name}>
|
||||
<button class="char-pick" onclick={() => selectProfile(p.name)}>
|
||||
<b>{p.name}</b>
|
||||
<span class="muted">
|
||||
étape {p.current_step + 1} · {p.league_start ? "league-start" : "standard"}
|
||||
</span>
|
||||
</button>
|
||||
<button class="del" title="Supprimer ce profil" onclick={() => deleteProfile(p.name)}>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">Aucun profil pour l'instant.</p>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newProfileName}
|
||||
placeholder="nom du personnage"
|
||||
style="flex:1"
|
||||
onkeydown={(e) => e.key === "Enter" && addProfile()}
|
||||
/>
|
||||
<label class="ck" style="white-space:nowrap">
|
||||
<input type="checkbox" bind:checked={newProfileLeagueStart} />
|
||||
league-start
|
||||
</label>
|
||||
<button onclick={addProfile} disabled={!newProfileName.trim()}>+ Créer</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Layouts de zones <small>· identificateur de layout (PoE2)</small></h2>
|
||||
<p class="muted">
|
||||
Ouvre une fenêtre interactive affichant les layouts possibles de la zone
|
||||
courante. Clique celui qui correspond à ce que tu vois pour l'affiner, et
|
||||
pivote/miroir l'image pour l'aligner sur l'orientation en jeu (chaque
|
||||
instance de zone est tournée aléatoirement).
|
||||
</p>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.feature_layouts} onchange={save} />
|
||||
Activer le viewer de layouts
|
||||
</label>
|
||||
{#if config.feature_layouts}
|
||||
<div class="row">
|
||||
<button onclick={() => toggleLayout()}>Afficher / masquer la fenêtre layouts</button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Hotkey « layouts »
|
||||
<input type="text" bind:value={config.hotkey_layout} onchange={save} />
|
||||
</label>
|
||||
<label>Taille image (px)
|
||||
<input type="number" min="160" max="800" bind:value={config.layout_size} onchange={save} />
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted">
|
||||
La fenêtre est interactive (elle prend le focus). Si KWin ne la garde pas
|
||||
au-dessus du jeu, ajoute une règle de fenêtre pour « Exile UI Layouts ».
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Timer de campagne</h2>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.timer_enabled} onchange={save} />
|
||||
Activer le timer
|
||||
</label>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.timer_pause_in_town} onchange={save} />
|
||||
Mettre en pause automatiquement en ville / hideout
|
||||
</label>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.timer_afk_enabled} onchange={save} />
|
||||
Anti-AFK : mettre en pause si la souris ne bouge plus
|
||||
</label>
|
||||
{#if config.timer_afk_enabled}
|
||||
<label>Délai avant pause AFK (secondes)
|
||||
<input type="number" min="10" bind:value={config.timer_afk_seconds} onchange={save} />
|
||||
</label>
|
||||
{/if}
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.timer_pause_unfocused} onchange={save} />
|
||||
Mettre en pause quand PoE2 n'a pas le focus (alt-tab)
|
||||
</label>
|
||||
{#if config.timer_enabled}
|
||||
{#if timer && timer.active}
|
||||
<div class="status timer-box">
|
||||
<div>
|
||||
Total : <b class="hl">{fmtTime(timer.total_seconds + timer.act_seconds)}</b>
|
||||
· acte {timer.current_act} : {fmtTime(timer.act_seconds)}
|
||||
{#if timer.finished}<span class="flag done">terminé</span>
|
||||
{:else if timer.manual_pause}<span class="flag">en pause</span>
|
||||
{:else if timer.afk_paused}<span class="flag">{status?.game_focused ? "AFK" : "fenêtre (pas de focus)"}</span>
|
||||
{:else if timer.auto_paused}<span class="flag">pause auto (ville)</span>{/if}
|
||||
</div>
|
||||
{#if timer.run_name}<div class="muted">Run : {timer.run_name}</div>{/if}
|
||||
{#if timer.splits.some((s) => s > 0)}
|
||||
<div class="splits">
|
||||
{#each timer.splits as s, i}
|
||||
{#if s > 0}<span class="split">A{i}: {fmtTime(s)}</span>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">Aucun run en cours. Le timer démarre tout seul en entrant dans la première zone de campagne, ou via « Démarrer ».</p>
|
||||
{/if}
|
||||
<div class="row">
|
||||
<button onclick={() => timerStart()}>▶ Démarrer / redémarrer</button>
|
||||
<button onclick={() => timerPause()} disabled={!timer || !timer.active}>
|
||||
{timer && timer.manual_pause ? "Reprendre" : "⏸ Pause"}
|
||||
</button>
|
||||
<button onclick={() => timerReset()} disabled={!timer || !timer.active}>↺ Reset</button>
|
||||
</div>
|
||||
<label>Hotkey « pause timer »
|
||||
<input type="text" bind:value={config.hotkey_timer_pause} onchange={save} />
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Fichier de log (Client.txt)</h2>
|
||||
<div class="row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.log_path}
|
||||
placeholder="/chemin/vers/Path of Exile 2/logs/Client.txt"
|
||||
style="flex:1"
|
||||
/>
|
||||
<button onclick={save}>Enregistrer</button>
|
||||
</div>
|
||||
{#if detected.length}
|
||||
<label>Détectés :
|
||||
<select onchange={(e) => { if (config) { config.log_path = (e.target as HTMLSelectElement).value; save(); } }}>
|
||||
<option value="">— choisir —</option>
|
||||
{#each detected as d}<option value={d}>{d}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{:else}
|
||||
<p class="muted">Aucun log détecté automatiquement. Renseigne le chemin manuellement.</p>
|
||||
{/if}
|
||||
<button onclick={refreshDetect}>Re-scanner</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Réglages</h2>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.overlay_only_when_focused} onchange={save} />
|
||||
Afficher l'overlay uniquement quand le jeu est au premier plan
|
||||
</label>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.league_start} onchange={save} />
|
||||
Mode « league-start » (profil) — sinon branche non-league-start du guide
|
||||
</label>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.show_optionals} onchange={save} />
|
||||
Afficher le contenu optionnel (profil) — loot/quêtes/rencontres « + »
|
||||
</label>
|
||||
<label class="ck">
|
||||
<input type="checkbox" bind:checked={config.show_recommendation} onchange={save} />
|
||||
Afficher les niveaux recommandés par zone
|
||||
</label>
|
||||
<label>Titre fenêtre du jeu (détection focus)
|
||||
<input type="text" bind:value={config.poe_window_match} onchange={save} />
|
||||
</label>
|
||||
<div class="grid">
|
||||
<label>Largeur overlay (px)
|
||||
<input type="number" bind:value={config.overlay_width} onchange={save} />
|
||||
</label>
|
||||
<label>Taille police (px)
|
||||
<input type="number" bind:value={config.overlay_font_size} onchange={save} />
|
||||
</label>
|
||||
<label>Étapes à anticiper
|
||||
<input type="number" min="0" max="6" bind:value={config.lookahead} onchange={save} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Hotkey « suivant »
|
||||
<input type="text" bind:value={config.hotkey_next} onchange={save} />
|
||||
</label>
|
||||
<label>Hotkey « précédent »
|
||||
<input type="text" bind:value={config.hotkey_prev} onchange={save} />
|
||||
</label>
|
||||
<label>Hotkey « afficher/masquer »
|
||||
<input type="text" bind:value={config.hotkey_toggle} onchange={save} />
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted">Format hotkey : ex. <code>Alt+X</code>, <code>Control+Shift+G</code>, <code>F8</code>.</p>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
padding: 16px 22px;
|
||||
max-width: 760px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.4em;
|
||||
color: var(--accent);
|
||||
}
|
||||
h1 small {
|
||||
color: var(--muted);
|
||||
font-size: 0.6em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.05em;
|
||||
color: var(--accent2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.status {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hl {
|
||||
color: var(--accent2);
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
label.ck {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select {
|
||||
width: 100%;
|
||||
background: #100d09;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
label.ck input {
|
||||
width: auto;
|
||||
}
|
||||
button {
|
||||
background: #2a2218;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.prog {
|
||||
color: var(--muted);
|
||||
}
|
||||
.timer-box {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.splits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.split {
|
||||
background: #100d09;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.flag {
|
||||
margin-left: 8px;
|
||||
color: #e0c84a;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.flag.done {
|
||||
color: #7cfc00;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
code {
|
||||
background: #100d09;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.char-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.char {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
.char.active .char-pick {
|
||||
border-color: var(--accent);
|
||||
background: rgba(200, 162, 74, 0.12);
|
||||
}
|
||||
button.char-pick {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
gap: 10px;
|
||||
}
|
||||
button.del {
|
||||
color: #e05555;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
</style>
|
||||
92
src/lib/StepView.svelte
Normal file
@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import type { Area, Step } from "./api";
|
||||
import { renderLine } from "./markup";
|
||||
|
||||
let {
|
||||
step,
|
||||
areas,
|
||||
current = false,
|
||||
showOptionals = true,
|
||||
}: {
|
||||
step: Step;
|
||||
areas: Record<string, Area>;
|
||||
current?: boolean;
|
||||
showOptionals?: boolean;
|
||||
} = $props();
|
||||
|
||||
const lines = $derived(
|
||||
step.lines
|
||||
.map((l) => renderLine(l, areas))
|
||||
.filter((l) => showOptionals || l.kind !== "optional"),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="step" class:current>
|
||||
{#each lines as line}
|
||||
<div class="line {line.kind}" class:indent={line.indent > 0}>
|
||||
{@html line.html}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.step {
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.step.current {
|
||||
border-left-color: var(--accent);
|
||||
background: rgba(200, 162, 74, 0.08);
|
||||
}
|
||||
.line {
|
||||
line-height: 1.35;
|
||||
margin: 1px 0;
|
||||
}
|
||||
.line.hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.line.optional {
|
||||
color: #9fb88f;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.line.info {
|
||||
color: #d8a0a0;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.line.indent {
|
||||
padding-left: 16px;
|
||||
}
|
||||
:global(.step .area) {
|
||||
color: var(--accent2);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
:global(.step .chip) {
|
||||
display: inline-block;
|
||||
padding: 0 5px;
|
||||
margin: 0 1px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82em;
|
||||
vertical-align: middle;
|
||||
background: #2a2218;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
:global(.step .chip.quest) {
|
||||
background: #2a2030;
|
||||
border-color: #5a4a6a;
|
||||
color: #c9a6e0;
|
||||
}
|
||||
:global(.step .chip.img) {
|
||||
background: #23282e;
|
||||
border-color: #3a4654;
|
||||
color: #a9c4dd;
|
||||
}
|
||||
:global(.step .icon) {
|
||||
height: 1.25em;
|
||||
width: auto;
|
||||
vertical-align: -0.25em;
|
||||
margin: 0 1px;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
</style>
|
||||
140
src/lib/api.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
export interface Config {
|
||||
log_path: string | null;
|
||||
poe_window_match: string;
|
||||
overlay_only_when_focused: boolean;
|
||||
overlay_x: number;
|
||||
overlay_y: number;
|
||||
overlay_width: number;
|
||||
overlay_font_size: number;
|
||||
hotkey_next: string;
|
||||
hotkey_prev: string;
|
||||
hotkey_toggle: string;
|
||||
hotkey_timer_pause: string;
|
||||
hotkey_layout: string;
|
||||
feature_layouts: boolean;
|
||||
layout_x: number;
|
||||
layout_y: number;
|
||||
layout_size: number;
|
||||
timer_enabled: boolean;
|
||||
timer_pause_in_town: boolean;
|
||||
timer_afk_enabled: boolean;
|
||||
timer_afk_seconds: number;
|
||||
timer_pause_unfocused: boolean;
|
||||
current_step: number;
|
||||
show_optionals: boolean;
|
||||
league_start: boolean;
|
||||
lookahead: number;
|
||||
show_recommendation: boolean;
|
||||
active_character: string | null;
|
||||
profiles: Profile[];
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
current_step: number;
|
||||
league_start: boolean;
|
||||
show_optionals: boolean;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
character: string;
|
||||
class: string;
|
||||
level: number;
|
||||
area_id: string;
|
||||
area_name: string;
|
||||
area_level: number;
|
||||
area_seed: string;
|
||||
act: number;
|
||||
game_focused: boolean;
|
||||
}
|
||||
|
||||
export interface Area {
|
||||
id: string;
|
||||
name: string;
|
||||
recommendation: string | null;
|
||||
group: number;
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
lines: string[];
|
||||
section: number;
|
||||
target_area: string | null;
|
||||
}
|
||||
|
||||
export interface GuideData {
|
||||
steps: Step[];
|
||||
areas: Record<string, Area>;
|
||||
section_count: number;
|
||||
}
|
||||
|
||||
export interface RunTimer {
|
||||
active: boolean;
|
||||
finished: boolean;
|
||||
manual_pause: boolean;
|
||||
auto_paused: boolean;
|
||||
afk_paused: boolean;
|
||||
total_seconds: number;
|
||||
act_seconds: number;
|
||||
current_act: number;
|
||||
splits: number[];
|
||||
run_name: string | null;
|
||||
}
|
||||
|
||||
export const getConfig = () => invoke<Config>("get_config");
|
||||
export const saveConfig = (cfg: Config) => invoke("save_config", { new: cfg });
|
||||
export const getStatus = () => invoke<Status>("get_status");
|
||||
export const getGuide = () => invoke<GuideData>("get_guide");
|
||||
export const getGems = () => invoke<Record<string, Record<string, number[]>>>("get_gems");
|
||||
export const detectLogs = () => invoke<string[]>("detect_logs");
|
||||
export const getTimer = () => invoke<RunTimer>("get_timer");
|
||||
export const timerStart = () => invoke("timer_start");
|
||||
export const timerPause = () => invoke("timer_pause");
|
||||
export const timerReset = () => invoke("timer_reset");
|
||||
export const stepDelta = (delta: number) => invoke("step_delta", { delta });
|
||||
export const stepGoto = (newIndex: number) => invoke("step_goto", { new: newIndex });
|
||||
export const createProfile = (name: string, leagueStart: boolean) =>
|
||||
invoke("create_profile", { name, leagueStart });
|
||||
export const deleteProfile = (name: string) => invoke("delete_profile", { name });
|
||||
export const selectProfile = (name: string) => invoke("select_profile", { name });
|
||||
export const setOverlayLocked = (locked: boolean) => invoke("set_overlay_locked", { locked });
|
||||
export const toggleOverlay = () => invoke("toggle_overlay");
|
||||
export const saveOverlayGeometry = () => invoke("save_overlay_geometry");
|
||||
export const setOverlayHeight = (height: number) => invoke("set_overlay_height", { height });
|
||||
export const restoreOverlayPosition = () => invoke("restore_overlay_position");
|
||||
export const toggleLayout = () => invoke("toggle_layout");
|
||||
export const setLayoutSize = (width: number, height: number) =>
|
||||
invoke("set_layout_size", { width, height });
|
||||
export const saveLayoutGeometry = () => invoke("save_layout_geometry");
|
||||
export const startLayoutDrag = () => invoke("start_layout_drag");
|
||||
export const endLayoutDrag = () => invoke("end_layout_drag");
|
||||
|
||||
// Event helpers ------------------------------------------------------------
|
||||
export const onStatus = (cb: (s: Status) => void): Promise<UnlistenFn> =>
|
||||
listen<Status>("status://update", (e) => cb(e.payload));
|
||||
export const onStep = (cb: (i: number) => void): Promise<UnlistenFn> =>
|
||||
listen<number>("tracker://step", (e) => cb(e.payload));
|
||||
export const onConfig = (cb: (c: Config) => void): Promise<UnlistenFn> =>
|
||||
listen<Config>("config://update", (e) => cb(e.payload));
|
||||
export const onFocus = (cb: (f: boolean) => void): Promise<UnlistenFn> =>
|
||||
listen<boolean>("focus://update", (e) => cb(e.payload));
|
||||
export const onOverlayToggle = (cb: () => void): Promise<UnlistenFn> =>
|
||||
listen("overlay://toggle", () => cb());
|
||||
export const onOverlayLocked = (cb: (locked: boolean) => void): Promise<UnlistenFn> =>
|
||||
listen<boolean>("overlay://locked", (e) => cb(e.payload));
|
||||
export const onGuideReload = (cb: () => void): Promise<UnlistenFn> =>
|
||||
listen("guide://reload", () => cb());
|
||||
export const onTimer = (cb: (t: RunTimer) => void): Promise<UnlistenFn> =>
|
||||
listen<RunTimer>("timer://update", (e) => cb(e.payload));
|
||||
|
||||
/** Format seconds as h:mm:ss or m:ss. */
|
||||
export function fmtTime(total: number): string {
|
||||
const s = Math.floor(total);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
||||
}
|
||||
75
src/lib/layouts.ts
Normal file
@ -0,0 +1,75 @@
|
||||
// Zone-layout decision tree, ported from the AHK `act-decoder`.
|
||||
//
|
||||
// The bundled PoE2 layout images (static/layouts/) are named `<areaID> <path>.jpg`
|
||||
// where the path is a decision tree encoded as underscore-separated segments:
|
||||
// "1", "2", "3" = the candidate layouts you first see entering a zone
|
||||
// "3_1", "3_2" = refinements of "3" revealed as you explore deeper
|
||||
// "x", "x_x" = "none of these / not catalogued" dead-ends
|
||||
// You pick the candidate matching what you see in-game, then keep refining until
|
||||
// no children remain. (On disk the space is stored as "~" for URL-safe serving.)
|
||||
|
||||
export type LayoutManifest = Record<string, string[]>; // areaID -> sorted paths
|
||||
|
||||
let manifestPromise: Promise<LayoutManifest> | null = null;
|
||||
|
||||
/** Load (once) the areaID -> paths manifest generated at build time. */
|
||||
export function loadManifest(): Promise<LayoutManifest> {
|
||||
if (!manifestPromise) {
|
||||
manifestPromise = fetch("/layouts/index.json")
|
||||
.then((r) => (r.ok ? r.json() : {}))
|
||||
.catch(() => ({}));
|
||||
}
|
||||
return manifestPromise;
|
||||
}
|
||||
|
||||
/** URL of the image for a given area + decision path. */
|
||||
export function imageUrl(areaId: string, path: string): string {
|
||||
return `/layouts/${areaId}~${path}.jpg`;
|
||||
}
|
||||
|
||||
const depth = (p: string): number => (p === "" ? 0 : p.split("_").length);
|
||||
|
||||
/** Order paths numerically per segment, "x" sorting last. */
|
||||
function sortPaths(a: string, b: string): number {
|
||||
const sa = a.split("_");
|
||||
const sb = b.split("_");
|
||||
const n = Math.min(sa.length, sb.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (sa[i] !== sb[i]) {
|
||||
const na = sa[i] === "x" ? Infinity : parseInt(sa[i], 10);
|
||||
const nb = sb[i] === "x" ? Infinity : parseInt(sb[i], 10);
|
||||
return na - nb;
|
||||
}
|
||||
}
|
||||
return sa.length - sb.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct children of `path` ("" = root) among `paths`: entries one segment
|
||||
* deeper that extend `path`.
|
||||
*/
|
||||
export function childrenOf(paths: string[], path: string): string[] {
|
||||
const want = depth(path) + 1;
|
||||
const prefix = path === "" ? "" : path + "_";
|
||||
return paths
|
||||
.filter((p) => p !== path && depth(p) === want && (path === "" || p.startsWith(prefix)))
|
||||
.sort(sortPaths);
|
||||
}
|
||||
|
||||
/** A path is an "x" (none-of-these / not catalogued) marker. */
|
||||
export function isDeadEnd(path: string): boolean {
|
||||
return path.split("_")[0] === "x";
|
||||
}
|
||||
|
||||
// Zones whose layout placement is fixed (or where rotation gave wrong results in
|
||||
// the original), so the viewer hides its rotation controls. Ported from the AHK
|
||||
// `rota_block`; keys there may be "<areaID>" or "<areaID> <path>" — we block the
|
||||
// whole area if any key matches (a single orientation is applied per zone).
|
||||
const ROTA_BLOCK_AREAS = new Set([
|
||||
"g1_2", "g1_4", "g1_7", "g1_8", "g1_11", "g1_12", "g1_14", "g1_15",
|
||||
"g2_2", "g2_4_3", "g2_6", "g3_11", "g3_16",
|
||||
]);
|
||||
|
||||
export function rotationBlocked(areaId: string): boolean {
|
||||
return ROTA_BLOCK_AREAS.has(areaId);
|
||||
}
|
||||
129
src/lib/markup.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import type { Area } from "./api";
|
||||
|
||||
// Named colors used by the guide markup; anything else that is 3/6 hex chars
|
||||
// is treated as a hex color.
|
||||
const COLOR_NAMES: Record<string, string> = {
|
||||
red: "#e05555",
|
||||
lime: "#7CFC00",
|
||||
aqua: "#55e0e0",
|
||||
yellow: "#e0c84a",
|
||||
green: "#5fbf5f",
|
||||
white: "#ffffff",
|
||||
orange: "#e08a3c",
|
||||
};
|
||||
|
||||
function resolveColor(name: string): string {
|
||||
if (COLOR_NAMES[name]) return COLOR_NAMES[name];
|
||||
if (/^[0-9a-fA-F]{6}$/.test(name) || /^[0-9a-fA-F]{3}$/.test(name)) return "#" + name;
|
||||
return "inherit";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Icon names available under static/icons/leveling/ (from the original AHK
|
||||
// "leveling tracker" assets). Only these render as real <img>; anything else
|
||||
// falls back to a text chip so unknown markup never shows a broken image.
|
||||
const ICONS = new Set([
|
||||
"0", "1", "2", "3", "4", "5", "6", "7",
|
||||
"arena", "artificer", "b-rune", "checkpoint", "craft", "exa", "flasks",
|
||||
"gcp", "hideout", "in-out", "in-out2", "jeweller", "lab", "portal",
|
||||
"quest", "quest_2", "regal", "ring", "rune", "skill", "skill2", "skip",
|
||||
"spirit", "spirit2", "support", "support2", "town", "waypoint",
|
||||
]);
|
||||
|
||||
// Underscores stand in for spaces in plain guide text.
|
||||
function plain(text: string, color: string): string {
|
||||
const t = escapeHtml(text.replace(/_/g, " "));
|
||||
if (!t) return "";
|
||||
if (color === "inherit") return t;
|
||||
return `<span style="color:${color}">${t}</span>`;
|
||||
}
|
||||
|
||||
export interface RenderedLine {
|
||||
html: string;
|
||||
kind: "normal" | "hint" | "optional" | "info";
|
||||
indent: number;
|
||||
}
|
||||
|
||||
const TOKEN_RE = /\(([a-z]+)(?::([^)]*))?\)|areaid([a-z0-9_]+)/gi;
|
||||
|
||||
/** Render a single guide line (with embedded markup) into HTML + metadata. */
|
||||
export function renderLine(raw: string, areas: Record<string, Area>): RenderedLine {
|
||||
let line = raw;
|
||||
|
||||
// Classify the line for styling.
|
||||
let kind: RenderedLine["kind"] = "normal";
|
||||
let indent = 0;
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.startsWith("(hint)")) {
|
||||
kind = "hint";
|
||||
line = line.slice("(hint)".length);
|
||||
// leading underscores after (hint) indicate indentation
|
||||
const m = line.match(/^_+/);
|
||||
if (m) {
|
||||
indent = 1;
|
||||
line = line.slice(m[0].length);
|
||||
}
|
||||
} else if (lower.startsWith("optional:")) {
|
||||
kind = "optional";
|
||||
} else if (lower.includes("info:")) {
|
||||
kind = "info";
|
||||
}
|
||||
|
||||
// Pull out the trailing " ;; area name" annotation.
|
||||
let areaName: string | null = null;
|
||||
const sc = line.indexOf(";;");
|
||||
if (sc >= 0) {
|
||||
areaName = line.slice(sc + 2).trim();
|
||||
line = line.slice(0, sc).trim();
|
||||
}
|
||||
|
||||
let out = "";
|
||||
let color = "inherit";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
TOKEN_RE.lastIndex = 0;
|
||||
while ((m = TOKEN_RE.exec(line)) !== null) {
|
||||
out += plain(line.slice(last, m.index), color);
|
||||
last = m.index + m[0].length;
|
||||
|
||||
const kindTok = m[1]?.toLowerCase();
|
||||
const val = m[2] ?? "";
|
||||
const areaId = m[3];
|
||||
|
||||
if (areaId !== undefined) {
|
||||
const name = areaName || areas[areaId]?.name || areaId.replace(/_/g, " ");
|
||||
out += `<span class="area">${escapeHtml(name)}</span>`;
|
||||
areaName = null; // consume once
|
||||
} else if (kindTok === "color") {
|
||||
color = resolveColor(val.trim());
|
||||
} else if (kindTok === "img") {
|
||||
const name = val.trim();
|
||||
const label = name.replace(/_/g, " ");
|
||||
if (ICONS.has(name.toLowerCase())) {
|
||||
out += `<img class="icon" src="/icons/leveling/${encodeURIComponent(
|
||||
name.toLowerCase()
|
||||
)}.png" alt="${escapeHtml(label)}" title="${escapeHtml(label)}" />`;
|
||||
} else {
|
||||
out += `<span class="chip img" data-img="${escapeHtml(name)}">${escapeHtml(
|
||||
label
|
||||
)}</span>`;
|
||||
}
|
||||
} else if (kindTok === "quest") {
|
||||
out += `<span class="chip quest">${escapeHtml(val.replace(/_/g, " "))}</span>`;
|
||||
} else if (kindTok === "emph") {
|
||||
// ignored: treated as a plain emphasis marker
|
||||
}
|
||||
// (hint) handled at line level above
|
||||
}
|
||||
out += plain(line.slice(last), color);
|
||||
|
||||
// Any leftover area name annotation with no areaid token: append it.
|
||||
if (areaName) {
|
||||
out += ` <span class="area">${escapeHtml(areaName)}</span>`;
|
||||
}
|
||||
|
||||
return { html: out, kind, indent };
|
||||
}
|
||||
6
src/routes/+layout.svelte
Normal file
@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
5
src/routes/+layout.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
17
src/routes/+page.svelte
Normal file
@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import Settings from "$lib/Settings.svelte";
|
||||
import Overlay from "$lib/Overlay.svelte";
|
||||
import Layouts from "$lib/Layouts.svelte";
|
||||
|
||||
// All windows load the same SPA entry; pick the view by window label.
|
||||
const label = getCurrentWindow().label;
|
||||
</script>
|
||||
|
||||
{#if label === "overlay"}
|
||||
<Overlay />
|
||||
{:else if label === "layout"}
|
||||
<Layouts />
|
||||
{:else}
|
||||
<Settings />
|
||||
{/if}
|
||||
BIN
static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/icons/leveling/0.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/icons/leveling/1.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
static/icons/leveling/2.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/icons/leveling/3.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/icons/leveling/4.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/icons/leveling/5.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/icons/leveling/6.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/icons/leveling/7.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/icons/leveling/arena.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
static/icons/leveling/artificer.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
static/icons/leveling/b-rune.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
static/icons/leveling/checkpoint.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
static/icons/leveling/craft.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
static/icons/leveling/exa.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
static/icons/leveling/flasks.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
static/icons/leveling/gcp.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
static/icons/leveling/hideout.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
static/icons/leveling/in-out.png
Normal file
|
After Width: | Height: | Size: 517 B |
BIN
static/icons/leveling/in-out2.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
static/icons/leveling/jeweller.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
static/icons/leveling/lab.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
static/icons/leveling/portal.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
static/icons/leveling/quest.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
static/icons/leveling/quest_2.png
Normal file
|
After Width: | Height: | Size: 561 B |
BIN
static/icons/leveling/regal.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/icons/leveling/ring.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
static/icons/leveling/rune.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
static/icons/leveling/skill.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
static/icons/leveling/skill2.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
static/icons/leveling/skip.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
static/icons/leveling/spirit.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
static/icons/leveling/spirit2.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
static/icons/leveling/support.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
static/icons/leveling/support2.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
static/icons/leveling/town.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
static/icons/leveling/waypoint.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
static/layouts/explanation.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
static/layouts/g1_11~1.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
static/layouts/g1_11~2.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
static/layouts/g1_12~1.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
static/layouts/g1_12~2.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
static/layouts/g1_13_1~1.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
static/layouts/g1_13_1~1_1.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
static/layouts/g1_13_1~2.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
static/layouts/g1_13_1~2_1.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
static/layouts/g1_13_1~2_1_1.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
static/layouts/g1_13_1~2_1_2.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
static/layouts/g1_13_1~2_1_3.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
static/layouts/g1_13_1~2_2.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |