feat: adapt Exile-ui for linux
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"
|
||||
]
|
||||
}
|
||||
}
|
||||