From f9bfc758a726f8c0360a032801d52d43b2119af1 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:07:35 +0200 Subject: [PATCH 1/2] preflight: warn on a weak tank and on a hire of the other faction (#266) --- README.md | 7 +- cmd/turtle-agent/commander_methods.go | 3 + cmd/turtle-agent/remote_interfaces_test.go | 1 + dungeons/ragefire_chasm.json | 1 + internal/dungeon/plan.go | 11 ++ internal/dungeon/plan_test.go | 24 ++++ internal/dungeon/plans_all_test.go | 11 ++ internal/dungeon/preflight.go | 1 + internal/dungeon/preflight_party.go | 48 ++++++++ internal/dungeon/remote_gaps_test.go | 125 +++++++++++++++++++++ internal/session/openobject.go | 4 + 11 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 internal/dungeon/preflight_party.go diff --git a/README.md b/README.md index 6aab75bf..c554ed88 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 `; 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 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 not found: no living [] 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 ` 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 `: 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 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 ` 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 `: 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 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 ` 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 ` 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 [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 | @@ -2122,7 +2124,8 @@ in the report's `refusals[]` with intent `cmd `), `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 diff --git a/cmd/turtle-agent/commander_methods.go b/cmd/turtle-agent/commander_methods.go index 41429b80..a7a0c109 100644 --- a/cmd/turtle-agent/commander_methods.go +++ b/cmd/turtle-agent/commander_methods.go @@ -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 ` 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) diff --git a/cmd/turtle-agent/remote_interfaces_test.go b/cmd/turtle-agent/remote_interfaces_test.go index e3770bd1..84630df7 100644 --- a/cmd/turtle-agent/remote_interfaces_test.go +++ b/cmd/turtle-agent/remote_interfaces_test.go @@ -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{} diff --git a/dungeons/ragefire_chasm.json b/dungeons/ragefire_chasm.json index a7e4b252..7eb59f51 100644 --- a/dungeons/ragefire_chasm.json +++ b/dungeons/ragefire_chasm.json @@ -15,6 +15,7 @@ "z": -10.4478 }, "level_band": [13, 18], + "tank_min_hp": 800, "hire_composition": [ "warrior:tank", "priest:healer", diff --git a/internal/dungeon/plan.go b/internal/dungeon/plan.go index ad423b53..bcb4bd66 100644 --- a/internal/dungeon/plan.go +++ b/internal/dungeon/plan.go @@ -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"` @@ -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"` @@ -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) } diff --git a/internal/dungeon/plan_test.go b/internal/dungeon/plan_test.go index d790ab3b..3b4c9f57 100644 --- a/internal/dungeon/plan_test.go +++ b/internal/dungeon/plan_test.go @@ -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}, diff --git a/internal/dungeon/plans_all_test.go b/internal/dungeon/plans_all_test.go index c137ed94..c9e2111e 100644 --- a/internal/dungeon/plans_all_test.go +++ b/internal/dungeon/plans_all_test.go @@ -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")) diff --git a/internal/dungeon/preflight.go b/internal/dungeon/preflight.go index 9ad5915a..b271f942 100644 --- a/internal/dungeon/preflight.go +++ b/internal/dungeon/preflight.go @@ -176,6 +176,7 @@ func (d *RemoteDriver) preflight(ctx context.Context) error { pf.Bots = append(pf.Bots, b) } d.checkAllTalents(ctx, bl, pf) + d.checkPartyStrength(pf) for i, m := range d.fr.Party { d.checkKit(ctx, bl, pf, &pf.Bots[i], m) if err := ctx.Err(); err != nil { diff --git a/internal/dungeon/preflight_party.go b/internal/dungeon/preflight_party.go new file mode 100644 index 00000000..b201791c --- /dev/null +++ b/internal/dungeon/preflight_party.go @@ -0,0 +1,48 @@ +package dungeon + +import ( + "fmt" + "strings" + + "github.com/pjerra/turtle-agent/internal/packet" +) + +// The party-strength half of the pre-run check (issue #266): the tank below the plan's tank_min_hp, and a hired bot of the other team than +// the party's. Both are warnings; a warning fails the run only with RemoteOptions.Preflight "strict". Neither sees what stands around the +// hire spot or the portal (the observer's duty): the check reads only the first frame's party and the units in the agent's object store. + +// BotTeamer is the optional part of a RemoteGame that tells a bot's team from its race: "alliance", "horde", or "" when the unit is not in +// view. The feed carries no race; the agent's object store has each visible unit's race byte (UNIT_FIELD_BYTES_0). +type BotTeamer interface { + BotTeam(guid packet.GUID) string +} + +// checkPartyStrength adds the party warnings the plan and the visible units allow before the first step. +func (d *RemoteDriver) checkPartyStrength(pf *Preflight) { + name := d.opt.Tank + if name == "" { + name = d.opt.Lead + } + if m, ok := d.memberByName(name); ok && d.plan.TankMinHP > 0 && m.MaxHP > 0 && m.MaxHP < d.plan.TankMinHP { + pf.Warnings = append(pf.Warnings, fmt.Sprintf("%s: the tank's max health is %d, the plan wants at least %d for its level band (weak gear: autogear again, or hire another tank)", m.Name, m.MaxHP, d.plan.TankMinHP)) + } + bt, ok := d.g.(BotTeamer) + if !ok { + return + } + team := d.opt.Team + if team == "" { + if ts, ok := d.g.(TeamSource); ok { + team = ts.Team() + } + } + if team == "" { + return + } + for _, m := range d.fr.Party { + botTeam := bt.BotTeam(m.GUID) + if botTeam != "" && !strings.EqualFold(botTeam, team) { + pf.Warnings = append(pf.Warnings, fmt.Sprintf("%s: is %s but the party's team is %s (a hire of the other faction: keep it out of enemy towns, hire and enter at the dungeon door)", m.Name, botTeam, team)) + } + } +} diff --git a/internal/dungeon/remote_gaps_test.go b/internal/dungeon/remote_gaps_test.go index bdf80c27..2a1f60dc 100644 --- a/internal/dungeon/remote_gaps_test.go +++ b/internal/dungeon/remote_gaps_test.go @@ -393,6 +393,131 @@ func TestRemotePreflightStrictFailsTheRun(t *testing.T) { } } +// #266: the plan's tank floor warns before the first step when the hired tank's max health is below it (a level-16 tank in white gear). +func TestPreflightWarnsAWeakTank(t *testing.T) { + g := kitFake(t) + g.party[0].MaxHP = 641 + p := plan1() + p.TankMinHP = 800 + rep, err := runRemote(t, g, p, kitOpts(), nil) + if err != nil || rep.State != "done" { + t.Fatalf("a warning failed the run: %v %s %s", err, rep.State, notesOf(rep)) + } + pf := rep.Remote.Preflight + if pf == nil { + t.Fatal("no preflight") + } + want := "Tanky: the tank's max health is 641, the plan wants at least 800 for its level band (weak gear: autogear again, or hire another tank)" + if all := strings.Join(pf.Warnings, "\n"); !strings.Contains(all, want) { + t.Errorf("missing %q in\n%s", want, all) + } + + // Strict mode fails the run on the same warning. + g = kitFake(t) + g.party[0].MaxHP = 641 + o := kitOpts() + o.Preflight = "strict" + rep, err = runRemote(t, g, p, o, nil) + if err == nil || !strings.Contains(err.Error(), "preflight (strict)") || !strings.Contains(err.Error(), "max health is 641") || rep.State != "failed" { + t.Fatalf("%v %s", err, rep.State) + } +} + +// A tank at the floor or above it warns nothing, and a plan without a floor does not check at all. +func TestPreflightStrongTankAndNoFloorWarnNothing(t *testing.T) { + g := kitFake(t) + g.party[0].MaxHP = 901 + p := plan1() + p.TankMinHP = 800 + rep, err := runRemote(t, g, p, kitOpts(), nil) + if err != nil || rep.Remote.Preflight == nil { + t.Fatalf("%v", err) + } + if all := strings.Join(rep.Remote.Preflight.Warnings, "\n"); strings.Contains(all, "max health is") { + t.Fatalf("a strong tank was warned about:\n%s", all) + } + + g = kitFake(t) + g.party[0].MaxHP = 641 + rep, err = runRemote(t, g, plan1(), kitOpts(), nil) + if err != nil || rep.Remote.Preflight == nil { + t.Fatalf("%v", err) + } + if all := strings.Join(rep.Remote.Preflight.Warnings, "\n"); strings.Contains(all, "max health is") { + t.Fatalf("a plan without a floor warned:\n%s", all) + } +} + +// teamBotFake is a botFake whose game knows the agent's team and each bot's race-team (BotTeamer). +type teamBotFake struct { + *botFake + team string + botTeams map[packet.GUID]string +} + +func (g teamBotFake) Team() string { return g.team } +func (g teamBotFake) BotTeam(guid packet.GUID) string { return g.botTeams[guid] } + +// #266: a hired bot of the other faction is flagged before the first step (the feed carries no race; the object store has it). +func TestPreflightFlagsAHireOfTheOtherFaction(t *testing.T) { + teams := map[packet.GUID]string{gTank: "horde", gHealer: "alliance", gMage: "horde"} + g := teamBotFake{botFake: kitFake(t), team: "horde", botTeams: teams} + rep, err := runRemote(t, g, plan1(), kitOpts(), nil) + if err != nil || rep.State != "done" { + t.Fatalf("%v %s %s", err, rep.State, notesOf(rep)) + } + pf := rep.Remote.Preflight + if pf == nil { + t.Fatal("no preflight") + } + want := "Healy: is alliance but the party's team is horde (a hire of the other faction: keep it out of enemy towns, hire and enter at the dungeon door)" + all := strings.Join(pf.Warnings, "\n") + if !strings.Contains(all, want) { + t.Fatalf("missing %q in\n%s", want, all) + } + if n := strings.Count(all, "but the party's team is"); n != 1 { + t.Fatalf("%d team warnings:\n%s", n, all) + } + + // The run's team option wins over the agent's; the two horde bots are flagged now. + o := kitOpts() + o.Team = "alliance" + rep, err = runRemote(t, g, plan1(), o, nil) + if err != nil || rep.Remote.Preflight == nil { + t.Fatalf("%v", err) + } + all = strings.Join(rep.Remote.Preflight.Warnings, "\n") + for _, w := range []string{"Tanky: is horde", "Magey: is horde"} { + if !strings.Contains(all, w) { + t.Errorf("missing %q in\n%s", w, all) + } + } + if strings.Contains(all, "Healy: is") { + t.Errorf("the alliance healer was warned:\n%s", all) + } + if n := strings.Count(all, "but the party's team is"); n != 2 { + t.Fatalf("%d team warnings:\n%s", n, all) + } + + // A game without BotTeamer says nothing... + rep, err = runRemote(t, kitFake(t), plan1(), kitOpts(), nil) + if err != nil || rep.Remote.Preflight == nil { + t.Fatalf("%v", err) + } + if all := strings.Join(rep.Remote.Preflight.Warnings, "\n"); strings.Contains(all, "but the party's team is") { + t.Fatalf("a game without BotTeamer warned:\n%s", all) + } + // ... and neither does a bot out of view (BotTeam ""). + g2 := teamBotFake{botFake: kitFake(t), team: "horde", botTeams: map[packet.GUID]string{}} + rep, err = runRemote(t, g2, plan1(), kitOpts(), nil) + if err != nil || rep.Remote.Preflight == nil { + t.Fatalf("%v", err) + } + if all := strings.Join(rep.Remote.Preflight.Warnings, "\n"); strings.Contains(all, "but the party's team is") { + t.Fatalf("a bot out of view warned:\n%s", all) + } +} + func TestRemotePreflightMaintenanceOffOrRefused(t *testing.T) { g := kitFake(t) g.replies["maintenance Tanky"] = []string{"Owned-bot maintenance is disabled (AiPlayerbot.OwnedBotMaintenanceEnabled = 0)."} diff --git a/internal/session/openobject.go b/internal/session/openobject.go index 44341ea4..b186c197 100644 --- a/internal/session/openobject.go +++ b/internal/session/openobject.go @@ -202,6 +202,10 @@ func (s *Session) takeAll(ctx context.Context, w *LootWindow, wait time.Duration // Race is the player's race id (UNIT_FIELD_BYTES_0, low byte), 0 until the player's create block has arrived. func (s *Session) Race() uint8 { return uint8(s.store.Field(s.selfGUID(), objects.UnitBytes0)) } +// RaceOf is a unit's race id (UNIT_FIELD_BYTES_0, low byte), 0 while the unit is not in the object store: a hired bot's race, which the +// perception feed does not carry. +func (s *Session) RaceOf(g packet.GUID) uint8 { return uint8(s.store.Field(g, objects.UnitBytes0)) } + // Team names the faction side of a race id, "alliance" or "horde" ("" for an id that is neither). The core takes it from the race's base // language (Player::TeamForRace, Player.cpp:7755-7773); the race masks name the same sets: human 1, dwarf 3, night elf 4, gnome 7 and // high elf 10 are the Alliance, orc 2, undead 5, tauren 6, troll 8 and goblin 9 the Horde (SharedDefines.h:41-70). From c6e2e27d3b27fb7c4b92763fa04650bc61b77813 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:39:54 +0200 Subject: [PATCH 2/2] preflight: check the party's faction before maintenance moves the hires out of view (#266) --- internal/dungeon/preflight.go | 4 +- internal/dungeon/preflight_party.go | 7 +++ internal/dungeon/remote_gaps_test.go | 67 +++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/internal/dungeon/preflight.go b/internal/dungeon/preflight.go index b271f942..b2e9efc3 100644 --- a/internal/dungeon/preflight.go +++ b/internal/dungeon/preflight.go @@ -158,6 +158,9 @@ func (d *RemoteDriver) preflight(ctx context.Context) error { maintenanceNote = "not sent (observe mode: observe.maintenance is off)" d.rep.note("preflight: .bot maintenance skipped (observe mode: off by default, it scatters the party to vendors); pass observe.maintenance to send it") } + // Before the maintenance orders, not after: `.bot maintenance` walks the hires to vendors, they leave the object store, and the faction + // check would read no race and skip its warning (#266). The check needs only the first frame's party and what is in view now. + d.checkPartyStrength(pf) for _, m := range d.fr.Party { b := PreflightBot{Name: m.Name, Maintenance: maintenanceNote} if maintenanceOn { @@ -176,7 +179,6 @@ func (d *RemoteDriver) preflight(ctx context.Context) error { pf.Bots = append(pf.Bots, b) } d.checkAllTalents(ctx, bl, pf) - d.checkPartyStrength(pf) for i, m := range d.fr.Party { d.checkKit(ctx, bl, pf, &pf.Bots[i], m) if err := ctx.Err(); err != nil { diff --git a/internal/dungeon/preflight_party.go b/internal/dungeon/preflight_party.go index b201791c..c468bc46 100644 --- a/internal/dungeon/preflight_party.go +++ b/internal/dungeon/preflight_party.go @@ -39,10 +39,17 @@ func (d *RemoteDriver) checkPartyStrength(pf *Preflight) { if team == "" { return } + unread := 0 for _, m := range d.fr.Party { botTeam := bt.BotTeam(m.GUID) + if botTeam == "" { + unread++ + } if botTeam != "" && !strings.EqualFold(botTeam, team) { pf.Warnings = append(pf.Warnings, fmt.Sprintf("%s: is %s but the party's team is %s (a hire of the other faction: keep it out of enemy towns, hire and enter at the dungeon door)", m.Name, botTeam, team)) } } + if unread > 0 { + d.rep.note(fmt.Sprintf("faction check: %d of %d bots not in view, not checked", unread, len(d.fr.Party))) + } } diff --git a/internal/dungeon/remote_gaps_test.go b/internal/dungeon/remote_gaps_test.go index 2a1f60dc..cde3ee5b 100644 --- a/internal/dungeon/remote_gaps_test.go +++ b/internal/dungeon/remote_gaps_test.go @@ -507,7 +507,7 @@ func TestPreflightFlagsAHireOfTheOtherFaction(t *testing.T) { if all := strings.Join(rep.Remote.Preflight.Warnings, "\n"); strings.Contains(all, "but the party's team is") { t.Fatalf("a game without BotTeamer warned:\n%s", all) } - // ... and neither does a bot out of view (BotTeam ""). + // ... and neither does a bot out of view (BotTeam ""), which the report notes once. g2 := teamBotFake{botFake: kitFake(t), team: "horde", botTeams: map[packet.GUID]string{}} rep, err = runRemote(t, g2, plan1(), kitOpts(), nil) if err != nil || rep.Remote.Preflight == nil { @@ -516,6 +516,71 @@ func TestPreflightFlagsAHireOfTheOtherFaction(t *testing.T) { if all := strings.Join(rep.Remote.Preflight.Warnings, "\n"); strings.Contains(all, "but the party's team is") { t.Fatalf("a bot out of view warned:\n%s", all) } + if !strings.Contains(notesOf(rep), "faction check: 3 of 3 bots not in view, not checked") { + t.Fatalf("no note for the unread races:\n%s", notesOf(rep)) + } + + // One of three out of view: one note, with both counts. + g3 := teamBotFake{botFake: kitFake(t), team: "horde", botTeams: map[packet.GUID]string{gTank: "horde", gHealer: "alliance"}} + rep, err = runRemote(t, g3, plan1(), kitOpts(), nil) + if err != nil || rep.Remote.Preflight == nil { + t.Fatalf("%v", err) + } + if !strings.Contains(notesOf(rep), "faction check: 1 of 3 bots not in view, not checked") { + t.Fatalf("no note for the one unread race:\n%s", notesOf(rep)) + } + if n := strings.Count(notesOf(rep), "faction check:"); n != 1 { + t.Fatalf("%d faction notes:\n%s", n, notesOf(rep)) + } +} + +// movedByMaintenance empties the object store's team of every bot once a `.bot maintenance` order went out, as the real store does when the +// hires walk to their vendors. +type movedByMaintenance struct{ *teamBotFake } + +func (g movedByMaintenance) BotTeam(guid packet.GUID) string { + for _, l := range g.teamBotFake.botFake.lines { + if strings.HasPrefix(l, "maintenance ") { + return "" + } + } + return g.teamBotFake.BotTeam(guid) +} + +// #266: the faction check runs before the maintenance orders, so a hire the maintenance moves out of view is still warned about. +func TestPreflightFactionCheckRunsBeforeMaintenance(t *testing.T) { + teams := map[packet.GUID]string{gTank: "horde", gHealer: "alliance", gMage: "horde"} + g := &teamBotFake{botFake: kitFake(t), team: "horde", botTeams: teams} + rep, err := runRemote(t, movedByMaintenance{g}, plan1(), kitOpts(), nil) + if err != nil || rep.State != "done" { + t.Fatalf("%v %s %s", err, rep.State, notesOf(rep)) + } + // The orders went out (the store would be empty after them), and the warning is there anyway. + if got := g.sent("maintenance"); len(got) != 3 { + t.Fatalf("%d maintenance orders: %v", len(got), got) + } + all := strings.Join(rep.Remote.Preflight.Warnings, "\n") + if !strings.Contains(all, "Healy: is alliance but the party's team is horde") { + t.Fatalf("the hire of the other faction was not warned about:\n%s", all) + } +} + +// #266: without a named tank the floor reads the lead bot, the party member that takes the hits. +func TestPreflightTankFloorFallsBackToTheLead(t *testing.T) { + g := kitFake(t) + g.party[0].MaxHP = 641 + p := plan1() + p.TankMinHP = 800 + o := kitOpts() + o.Tank = "" + rep, err := runRemote(t, g, p, o, nil) + if err != nil || rep.Remote.Preflight == nil { + t.Fatalf("%v", err) + } + all := strings.Join(rep.Remote.Preflight.Warnings, "\n") + if !strings.Contains(all, "Tanky: the tank's max health is 641, the plan wants at least 800") { + t.Fatalf("the lead's health was not checked for the tank floor:\n%s", all) + } } func TestRemotePreflightMaintenanceOffOrRefused(t *testing.T) {