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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,8 @@ that carries a plan out through the RPC is described under [Dungeon runs](#dunge
`prepare.fresh` step teleports the bots and the agent to before it unbinds them. A plan without them can only be entered by hand.
- `level_band` (optional, `[low, high]`, 1 to 60; Uldaman `[41, 45]`, Zul'Farrak `[44, 48]`) is the levels the dungeon is meant for: the
[hired party](#hired-party-prepare-party-hire) takes the middle of it as its level and accepts no other level than the band's.
- `tank_min_hp` (optional, `0` or `100` to `20000`) is the least max health the hired tank should have for the plan's level band (Ragefire
Chasm `800` at 13-18); the [kit check](#remote-leader) warns below it. `0` or absent: no check.
- `hire_composition` (optional, the form of `prepare.hire.composition`) is the hired party when the run's `prepare.hire` gives none; the loader
checks it like the run's (one tank, one healer, two to five bots). Zul'Farrak hires `warrior:tank`, `priest:healer`, `mage`, `hunter`, `warlock`:
the mage is the cc bot of its big packs.
Expand Down Expand Up @@ -1751,7 +1753,7 @@ as a group run, but everything comes from the feed and goes out as an order:
| pull group / boss | the nearest hostile of the group's entries **as the bots see it** (not the agent's view), `.bot cmd pullback <guid>`; out of range, the lead bot is moved up to 38 yd first; `too-far` moves it closer and retries; `evading` skips the mob; **`unreachable`** (the tank found no firing position) is not a way to clear the group: the party is walked to 20 yd of the mob, then 8 yd and the pull is sent again from each; still unreachable, the step fails with the mob's position (`... is unreachable for the tank ...; the group is not cleared`), so a pack with a living mob is never counted cleared. Every `unreachable` answer, to a pull or to a `cmd move`, is a note (`unreachable pull|move to x y z (...)`), a trace event `unreachable` and counts in `remote.unreachable`, so the plan's positions can be corrected; **`tank-busy`** (the only tank is in combat, it cannot start a pull while it fights) waits up to 10 s for the tank to leave its fight (or for the mob to come to the party by itself) and sends the pull again, up to 3 times, never an attack order in its place (`tank_busy_waits`; still busy after that, the pull counts as one that drew no fight); `no-tank` (no tank-capable bot in the party at all) falls back to `.bot cmd attack`; **a group the bots do not see is not "already dead"**: it counts as cleared only with evidence for each of its positions (its mobs died there in a won pull of this run, listed dead, a living bot within 25 yd of the position and a whole frame that lists none, a pull of a mob that stood there failed and is named in the notes, on a First add's step the boss seen standing there with no add in view, or the lead bot walked to the position, once per position, and two whole frames in a row showed none; `pack_looks`, `packs_seen_empty`), else the lead bot walks up to 21 yd short of the position (within 3 yd of that spot: out of the pack's aggro range, still within the 25 yd), and the group is pulled when it shows. "Of the group" is always the entries the step picks its target from: the boss standing at its position is not its First add. A won pull does not end the group (the fight tracks only the pulled mob and what joins it): the driver picks again, so a group's other positions are pulled or looked at as well. Only a won pull's dead mobs mark their spots; an evaded pull marks none. Giving up (mobs that drew no fight or were refused, or the pulls used up) still needs every position seen, else the step fails (`... were never seen: not counting it as cleared`). A boss counts as killed only when it died in this run (in its own pull or a First add's it joined, or in any other fight of the run: **the kill ledger** follows every hostile the feed shows fighting the party, whichever pull or walk fight it came to, and counts one as dead when it is listed at 0 health, or is absent from two whole frames after being seen fighting at 20% health or less, not walking home, with a living bot within 25 yd of where it was last seen; a wipe clears the counts; a boss seen alive again is alive again, one that left view healthier, unfought, evading or out of every bot's sight is not dead) or is listed dead, or its corpse (health 0 or the dead flag, the boss's entry, within 80 yd of the step's boss position; a step without positions takes none) is in the **agent's own object store** (a boss killed before this driver ran, as on a resume; the agent is brought near the party first, see **Keeping the agent near**), never because its adds died. A pull's fight tracks every hostile that joins it, not only the step's own entries, so a boss that comes into a pack's fight and dies there is that fight's kill (run 1: Revelosh died inside a 4852 pull that ended `evaded`). The ledger proves the boss dead only; the group positions are still cleared only by a won pull's dead mobs (`killedAt`). A position the lead bot cannot get within 25 yd of fails the step instead of passing it. The resume rule believes an empty view on the same 25 yd terms and wants every position of a group seen |
| boss not at his spot | (issue #190: Theka and Velratha patrol, and the step failed at once with "no living [7795] seen") when the kill-boss step finds no living unit of the boss's entry in range after the look walk, it waits up to `boss_search_s` (60 s); the first living unit of the entry the feed shows is then approached and pulled, that exact unit, when it stands **within 120 yd of his plan spot** and the navmesh path from the lead bot to it is **complete** (ends within 8 yd of him; with no planner the straight line) and **passes no uncleared pull group** (a group of a later leg, or a group of this leg that the feed still shows a living member of, within the group's radius of a position: a pull on the way would drag the party through uncleared packs); else the wait goes on (noted once: `but not taken: ...`) and the step fails with `boss <entry> not found`. The party walks to him **hop by hop along that path and checks it again before each hop** (a changed path, a pack in the way or a boss gone stops the walk and fails the step), then the pull follows with its own checks. **No search** with `boss_search_s` 0, when the boss is already known dead (this run's kill, the ledger or his corpse in view: checked before the search and at every look of it), and when the run began inside this boss's leg from a later start (`from_leg` above 0, `from_pull_group`, `start_here`, or a `resume` that skipped the walk: he may have been killed before this run, so his absence is expected and the step fails at once as it always did); the wait is counted in `boss_searches` and noted. He is **never counted dead because he is absent**: only a kill of this run, the kill ledger (seen fighting, then dead or gone) or his corpse in view says so. After the wait the step fails with `boss <entry> not found: no living [<entry>] seen by the bots (...)`. Once per step |
| team gate | `boss.team` and a group's `team` work as in a group run: the party's team is `team`, else the agent's own (the feed carries no race, and the owned bots share the account's faction); the skipped steps are in the notes (`skip_team` events) and `remote.team` says which team ran. The gate is **data-driven**: a part gated to the other team is skipped only if none of its mobs is hostile (listed alive now, or a unit seen alive earlier in the run, by GUID, and not killed since) **and** the party has looked: the feed lists only what the bots see, so the part is skipped on the evidence a pack needs to count as cleared (a living bot within 25 yd of each of its positions and a whole frame that lists none of its mobs, else the lead bot walks up to look, once per position; `... : skipped (looked: ...)`), and a position that cannot be looked at fails the step rather than skipping blind; if the mobs are hostile it is fought like a pull group (a note, `team_override` event: `... its mobs are hostile to the party: fought`). Uldaman's Baelog, Eric and Olaf are `team: horde` in the plan (the world database has them friendly to the Alliance), but were hostile to an Alliance party in remote run 2 (the pull of the last pack aggroed them), so an Alliance run skips them only while they are not in the feed. Group mode keeps the plan's gate as written. With no team known nothing is gated (a note says so) |
| kit check (`preflight`) | before the first step: `.bot maintenance <bot>` for each bot in the first frame (not in an observed run unless `observe.maintenance`, #253) (plain `.bot` lines; the module refreshes consumables, reagents, ammo and repairs when `AiPlayerbot.OwnedBotMaintenanceEnabled = 1`; `disabled` or a refusal is recorded and the run goes on), then `.bot inv <bot>`: drinks for every mana user (10; 20 for the `healer`), arrows or bullets for the shot of a ranged weapon in the ranged slot (100; a gun with arrows is named), and a shield in the off hand of the `tank` (only when named). Entries come from the core's item table (`tools/itemkinds`, `internal/dungeon/itemkinds_gen.go`); a drink or ammo the table lacks reads as missing. Before the bags it reads the **spent talent points** of every bot with `.bot command <bot> talents`, **all bots at once** (the lines go out together and the answers, each in its own bot's words, as a whisper or party chat, "My current talent spec is: ... (a/b/c)", are read in one wait of 5 s: it was 6 s per bot one after another, 31 s at every start of the 2026-10-15 ZF runs, whose second asking got no answer from the server at all; the one `.bot command` line the agent sends, and the plain-line check refuses any other word after `talents`): a bot with none spent (a respec that was reset and never applied again, a 0/0/0 tank) or no answer is a warning. Whether the server answers a second asking is not settled: with no answer the check costs 5 s, not 31. The role is the `tank`/`healer` the run names (the module has no read for it that the agent may send). Result in `remote.preflight` (`bots[]` with `maintenance`, `inventory`, `talents` (`"0/5/31"`), `drinks`, `ammo`, `shield`; `warnings[]`; `ok`). `warn` goes on; `strict` fails the run before any order; `off` sends nothing |
| kit check (`preflight`) | before the first step: `.bot maintenance <bot>` for each bot in the first frame (not in an observed run unless `observe.maintenance`, #253) (plain `.bot` lines; the module refreshes consumables, reagents, ammo and repairs when `AiPlayerbot.OwnedBotMaintenanceEnabled = 1`; `disabled` or a refusal is recorded and the run goes on), then `.bot inv <bot>`: drinks for every mana user (10; 20 for the `healer`), arrows or bullets for the shot of a ranged weapon in the ranged slot (100; a gun with arrows is named), and a shield in the off hand of the `tank` (only when named). Entries come from the core's item table (`tools/itemkinds`, `internal/dungeon/itemkinds_gen.go`); a drink or ammo the table lacks reads as missing. Before the bags it reads the **spent talent points** of every bot with `.bot command <bot> talents`, **all bots at once** (the lines go out together and the answers, each in its own bot's words, as a whisper or party chat, "My current talent spec is: ... (a/b/c)", are read in one wait of 5 s: it was 6 s per bot one after another, 31 s at every start of the 2026-10-15 ZF runs, whose second asking got no answer from the server at all; the one `.bot command` line the agent sends, and the plain-line check refuses any other word after `talents`): a bot with none spent (a respec that was reset and never applied again, a 0/0/0 tank) or no answer is a warning. Whether the server answers a second asking is not settled: with no answer the check costs 5 s, not 31. The role is the `tank`/`healer` the run names (the module has no read for it that the agent may send). It also warns when the tank's max health is below the plan's `tank_min_hp`, and when a bot's race is of the other team than the party's (read from the object store; bots out of view are skipped). Result in `remote.preflight` (`bots[]` with `maintenance`, `inventory`, `talents` (`"0/5/31"`), `drinks`, `ammo`, `shield`; `warnings[]`; `ok`). `warn` goes on; `strict` fails the run before any order; `off` sends nothing |
| end of run | **while the party is in combat the binding is kept** and the fight logic (tank first, attack orders) keeps running, on any end (done, failed, stopped) and with its own context, for at most 90 s until the party is out of combat (`remote.end_fight`: `out of combat after N s` or `still in combat after 90 s; gave up`); unbound bots would die in the pack that aggroed them (run 2). Then `cmd stay` before the unbind (`remote.end_stay`), because the restored followers would run after the agent, however far away it is; `on_end: "logout"` then sends `.bot logout <bot>` for each bot after the unbind (`remote.logged_out`); `"none"` is the unbind only |
| fight | the feed's hostiles, targets, health and threat; `.bot cmd attack <guid>` at the engaged mob the tank holds best every 10 s (**Tank first**, below). A mob is dead when its health reads 0, or when it was seen fighting (engaged or hurt) and is then absent from **two frames in a row**; an absence in a `truncated` frame counts for nothing. A pull whose mobs all leave the feed without ever being seen fighting is `drew no fight`, not a win. `evaded` when they walk home |
| cc | `cc` option: `[{"entry":4863,"mark":"moon","bot":"Magey"}]` sends `.bot cmd cc <mark> <guid> [bot]` for mobs of that entry around the pull target, before the pull; such a mob is not picked as the pull target while another is there |
Expand Down Expand Up @@ -2122,7 +2124,8 @@ in the report's `refusals[]` with intent `cmd <verb>`), `move_stalls`, `walk_agg
**Limits.** The feed has no names for mobs (entries only), no class for bots (so a caster below 30 % mana always holds a rest gate, healer or not; `healer` names the bot that gets the healer mana target),
no loot or dead flag for hostiles (a hostile seen fighting that leaves the feed for two frames is counted dead), and the bots see only what they see: a pack
nobody has in sight is not counted dead: the lead bot walks up to its positions and looks (see the pull group row). A feed that goes quiet for 6 s makes the fight loops wait
(`feed_stalls`); one quiet for 45 s fails the run.
(`feed_stalls`); one quiet for 45 s fails the run. `resolveTeam` still assumes the agent's own team for the team gate (the feed carries no race);
the preflight's faction warning only reports a hire of the other team.

### Start anywhere and the pull test

Expand Down
3 changes: 3 additions & 0 deletions cmd/turtle-agent/commander_methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ func (g remoteGame) ViewStats() tbm.ViewStats { return g.c.feed.
// Team is the agent's own side (dungeon.TeamSource): the feed carries no race, and the owned bots share the account's faction.
func (g remoteGame) Team() string { return g.c.cur().Team() }

// BotTeam implements dungeon.BotTeamer: a unit's side from its race in the agent's object store ("" when the unit is not in view).
func (g remoteGame) BotTeam(guid packet.GUID) string { return session.Team(g.c.cur().RaceOf(guid)) }

// BotLines implements dungeon.BotLiner: a plain `.bot maintenance|inv|logout <bot>` and the lines it was answered with.
func (g remoteGame) BotLines(ctx context.Context, command string, wait time.Duration, done func(line string) bool) ([]string, bool, error) {
return g.c.cmdr.Lines(ctx, command, wait, done)
Expand Down
1 change: 1 addition & 0 deletions cmd/turtle-agent/remote_interfaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ var (
_ dungeon.AreaTriggerGame = remoteGame{}
_ dungeon.BotLiner = remoteGame{}
_ dungeon.BotLinesAller = remoteGame{}
_ dungeon.BotTeamer = remoteGame{}
_ dungeon.Enterer = remoteGame{}
_ dungeon.EntryLedger = remoteGame{}
_ dungeon.FloorSnapper = remoteGame{}
Expand Down
1 change: 1 addition & 0 deletions dungeons/ragefire_chasm.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"z": -10.4478
},
"level_band": [13, 18],
"tank_min_hp": 800,
"hire_composition": [
"warrior:tank",
"priest:healer",
Expand Down
11 changes: 11 additions & 0 deletions internal/dungeon/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ type Plan struct {
// LevelBand is the character levels the dungeon is meant for, low and high (Uldaman 41-45): the prepare step's hired party takes the
// middle of it as its level. Empty: the plan gives none.
LevelBand []int `json:"level_band,omitempty"`
// TankMinHP is the least max health the hired tank should have for the plan's level band (Ragefire Chasm 800 at 13-18, #266): a tank
// below it is a preflight warning (weak gear: autogear again, or hire another tank). 0: no check.
TankMinHP uint32 `json:"tank_min_hp,omitempty"`
// HireComposition is the hired party (prepare.party "hire") when prepare.hire.composition gives none, in its form ("class" or
// "class:role" each). Empty: DefaultHireComposition. Zul'Farrak hires a mage for the Polymorph of its big packs.
HireComposition []string `json:"hire_composition,omitempty"`
Expand Down Expand Up @@ -744,6 +747,7 @@ type rawPlan struct {
Outside string `json:"outside_tele"`
Portal *rawPortal `json:"portal"`
Band []int `json:"level_band"`
TankMinHP *uint32 `json:"tank_min_hp"`
Hire []string `json:"hire_composition"`
CCPrio []uint32 `json:"cc_priority"`
CCAlways []uint32 `json:"cc_always"`
Expand Down Expand Up @@ -1029,6 +1033,13 @@ func Parse(name string, data []byte) (*Plan, error) {
default:
p.LevelBand = []int{rp.Band[0], rp.Band[1]}
}
if rp.TankMinHP != nil {
if v := *rp.TankMinHP; v != 0 && (v < 100 || v > 20000) {
c.fail("tank_min_hp", "%d: use 0 or 100-20000", v)
} else {
p.TankMinHP = v
}
}
if len(rp.CCPrio) > 0 {
p.CCPriority = slices.Clone(rp.CCPrio)
}
Expand Down
24 changes: 24 additions & 0 deletions internal/dungeon/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,30 @@ func TestParseFleers(t *testing.T) {
}
}

// TestPlanTankMinHPIsChecked: the tank floor is optional (0 or absent), and 100-20000 is the range; anything else names the field.
func TestPlanTankMinHPIsChecked(t *testing.T) {
base := func(f string) string {
return `{"version":1,"name":"t","map":1,"entrance":{"x":0,"y":0,"z":0},` + f + `"legs":[{"to":{"x":0,"y":0,"z":0},"pull_groups":[{"entries":[1],"positions":[{"x":0,"y":0,"z":0}]}]}]}`
}
for _, v := range []string{"99999", "50"} {
if _, err := Parse("t.json", []byte(base(`"tank_min_hp":`+v+`,`))); err == nil || !strings.Contains(err.Error(), "tank_min_hp") {
t.Errorf("%s: %v", v, err)
}
}
p, err := Parse("t.json", []byte(base(`"tank_min_hp":800,`)))
if err != nil || p.TankMinHP != 800 {
t.Errorf("800: %v %v", err, p)
}
p, err = Parse("t.json", []byte(base(`"tank_min_hp":0,`)))
if err != nil || p.TankMinHP != 0 {
t.Errorf("0: %v %v", err, p)
}
p, err = Parse("t.json", []byte(base(``)))
if err != nil || p.TankMinHP != 0 {
t.Errorf("absent: %v %v", err, p)
}
}

// TestApproachAndHoldAreRead: approach points and a hold are read, and their limits refused.
func TestApproachAndHoldAreRead(t *testing.T) {
base := `{"version":1,"name":"t","map":70,"entrance":{"x":0,"y":0,"z":0},"legs":[{"name":"a","to":{"x":0,"y":0,"z":0},
Expand Down
11 changes: 11 additions & 0 deletions internal/dungeon/plans_all_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ func TestPlanKindStatusAndParty(t *testing.T) {
// drafts. A plan leaves the list when its status moves on: TestOfflineMappedPlansAreStillDrafts fails for one that does not.
var offlineMapped = map[string]bool{"deadmines": true}

// Ragefire Chasm is the one plan with measured hire data: 800 is the issue's tank floor for its level band (#266).
func TestRagefireChasmPlanCarriesTheTankFloor(t *testing.T) {
p, err := Load(filepath.Join(plansDir, "ragefire_chasm.json"))
if err != nil {
t.Fatal(err)
}
if p.TankMinHP != 800 {
t.Fatalf("ragefire_chasm tank_min_hp is %d, want 800", p.TankMinHP)
}
}

func TestOfflineMappedPlansAreStillDrafts(t *testing.T) {
for name := range offlineMapped {
p, err := Load(filepath.Join(plansDir, name+".json"))
Expand Down
Loading
Loading