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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 30 additions & 22 deletions dungeons/zulfarrak.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,26 +210,6 @@
],
"radius": 15
},
{
"entries": [5648, 5650, 5650, 5650],
"positions": [
{"x": 1660.7, "y": 901.2, "z": 8.9},
{"x": 1664.1, "y": 911.3, "z": 9},
{"x": 1669.7, "y": 896.5, "z": 9},
{"x": 1672.6, "y": 906.4, "z": 9}
],
"radius": 15
},
{
"entries": [7246, 5648, 5648, 5649],
"positions": [
{"x": 1662.3, "y": 929.7, "z": 9.1},
{"x": 1661.9, "y": 938.5, "z": 9.2},
{"x": 1660.8, "y": 937.6, "z": 9.2},
{"x": 1656.8, "y": 943, "z": 9.1}
],
"radius": 15
},
{
"entries": [5648, 7247],
"positions": [
Expand Down Expand Up @@ -288,7 +268,33 @@
],
"period_s": 126,
"source": "creature_movement of spawn 155626 (Sandfury Shadowhunter, movement type 2, 45 points, 316 yd at 2.5 yd/s); its creature_groups members are the Shadowhunter 44170 and the rare Zerillis 10082 (skipped). Live: the pair (0xAC8A, 0x25FEA) walked 1684 920 -\u003e 1668 917 on the map walk and joined the pull of 1680 871 at 1651 911 (zf1-hires-3 run 4)."
}
},
"approach": [
{"x": 1620, "y": 880, "z": 8.97}
]
},
{
"entries": [5648, 5650, 5650, 5650],
"positions": [
{"x": 1660.7, "y": 901.2, "z": 8.9},
{"x": 1664.1, "y": 911.3, "z": 9},
{"x": 1669.7, "y": 896.5, "z": 9},
{"x": 1672.6, "y": 906.4, "z": 9}
],
"radius": 15,
"approach": [
{"x": 1630, "y": 878, "z": 8.93}
]
},
{
"entries": [7246, 5648, 5648, 5649],
"positions": [
{"x": 1662.3, "y": 929.7, "z": 9.1},
{"x": 1661.9, "y": 938.5, "z": 9.2},
{"x": 1660.8, "y": 937.6, "z": 9.2},
{"x": 1656.8, "y": 943, "z": 9.1}
],
"radius": 15
}
],
"skip": [7269, 10080, 10082],
Expand All @@ -297,7 +303,9 @@
"ZF-1 (2026-10-14) and TestZulfarrakRouteHasNoUnplannedPack: four mobs of the export stood within 20 yd of this leg's navmesh route in no pull group, besides the pack of rows 38009-38011: the wandering Blood Drinker 45709 (1685.9 888.8, added to the group at 1681 872), the Blood Drinker 81452 (1713.7 784.1) and the Witch Doctor 81468 (1776.9 813.7, on the slope), now groups of their own.",
"The road x 1485-1535 by direct hops (ZF-2: 1485.6 825.5 -\u003e 1498.7 829.9 -\u003e 1506 844.9, z 8.9-10.2; ZF-1 run 4: -\u003e 1520 840 -\u003e 1535 834): the agent's navmesh routes this stretch north over the ramp (1561 955), which the server refuses or which leads into the Farraki arena; the hops were walked live in both runs (ZF-1 kept the 1505 846 via; 1506 844.9 is 1.5 yd from it). Plan check (#187): the hops to 1520 840 and 1535 834 are plain waypoints now (complete straight paths of 15 and 16 yd); the second was refused no-los when a fight begun by the pack at 1535 826 had dragged the lead bot onto the ledge at 1534 815. 1535 834 is the leg's `to`, so it is not a via of its own.",
"The pack at 1680.8 871.5 (spawns 44225 + 45708, linked, and the wandering Blood Drinker 45709) is three mobs, but every pull of it in ZF-1 hires-3 drew far more: the pair of Shadowhunters (7246, spawn 155626 with 44170) walks a 316 yd loop through 1643-1707 874-930 that passes 10 yd from the pack (its waypoints 22-24) and through the Shadowcaster + Soul Eater pair at 1642-1645 902-906; in run 4 a pet aggroed that pair from the pull spot (33 yd), the Shadowhunters joined, then the camp of 1660-1673 896-911 (Shadowcaster 44162, Witch Doctors 44164, 44165, 44169) came on the tank: twelve mobs, wipe in 34 s. So the pair is now a pull group before the pack, both carry the Shadowhunters' patrol (the driver waits for it to walk away or pulls it alone first, and Polymorphs a Shadowhunter that joins), and the camp of four stays out (keep fights south of y 885). The Witch Doctor 45710 patrols 1658.5 805.5 \u003c-\u003e 1679.9 858.1 (45 s round) and can join the pack from the south: no pull group (its spawn stands 15-20 yd from the Shadowhunter camp of 1639-1645 798-815); the walk's pulls on the way and the reactive CC take it.",
"Route edit Z3 (2026-10-16, test it in one group): the Shadowcaster camp of four at 1660 901 and the pack at 1662 930 (Shadowhunter, two Shadowcasters, Blood Drinker) were groups 1 and 2 of the `velratha` leg and are groups 6 and 7 here, pulled before the Shadowcaster / Soul Eater pair at 1642 902 (the last group, with the Shadowhunters' patrol), from the west stand at 1620 880. In round 3 the pull of the pair drew the camp and the Shadowhunter pack with it: 7 mobs in 118 s (s2b), 11 mobs in 172 s with 7 deaths and a wipe (s4-b), the same bot twice in s1b. The linked pack of three at 1613-1619 892-897 (Witch Doctor 42633, Shadowcaster 43695, Blood Drinker 43697) is group 4: it stands at that stand and joined the 62 s fight of the pair in all three runs (Z9). The `velratha` leg keeps the pair as its group 1, killed earlier in the run when the Antu'sul leg took it."
"Route edit Z3 (2026-10-16, test it in one group): the Shadowcaster camp of four at 1660 901 and the pack at 1662 930 (Shadowhunter, two Shadowcasters, Blood Drinker) were groups 1 and 2 of the `velratha` leg and are groups 6 and 7 here, pulled before the Shadowcaster / Soul Eater pair at 1642 902 (the last group, with the Shadowhunters' patrol), from the west stand at 1620 880. In round 3 the pull of the pair drew the camp and the Shadowhunter pack with it: 7 mobs in 118 s (s2b), 11 mobs in 172 s with 7 deaths and a wipe (s4-b), the same bot twice in s1b. The linked pack of three at 1613-1619 892-897 (Witch Doctor 42633, Shadowcaster 43695, Blood Drinker 43697) is group 4: it stands at that stand and joined the 62 s fight of the pair in all three runs (Z9). The `velratha` leg keeps the pair as its group 1, killed earlier in the run when the Antu'sul leg took it.",
"Route edit Z3 reverted for the module (2026-10-21, r2-19z19 step 26): the camp (5648 + 3 x 5650 at 1660 901) and the pack at 1662 930 were pulled before the pair and the export carried no stand, so the run leader stopped 30 yd short of the camp centre at 1642.1 886.7, 15.4 yd from the pair's Shadowcaster 42627 and 4.9 yd from the patrol's waypoint 1646.7 888.4: the fight began in the walk phase (no pull order, no crowd control) and the camp, the patrol, the north pack and the Blood Drinker 45709 joined (wipe at step 26 of 133; zf-full-19z16 pulled the pair first and cleared). The order is now ..., Witch Doctor 1633.3 868.3, the pair (with its patrol block), the camp, the north pack. Stands are the last approach point, ground distances to the spawns of the plan. The pair from 1620 880 z 8.97 (navmesh floor): 42627 31.3 yd, 7247 36.1, the camp's nearest (44162) 45.9, the north pack's nearest (44177) 65.3, the wanderer 45709 (1685.9 888.8) 66.5, the patrol path 26.8 (waypoint 1643.3 893.3). The camp from 1630 878 z 8.93: 44162 38.5, 44165 43.8, the other camp spawns 47.7 and 51.2, the north pack 61.0, 45709 56.9, the patrol path 19.6 (waypoint 1646.7 888.4 at 19.7). TestApproachClearanceFailsOnTheOldAntusulOrder holds the rule (20 yd from every later group, 15 yd from every patrol path) against this order. The agent's own driver does not walk the approach points.",
"Zerillis (10082) was up in zf-full-19z16 (fought at t 836-889) and in r2-19z19 (0x25FE9): he is the rare member of the 155626 patrol (creature_groups leader 155626 with 44170 and 155625 = 10082). The skip list entry 'probably not up' stays in this edit. plan-export does not carry skip lists (the rows have no skip column) and 10082 is in no pull row's entries, so the entry changes no row; it only makes the agent-led driver ignore him as a target."
]
},
{
Expand Down
222 changes: 222 additions & 0 deletions internal/dungeon/approach_clear_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package dungeon

import (
"fmt"
"math"
"path/filepath"
"strings"
"testing"
)

// The approach-clearance rule (r2-19z19, step 26 of Zul'Farrak; PLAN-ZF-STEP26): where the TortoiseBots run leader's party stands to pull a
// group is its last approach point, or else the point runLeaderApproachYd short of the group's centre on the way from where the party was
// before. That spot must keep clear of every group pulled LATER in the plan (their aggro reach at level 46 is under 20 yd) and of every
// patrol path (15 yd), or the walk to the pull starts a fight no pull order, crowd control or firing stand has planned. Today's step 26 stood
// 15.4 yd from a Shadowcaster of the pair that the plan pulled after it.
const (
runLeaderApproachYd = 30 // DungeonRunPolicy.h kApproachYd
laterGroupClearYd = 20.0
approachDriftYd = 3.0 // added where the stand is computed, not written: the real path and the walk-in drift
patrolPathClearYd = 15.0
)

type standFinding struct {
leg string
group int
stand Point
what string
dist float64
wanted float64
}

func (f standFinding) String() string {
return fmt.Sprintf("leg %s pull group %d: the stand %.1f %.1f is %.1f yd from %s (at least %.0f wanted)", f.leg, f.group, f.stand.X, f.stand.Y, f.dist, f.what, f.wanted)
}

func groupCentre(g PullGroup) Point {
var c Point
for _, p := range g.Positions {
c.X += p.X
c.Y += p.Y
c.Z += p.Z
}
n := float32(len(g.Positions))
return Point{c.X / n, c.Y / n, c.Z / n}
}

// segDist is the ground distance from p to the segment a-b.
func pointToSegYd(p, a, b Point) float64 {
dx, dy := float64(b.X-a.X), float64(b.Y-a.Y)
l2 := dx*dx + dy*dy
if l2 == 0 {
return groundDist(p, a)
}
t := (float64(p.X-a.X)*dx + float64(p.Y-a.Y)*dy) / l2
t = math.Max(0, math.Min(1, t))
return math.Hypot(float64(p.X-a.X)-t*dx, float64(p.Y-a.Y)-t*dy)
}

// approachClearance returns the findings of the rule for a plan, in plan order. Groups the run leader does not walk up to and pull (ambush,
// hold, off the navmesh) are neither checked nor counted as a stand's anchor.
func approachClearance(p *Plan) []standFinding {
return approachClearanceOf(p, nil)
}

// approachClearanceOf is approachClearance, limited to the groups whose first spawn is in only (nil: every group).
func approachClearanceOf(p *Plan, only map[Point]bool) []standFinding {
type ref struct {
leg string
idx int
g PullGroup
anchr Point
}
var refs []ref
var patrols []Patrol
anchor := p.Entrance
var seen []Point // positions of the groups already counted: a later leg that lists the same pack again (velratha's pair) is a repeat, dead by then
repeat := func(g PullGroup) bool {
for _, pos := range g.Positions {
found := false
for _, q := range seen {
if groundDist(pos, q) < 3 {
found = true
}
}
if !found {
return false
}
}
return true
}
for _, l := range p.Legs {
if l.To != (Point{}) {
anchor = l.To
}
for i, g := range l.PullGroups {
dup := repeat(g)
seen = append(seen, g.Positions...)
if g.Patrol != nil && !dup {
patrols = append(patrols, *g.Patrol)
}
if dup || g.Ambush || g.Hold != nil || g.NeedsWalk || len(g.Positions) == 0 {
continue
}
refs = append(refs, ref{l.Name, i, g, anchor})
anchor = groupCentre(g)
}
}
var out []standFinding
for k, r := range refs {
if only != nil && !only[Point{X: r.g.Positions[0].X, Y: r.g.Positions[0].Y}] {
continue
}
stand, wantLater := Point{}, laterGroupClearYd
if n := len(r.g.Approach); n > 0 {
stand = r.g.Approach[n-1]
} else {
c := groupCentre(r.g)
d := groundDist(r.anchr, c)
if d <= runLeaderApproachYd {
stand = r.anchr
} else {
f := (d - runLeaderApproachYd) / d
stand = Point{r.anchr.X + float32(f)*(c.X-r.anchr.X), r.anchr.Y + float32(f)*(c.Y-r.anchr.Y), c.Z}
}
wantLater += approachDriftYd
}
for _, later := range refs[k+1:] {
for _, pos := range later.g.Positions {
if d := groundDist(stand, pos); d < wantLater {
out = append(out, standFinding{r.leg, r.idx, stand, fmt.Sprintf("a spawn of leg %s group %d at %.1f %.1f (pulled later)", later.leg, later.idx, pos.X, pos.Y), d, wantLater})
}
}
}
for _, pa := range patrols {
for j := 0; j+1 < len(pa.Path); j++ {
if d := pointToSegYd(stand, pa.Path[j], pa.Path[j+1]); d < patrolPathClearYd {
out = append(out, standFinding{r.leg, r.idx, stand, fmt.Sprintf("the patrol path of %v (segment %d)", pa.Entries, j+1), d, patrolPathClearYd})
}
}
}
}
return out
}

// zfGuarded are the Zul'Farrak groups held to the rule (key: the first spawn): the Antu'sul leg's Shadowcaster / Soul Eater pair and the
// Shadowcaster camp (r2-19z19 step 26). The other Zul'Farrak groups were written before the rule and have findings of their own, listed below
// as known and not fixed here (the rule is stricter than the walks they were live-checked with; each needs a stand measured on the navmesh).
var zfGuarded = map[Point]bool{{X: 1642.1, Y: 902.1}: true, {X: 1660.7, Y: 901.2}: true}

// The guarded groups of Zul'Farrak must pass the rule; every other finding, in any plan, is logged (report-only).
func TestPlansApproachClearance(t *testing.T) {
files, _ := filepath.Glob(filepath.Join(plansDir, "*.json"))
for _, f := range files {
p, err := Load(f)
if err != nil {
continue // plans_all_test reports a plan that does not load
}
fail := map[string]bool{}
if p.Name == "zulfarrak" {
for _, x := range approachClearanceOf(p, zfGuarded) {
t.Errorf("%s: %s", p.Name, x)
fail[x.String()] = true
}
}
n := 0
for _, x := range approachClearance(p) {
if !fail[x.String()] {
n++
if p.Name == "zulfarrak" { // the other plans: the count only (thousands of lines)
t.Logf("report-only %s: %s", p.Name, x)
}
}
}
t.Logf("%s: %d report-only approach-clearance findings", p.Name, n)
}
}

// The regression: r2-19z19's order and no approach rows (the camp before the pair, no stands) must be found; the edited plan is clean.
func TestApproachClearanceFailsOnTheOldAntusulOrder(t *testing.T) {
p := loadPlan(t, zulfarrakPlan)
if fs := approachClearanceOf(p, zfGuarded); len(fs) != 0 {
t.Fatalf("the plan itself has findings: %v", fs)
}
for li := range p.Legs {
l := &p.Legs[li]
if l.Name != "antusul" {
continue
}
var camp, pair = -1, -1
for i, g := range l.PullGroups {
if len(g.Entries) == 4 && g.Entries[0] == 5648 && g.Entries[1] == 5650 {
camp = i
}
if len(g.Entries) == 2 && g.Entries[0] == 5648 && g.Entries[1] == 7247 {
pair = i
}
}
if camp < 0 || pair < 0 || pair >= camp {
t.Fatalf("antusul: pair at %d, camp at %d (the pair is pulled first)", pair, camp)
}
// Old order: the camp and the north pack before the pair, no approach rows.
gs := l.PullGroups
old := append([]PullGroup{}, gs[:pair]...)
old = append(old, gs[camp:]...)
old = append(old, gs[pair])
for i := range old {
old[i].Approach = nil
}
l.PullGroups = old
}
var hit []string
for _, f := range approachClearanceOf(p, zfGuarded) {
hit = append(hit, f.String())
}
if len(hit) == 0 {
t.Fatal("the old order passes the approach-clearance rule")
}
if !strings.Contains(strings.Join(hit, "\n"), "1642.1 902.1") {
t.Errorf("the old order is not caught on the pair's Shadowcaster: %v", hit)
}
t.Logf("old order findings: %s", strings.Join(hit, "; "))
}
Loading