diff --git a/LICENSE b/LICENSE
index da89b404..0ad25db4 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,661 @@
-MIT License
-
-Copyright (c) 2026 pjerra
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+ .
diff --git a/LICENSES/MIT-turtle-agent-before-agpl.txt b/LICENSES/MIT-turtle-agent-before-agpl.txt
new file mode 100644
index 00000000..da89b404
--- /dev/null
+++ b/LICENSES/MIT-turtle-agent-before-agpl.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 pjerra
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/NOTICE b/NOTICE
index fd7f11cc..c9170644 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,6 +1,11 @@
turtle-agent
Copyright (c) 2026 pjerra
+turtle-agent is licensed under the GNU Affero General Public License v3.0 or later (see LICENSE).
+Versions before the licence change were MIT-licensed (LICENSES/MIT-turtle-agent-before-agpl.txt).
+The third-party code below stays under its own licence and is compatible with the AGPL.
+Contributions: one commit (af8a2fd, session create) by Sagiroth, relicensed with his consent (see the licence-change PR).
+
turtle-agent contains code ported from agent-wow
(https://github.com/agent-wow/agent-wow), commit 5ef4916, used under the MIT License:
diff --git a/README.md b/README.md
index b06fd516..d1b92644 100644
--- a/README.md
+++ b/README.md
@@ -93,8 +93,9 @@ turtle-agent capture-stats
turtle-agent capture events [--from RFC3339] [--to RFC3339] [--kinds a,b] [--out FILE [--force]]
turtle-agent map [--capture FILE] [--events FILE] [--report FILE] [--botwatch FILE [--members a,b]] [--plan dungeons/x.json] [--out run.svg] [--html] [--force] [--mmaps DIR | --config FILE] [--map N] [--box x0,y0,x1,y1] [--from RFC3339] [--to RFC3339] [--pad YD] [--floors auto|off|z,z] [--background=false] [--width PX]
turtle-agent tune-diff
-turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--mmaps DIR] [--rev SHA] [--stamp YYYYMMDDHHMMSS] [--force] --out FILE|DIR (... | --all-live)
+turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--carrier-chests] [--skip-legs NAME,...] [--mmaps DIR] [--rev SHA] [--stamp YYYYMMDDHHMMSS] [--force] --out FILE|DIR (... | --all-live)
turtle-agent plan-check [--dir dungeons] --mmaps DIR [--from-leg N [--from-pull-group N]] [--json] ...
+turtle-agent route-oracle --plans DIR --mmaps DIR --out DIR [--mode walk|bosses] [--objectives plan|bosses] [--plan NAME]...
turtle-agent dashboard [--listen 127.0.0.1:8790] [--allow-host HOST[,HOST]] [--agents auto|] [--tests-dir DIR] [--traces-dir DIR] [--plans-dir DIR[,DIR]] [--minimap-dir DIR] [--embed-from ORIGIN[,ORIGIN]] [--config-dir DIR]
turtle-agent minimap-extract --client DIR --out DIR [--maps 70,209|all-instances] [--mmaps DIR[,DIR]] [--prefer worldmap,minimap,navmesh] [--list]
turtle-agent trace-score [--json] [--summary] [--set check=threshold]... ...
@@ -264,7 +265,7 @@ Python (`urllib`): read the token file, strip it, and add `"Authorization": "Bea
| `gameobjects` | `within` (yards, 3D; `radius` is the same), `entry`, `type` (a name such as `door`, `button`, `chest`, `goober`, `spell_focus` or its number 0-30), `name`, `limit` | nearby game objects, nearest first: `guid`, `entry`, `name`, `type`, `type_name`, `x`/`y`/`z`/`o`, `distance`, `distance_2d`, `state` (`ready`, `active`, `active_alt`), `flags`, `dyn_flags`, `locked`, `usable`, `display_id`, `created_by` (see [Game objects](#game-objects)) |
| `use_object` | `guid`, `wait_ms` (default 3000) | `since`, `object` (as a `gameobjects` row), `outcome` (the kind of the first answering event, or `none`), `event`, `hint` (why nothing came back, when likely). Sends `CMSG_GAMEOBJ_USE`; see [Game objects](#game-objects) |
| `open_object` | `guid`, `spell_id` (one Opening spell; omitted: 6477, then 3365, 6247, 6478, 22810 as far as the spell book has them, the next after a bad-targets or low-skill refusal), `take` (take everything and close the window), `wait_ms` (default 15000, at most 25000: the cast takes about 5 s) | `window` (as `loot`), `taken` (the slots now in the bags, with `take`), `money`, `left`. Casts an Opening spell at a chest or node in view and waits for its loot window; see [Game objects](#game-objects) |
-| `dungeon_run` | `name` (a plan in `dungeons_dir`, `a-z0-9_-`), `allow_draft` (run a plan whose status is `draft`; refused without it, see [Dungeon plans](#dungeon-plans)), `from_leg` (a leg number from 0 or its name), `teleport` (GM-teleport to the start and summon the party), `no_shortcuts` (no GM revive), `setup` (the GM commands you ran to set the run up, as a list of text such as `["go entrance", "levelup -15"]`; they go into the report's `setup_shortcuts`, the run sends none of them), `buffs` (optional GM boost for the companions: a spell id, a preset name or a list of both, `"world"` being the classic world buffs; see [Dungeon runs](#dungeon-runs)), `brain` (a [bot brain](#bot-brain) preset such as `"frost_mage"`: it plays the leader's character during the run's fights; off by default), `gm` (`false`: the leader's account is a player's and the run sends no GM command at all; `true`: a GM, no probe; omitted: the run asks the server and a probe with no answer counts as a player, see [Dungeon runs](#dungeon-runs)), `gm_level` (with `gm: true`: the account level, 1 to 6, default 3; it gates teleport, `.gm visible on`, revive and buffs), `recover_wipe` (the party, all dead, runs back to its corpses and the run goes on; **on by default in a commander run** (`mode "commander"`, the bots are revived with `.bot cmd revive`; a recovered wipe retries the same step, at most twice per run, and the run goes on after a refused revive), off by default in a group run; `false` turns it off in a commander run; an **observe** run refuses any `recover_wipe` value, true or false, and its trace options say off), `wait_sickness` (wait resurrection sickness out after the leader's corpse run), `resume` (`true`: skip what the party has already passed when it starts inside the leg, off by default, see **Resume** under [Dungeon runs](#dungeon-runs)), `from_pull_group` (remote mode: start inside `from_leg`, at that pull group), `start_here` (remote mode: `from_leg` without the move, the old behaviour) and `pulltest` (`{"leg": 2, "pull_group": 1, "tries": 3, "reset": "auto"}` runs one pull as a scored scenario, in commander mode scoring the party, in leader mode the character, see [Start anywhere and the pull test](#start-anywhere-and-the-pull-test)), `mode` (the [run mode](#run-modes): `"commander"` (the older `"remote"`): lead a bot group from outside it, see [Remote leader](#remote-leader); `"leader"` (the older `"group"`, the default without a brain): the agent's character is in the group, leads it and plays; `"observe"`: a TortoiseBots run leader clears the dungeon and the agent only watches, see [Observe mode](#observe-mode)), `observe` (observe mode: `{"plan":"uldaman","poll_s":15,"max_run_s":10800,"stall_s":600,"paused_wait_s":600}`, all optional), `rotation` (leader and group mode: a [rotation file](#rotations-as-data) that takes the place of the brain preset's class rotation), `role` (leader and group mode: the brain's role, `dps`, `tank` or `healer`; with `mode: "leader"` and no `brain` it also picks the preset of the character's class) and, for commander mode, `lead_bot`, `bots`, `tank`, `healer`, `cc`, `carrier` (the bot that does the object steps), `loot_restore` (`group`, `rr`, `master` or `nbg`: the loot method put back after its free-for-all loot), `object_fallback` (`agent`, the default, or `off`: whether the agent's own character takes an object step over when the carrier's order fails twice, see [Remote leader](#remote-leader)), `team` (`alliance` or `horde`: the party's side for the team gate; omitted: the agent's own), `preflight` (`warn`, the default, `strict` or `off`: the kit check before the run), `on_end` (`stay`, the default, `logout` or `none`), `keep_on_fail` (remote: a failed run keeps its binding so a resume re-enters the same instance; default off, `dungeon_stop` lets it go), `enter` (`auto`, the default, `gm`, `cmd`, `walk` or `off`: how the run gets a party that is outside the plan's map in, see [Remote leader](#remote-leader)), `ignore_entry_limit` (try a 6th new instance within the hour) and `prepare` (`{"online":true,"fresh":true,"gm_setup":["gm visible off"],"online_wait_s":45}`: log the bots in, get a fresh instance, send the GM setup lines, all before the bind, see [Prepare](#prepare-one-step-before-the-bind); `{"party":"hire","hire":{...}}` instead hires, checks and binds the party and takes `lead_bot`, `bots`, `tank` and `healer` out of the call, see [Hired party](#hired-party-prepare-party-hire)) for it, and in either mode `watchdog` (`{"warn_s":120,"recover_s":240,"fail_s":420}`: the stall levels of the [run watchdog](#run-watchdog), seconds without progress; a field left out takes the plan's or the default, a negative one turns its level off) | `started`, `plan`, `map`, `from_leg`, `from_pull_group`, `legs[]`, `buffs[]` (the spells the run will apply, `Name (id)`), `brain` (with a rotation: `preset+rot:@`), `leader_gm` (`true`, `false` or `probe`), `mode` (`group`, `remote` or `observe`), `run_mode` (`leader`, `commander` or `observe`), `lead_bot`; the run goes on in the background, see [Dungeon runs](#dungeon-runs) A commander-mode run first walks the plan on the navmesh ([Plan check](#plan-check)): a live-checked plan with a failing point, or one that cannot be checked, is refused unless `force` is given; any other status runs with the findings as a warning. `force_start` (commander mode only: leader and observe mode refuse it) runs a `from_leg` / `from_pull_group` / `pulltest` start that is under 30 yd from a live pack or boss of a skipped step in a fresh instance ([Start anywhere](#start-anywhere-and-the-pull-test)); `force` does not pass that gate and `force_start` does not pass the plan check. The result carries `plan_check` (`passed: ...`, `forced past plan check ...`, `warning (...)`) |
+| `dungeon_run` | `name` (a plan in `dungeons_dir`, `a-z0-9_-`), `allow_draft` (run a plan whose status is `draft`; refused without it, see [Dungeon plans](#dungeon-plans)), `from_leg` (a leg number from 0 or its name), `from_step` (an observed run only: the run leader's own step number, from 1, in place of from_leg/from_pull_group), `teleport` (GM-teleport to the start and summon the party), `no_shortcuts` (no GM revive), `setup` (the GM commands you ran to set the run up, as a list of text such as `["go entrance", "levelup -15"]`; they go into the report's `setup_shortcuts`, the run sends none of them), `buffs` (optional GM boost for the companions: a spell id, a preset name or a list of both, `"world"` being the classic world buffs; see [Dungeon runs](#dungeon-runs)), `brain` (a [bot brain](#bot-brain) preset such as `"frost_mage"`: it plays the leader's character during the run's fights; off by default), `gm` (`false`: the leader's account is a player's and the run sends no GM command at all; `true`: a GM, no probe; omitted: the run asks the server and a probe with no answer counts as a player, see [Dungeon runs](#dungeon-runs)), `gm_level` (with `gm: true`: the account level, 1 to 6, default 3; it gates teleport, `.gm visible on`, revive and buffs), `recover_wipe` (the party, all dead, runs back to its corpses and the run goes on; **on by default in a commander run** (`mode "commander"`, the bots are revived with `.bot cmd revive`; a recovered wipe retries the same step, at most twice per run, and the run goes on after a refused revive), off by default in a group run; `false` turns it off in a commander run; an **observe** run refuses any `recover_wipe` value, true or false, and its trace options say off), `wait_sickness` (wait resurrection sickness out after the leader's corpse run), `resume` (`true`: skip what the party has already passed when it starts inside the leg, off by default, see **Resume** under [Dungeon runs](#dungeon-runs)), `from_pull_group` (remote mode: start inside `from_leg`, at that pull group), `start_here` (remote mode: `from_leg` without the move, the old behaviour) and `pulltest` (`{"leg": 2, "pull_group": 1, "tries": 3, "reset": "auto"}` runs one pull as a scored scenario, in commander mode scoring the party, in leader mode the character, see [Start anywhere and the pull test](#start-anywhere-and-the-pull-test)), `mode` (the [run mode](#run-modes): `"commander"` (the older `"remote"`): lead a bot group from outside it, see [Remote leader](#remote-leader); `"leader"` (the older `"group"`, the default without a brain): the agent's character is in the group, leads it and plays; `"observe"`: a TortoiseBots run leader clears the dungeon and the agent only watches, see [Observe mode](#observe-mode)), `observe` (observe mode: `{"plan":"uldaman","poll_s":15,"max_run_s":10800,"stall_s":600,"paused_wait_s":600}`, all optional), `rotation` (leader and group mode: a [rotation file](#rotations-as-data) that takes the place of the brain preset's class rotation), `role` (leader and group mode: the brain's role, `dps`, `tank` or `healer`; with `mode: "leader"` and no `brain` it also picks the preset of the character's class) and, for commander mode, `lead_bot`, `bots`, `tank`, `healer`, `cc`, `carrier` (the bot that does the object steps), `loot_restore` (`group`, `rr`, `master` or `nbg`: the loot method put back after its free-for-all loot), `object_fallback` (`agent`, the default, or `off`: whether the agent's own character takes an object step over when the carrier's order fails twice, see [Remote leader](#remote-leader)), `team` (`alliance` or `horde`: the party's side for the team gate; omitted: the agent's own), `preflight` (`warn`, the default, `strict` or `off`: the kit check before the run), `on_end` (`stay`, the default, `logout` or `none`), `keep_on_fail` (remote: a failed run keeps its binding so a resume re-enters the same instance; default off, `dungeon_stop` lets it go), `enter` (`auto`, the default, `gm`, `cmd`, `walk` or `off`: how the run gets a party that is outside the plan's map in, see [Remote leader](#remote-leader)), `ignore_entry_limit` (try a 6th new instance within the hour) and `prepare` (`{"online":true,"fresh":true,"gm_setup":["gm visible off"],"online_wait_s":45}`: log the bots in, get a fresh instance, send the GM setup lines, all before the bind, see [Prepare](#prepare-one-step-before-the-bind); `{"party":"hire","hire":{...}}` instead hires, checks and binds the party and takes `lead_bot`, `bots`, `tank` and `healer` out of the call, see [Hired party](#hired-party-prepare-party-hire)) for it, and in either mode `watchdog` (`{"warn_s":120,"recover_s":240,"fail_s":420}`: the stall levels of the [run watchdog](#run-watchdog), seconds without progress; a field left out takes the plan's or the default, a negative one turns its level off) | `started`, `plan`, `map`, `from_leg`, `from_pull_group`, `legs[]`, `buffs[]` (the spells the run will apply, `Name (id)`), `brain` (with a rotation: `preset+rot:@`), `leader_gm` (`true`, `false` or `probe`), `mode` (`group`, `remote` or `observe`), `run_mode` (`leader`, `commander` or `observe`), `lead_bot`; the run goes on in the background, see [Dungeon runs](#dungeon-runs) A commander-mode run first walks the plan on the navmesh ([Plan check](#plan-check)): a live-checked plan with a failing point, or one that cannot be checked, is refused unless `force` is given; any other status runs with the findings as a warning. `force_start` (commander mode only: leader and observe mode refuse it) runs a `from_leg` / `from_pull_group` / `pulltest` start that is under 30 yd from a live pack or boss of a skipped step in a fresh instance ([Start anywhere](#start-anywhere-and-the-pull-test)); `force` does not pass that gate and `force_start` does not pass the plan check. The result carries `plan_check` (`passed: ...`, `forced past plan check ...`, `warning (...)`) |
| `death_status` | | `dead` (health 0, spirit not released), `ghost` (released), `resurrect` (an open offer: `caster`, `name`, `sickness`, `timer`; null for none), `reclaim_in_ms` (the corpse may be reclaimed then); see [Death and the corpse run](#death-and-the-corpse-run) |
| `accept_resurrect` | | Answers the open offer with yes (CMSG_RESURRECT_RESPONSE) and returns it. Errors when the player is alive or nothing is on offer |
| `release_spirit` | | CMSG_REPOP_REQUEST: the dead player becomes a ghost at the graveyard. Errors when alive or already a ghost |
@@ -581,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.
@@ -666,6 +669,7 @@ that carries a plan out through the RPC is described under [Dungeon runs](#dunge
in order. `rest` overrides the leg's rest gates (all keys optional, `0` or absent keeps the default): `{"health": 90, "healer_mana": 50, "mana": 35, "max_s": 60}`
(percentages 1-100; `max_s` 1-600 seconds; `walk_yd`, `walk_health`, `walk_healer_mana` and `walk_mana` set the walking rest, see **rest gate** under
[Dungeon runs](#dungeon-runs)). `companions_loot: false` on the **leg** has the party's looting off for the whole leg, from its start to its end (`.bot behavior loot off` for every bot, `on` after; remote runs). A leg that cannot have it does not start: a game that cannot send behavior orders, or a bot that refuses, fails the leg before it moves, naming the bots (an empty party counts as a failure), and the bots already turned off are turned on again. A bot that joins the party during the leg is turned off too, before the next step (a bot that cannot be fails the step). At the leg's end each bot's looting is turned back on with up to 3 tries; a bot that still refuses, or a game that cannot send the order, ends the run **"done (degraded: looting left off on )"**: the state stays `done`, the report's `degraded` list and the `dungeon_done` event carry it, and the next run's start turns looting on for everyone. The same marks a run whose agent could not be put back at its safe point after an area trigger (a failed walk **and** a failed or ineffective `.go xyz`). Group mode refuses a leg with `companions_loot: false` at its start (only a remote run can enforce it for a whole leg), and the loader accepts it together with a `boss.loot_item` in the same leg (the remote run's `cmd loot` is an explicit order; the leg's mute is not turned back on by the boss; Zul'Farrak's stairs leg takes the Executioner's Key that way). the Zul'Farrak graveyard, where a bot that loots opens a Shallow Grave chest and wakes what lies in it. It is not the boss's `companions_loot` (below), which is only for the fight and the leader's loot; a boss in a leg that has it off does not turn the looting back on. Absent or true leaves the companions as they are.
+- `leave` on a **leg** (`[{"entry": 7246, "spawn": 81610, "at": {"x":..,"y":..,"z":..}, "why": ".."}]`; `entry`, `at` and `why` are required, `spawn` is the creature guid, optional) names spawns a run must not pull, fight or walk up to: Zul'Farrak's lone Shadowhunter on a navmesh island (zf-r6-19z24: the run leader's hop pull engaged it and the tank ended on the island, where every move was refused). A spawn stands for the whole run, so every leg's points are held to every leg's list. The loader refuses a leave that is a pull group position of its own leg. The plan check (`internal/dungeon/leave_clear_test.go`, runs with `go test`) fails a plan with an approach point, a computed or written stand, a `to` or a via point within `LeaveClearYd` (15 yd, the run leader's hop pull reach) of a leave, and walks each pull group's approach rows on the navmesh (`TURTLE_AGENT_MMAPS`) to check the walk keeps 15 yd too (shortest distance over every segment), and the run leader's own walk from each stand toward its pack and back; the Zul'Farrak walks that plan data cannot move (5.0, 5.0 and 13.6 yd) are recorded as known entries. The agent's driver does not read the list. **Deploy order:** the loader refuses unknown fields, so a binary older than this change refuses `dungeons/zulfarrak.json` with `unknown field "leave"` (and any plan with a `leave`): ship the loader (turtle-agent dev) before the plan file. **`plan-export`** has no row for it: the list is written as free text in the leg row's comment (`leave (not read by the run leader yet): 7246 spawn 81610 at 1591 1047.7: `, cut to 255 characters) and is one warning (`leave`, not in the rows); a module that reads it would need a step kind or a flag on the leg's pull and hop rows (PLAN-ZF-LESSONS #3, TortoiseBots part).
- `optional` on a **leg** (`{"item": 9240, "name": "Mallet of Zul'Farrak"}`) marks a leg that needs an item no honest run carries (Zul'Farrak's gong needs the Mallet of Zul'Farrak): at the leg's start a remote run
reads the bags (`.bot inv`) of the carrier and the lead bot and, when neither has the item, **skips the whole leg**: nothing of it is walked, pulled or used, its `legs[i].result` is `skipped: no Mallet of Zul'Farrak (item 9240) in the bags of or (...)`,
a note says so, and the run goes on at the next leg; it is never a failure. A bag that cannot be read is an error, not a skip. A group-mode run does the same with the leader's bags. The run-start checks of `requires_item` (a start past the leg that makes the item,
@@ -722,13 +726,13 @@ that carries a plan out through the RPC is described under [Dungeon runs](#dunge
(never through a fight) to the `stand`, or 2.5 yd short of the NPC. It stays where it ends: the step does not walk it back, so an option that starts a fight (Bly's) belongs to `by: tank` or
`carrier`, or to a plan whose later steps keep the agent out of it. In group mode the leader is the agent's own character: it does every gossip step itself (`carrier` and `tank` are noted).
- **By the carrier or the tank** (commander runs): the party is walked to the NPC like for a chest (the approach), then the bot gets `cmd gossip `
- (TortoiseBots #1012; ` ` is the index, or the words of `text`, which the module matches as a substring: a bot's `text` may use only letters, digits and `_ . , : + -` in each
+ (TortoiseBots #1012; ` ` is the plan's `option` plus 1 (the order counts the menu from 1 and refuses 0, the plan counts from 0: the menu index the client sends), or the words of `text`, which the module matches as a substring: a bot's `text` may use only letters, digits and `_ . , : + -` in each
word, and must not be a number, which the plan loader checks). The final ACK ends the step; any other refusal is sent again until `retry_s` has passed (`busy`, `in-combat` and the other
waits as for the other orders), then the step fails with it and the `fail_text`. A module that does not know the verb (an unknown-verb refusal, or no answer to the order at all, as for
`cmd enter`) makes **the agent take the step once** (noted, `fallback_used` in the step's report, the notes say `the module has no cmd gossip`); the agent's attempt is the last one.
- The step **ends at `SMSG_GOSSIP_COMPLETE`** (the agent) or the order's ACK (a bot): it does not check what the option did. Put a `wait_for` after it (the door `active`, the unit hostile). A select that is
answered with another menu, another panel or nothing within 5 s fails the step: the option was sent, but nothing says it ran.
- - It is the commander report's `object_steps` entry with `kind: "gossip"` (`carrier`: the bot, or `agent`), and `plan-export` cuts a plan at its first leg with a gossip step (see the end of the Zul'Farrak stairs section) until the TortoiseBots run leader has one.
+ - It is the commander report's `object_steps` entry with `kind: "gossip"` (`carrier`: the bot, or `agent`), and `plan-export` writes a gossip step that names its `option` and is `by` `tank` or `carrier` as a `gossip` row for the TortoiseBots run leader (param = `option` + 1, the number `.bot cmd gossip` takes); a gossip by `text` or `by` `agent` still cuts the export at its leg (see the end of the Zul'Farrak stairs section).
- `use_item` uses an item from the leader's bags (`entry`, `name` for the messages) with `CMSG_USE_ITEM` on itself, waits for the
cast, and checks `expect_item` the same way (spell 9594 of the Gni'kiv Medallion makes the Staff of Prehistoria, 7733). `requires_item` is a
second item the use consumes (the Shaft of Tsol, 7741): checked before the use, so that the failure names it. A leader that carries the
@@ -855,6 +859,27 @@ that only the server's own pathing gives (a tank with no firing position), so a
has navmesh files here (`$TURTLE_AGENT_MMAPS`, `~/tb-ci/mmaps-turtle` or `~/tortoise-wow-server/data/mmaps`); the plans that fail today are listed in `planCheckKnownFailing` (issue #187; most are drafts, which only warn);
a listed plan that passes fails the test until it is taken off. The gate itself is tested with a stub navmesh, so it runs in CI without map files.
+### Route oracle
+
+`turtle-agent route-oracle --plans dungeons --mmaps DIR --out DIR` compares a plan's **stored route** with what the navmesh **plans live** for the same dungeon, and writes `/.txt` (a human table) and `/.json` (the machine report, Stage 1's fixture). It reads plan files and mmaps and nothing else; a plan's status, live or draft, changes nothing (the report is a measurement, not a gate). Without `--plan` it reports the five mapped plans: `wailing_caverns ragefire_chasm uldaman zulfarrak deadmines`.
+
+The walk it plans is the driver's own. Per leg the leg's stored points in the order planexport writes them — the `via` points, `to`, each pull group's `approach` points and its pull point (the positions' centroid, or a hold's spot), the steps' stand points, the boss's approach points and position, the after-boss stands — and every hop between two of them is planned twice: on the plain planner, and on a scratch planner with `SetAvoid` of the plan's avoid boxes. The avoid-aware paths are the live route the metrics use, because it is the one a run plans with; each stored hop is scored against its own navmesh hop (per-segment rows in the JSON, per-leg sums in the table).
+
+A hop that does not plan complete **fails its leg**: the leg takes the failing hop's status (`partial`, `no_path`), the leg's remaining hops are planned on from the point the failed path reached and marked `non_comparable` (that walk is not the plan's, so it scores nothing), and the legs after a failed leg are `indeterminate`. Every aggregate — the mean ratio, the packs on path, the break and redundancy counts — is over the complete legs only; the failed and indeterminate legs stay in the report for reading.
+
+Per leg the report holds: the status, end gap and live length of both runs; the stored hops' length and their ratio to the live length; the most and the mean distance of the leg's stored points to the live route; the route's Z jumps (consecutive samples of the corners and their ground sampling more than 8 yd apart in Z: ground the mesh joins without a climb); the leg's **breaks** — `via` points with `exact`, `direct`, `gm_cross` or `check_skip` — flagged when the live route passes within 6 yd of the foot, or when the failing hop is partial and its path's end stands within 10 yd of the foot; each pull group's corridor membership (one of its positions within the group's radius + 18 yd of the route, no more than 8 yd above or below it); and, for a complete leg, how many of the approach and via points are **redundant**: the navmesh path from the previous to the next stored point passes within 5 yd of the point, so the route would lose nothing without it. The plan's line sums it up, and the report names the SHA-256 of every `.mmap`/`.mmtile` file the planning used.
+
+The report is written under the **validated CLI plan name** (never the name inside the plan JSON — a plan file that says `name: "../escape"` is refused, and the destination must resolve under `--out`). `internal/routeoracle` holds the report; its tests plan over a synthetic mesh (`internal/navmesh/navfixture`) and need no real mmaps.
+
+**Modes.** `--mode walk` (the default) is everything above: the plan's own stored sequence, hop by hop. `--mode bosses` asks a different question, how much of a route the live navmesh finds with the plan's hand data left out: each objective is ONE avoid-aware navmesh plan from where the party stands, using no hand point and skipping no break hop (a `direct`, `gm_cross` or `check_skip` via). It writes `-bosses.txt/.json`. `--objectives` picks the objectives:
+
+- `--objectives plan` (the default): each stored leg's own objective, its boss, else its `to`, else its last step's point. A leg without a boss is planned to a stored `to` point (a hand-chosen waypoint), so this is not a bare boss list.
+- `--objectives bosses` (needs `--mode bosses`): ONLY the chain entrance -> boss 1 -> boss 2 -> ...; a leg without a boss is folded into the next boss's row (its pull groups, stands and `to` are measured against that row's live path; no stored `to` is an objective), and the legs after the last boss are not planned. It writes `-bosses-only.txt/.json`.
+
+An objective the mesh cannot reach is a failed leg (`partial` / `no_path`): it scores nothing, names the stored break it ran into (a break hop whose foot the mesh reaches and whose landing reaches the objective, or one within 15 yd of the live end or of the line to the objective), and the objective after it is planned from the last point reached and labelled non-comparable (excluded from every aggregate); it is also re-planned from the previous objective (`bridged`), beside the primary numbers. Both modes judge a pack the same way: one of its positions within its radius plus 18 yd of the route, 8 yd of height. A pull group with neither a position nor a hold is skipped and counted (`empty_groups`).
+
+**Output safety.** `--out` is refused when it is, or has a component that is, a symlink (a host where `/tmp` or `/home` is itself a symlink must name the real folder). A report file that already exists as anything but a regular file (a symlink, even a dangling one, a folder) is refused, and each file is written to a temp file in the folder and renamed over the target, so no existing link is followed. Documented limit: the folder is validated by path, so a concurrent rename of a writable ancestor between the check and the open could redirect the writes; this is a local, owner-run tool and the limit is accepted.
+
### Exporting plans for the TortoiseBots run leader
`turtle-agent plan-export` writes the plan file as rows for the TortoiseBots run leader (`.bot run`; its tables `ai_dungeon_plan`, `ai_dungeon_leg`,
@@ -875,20 +900,21 @@ turtle-agent plan-export --all-live --out ./migrations/ --stamp 20261014200000
| `...` / `--all-live` | named plans of `--dir` (default `dungeons`), or every plan whose status starts with `live-checked`. A `draft` plan is refused unless `--allow-draft` |
| `--mmaps DIR` | a navmesh folder: a hop of more than 60 yd between two moves (the module logs these) is cut into `move` rows along the planned path, at most 50 yd apart. Without it the long hops are listed on stderr and left |
| `--approach-hops` | with `--mmaps`, also cut the hop to a pull, boss or hold more than 50 yd from the anchor before it (the move, or the fight the party stood at) into `move` rows along the navmesh path (comment `approach path`), the last at least 25 yd short of the pack. Without it such a hop is left to the run leader's own navmesh hops (at most 8 of 40 yd, stopping 30 yd short), which a plan of long corridors outruns. Not on by default: it would change the rows of the plans already exported |
+| `--skip-legs NAME,...` | leave the named legs out of the rows (each with a warning; the later legs close up, as for an optional leg), for a leg the run leader cannot do while the legs after it can: Zul'Farrak's `bly` (a boss that turns hostile after its gossip) stands between Weegli's gossip, which opens the End Door, and `ukorz` behind it. A name that is no leg of the plan is refused |
| `--gm-cross-direct` | export a `gm_cross` waypoint as a `move_direct` (a straight walk to the landing point) instead of refusing the plan; the run leader has no GM move. Written for Wailing Caverns' ramp to Lord Serpentis (6 yd down over 3.9 yd), which the plan now crosses with a plain direct step and a `check_skip`, so no plan needs it now; the row's comment and a warning say it is not tried live |
| `--rev`, `--stamp` | the commit named in `ai_dungeon_plan.source` (default: `git rev-parse` of the plan folder, `+local` when it has changes) and the file's timestamp (default now) |
-Per leg the rows are, in the order the driver does them: the `via` waypoints (`move`, or `move_direct` for `direct`), `to` (`move`), each pull group (`pull`: the centre is the
+Per leg the rows are, in the order the driver does them: the `via` waypoints (`move`, or `move_direct` for `direct`; an `exact` via is a `move` with flags 64, the run leader's exact waypoint: done only once the lead bot stands within 1.5 yd of it, still for 2 s, and only then the direct step after it goes out, TortoiseBots `docs/guides/dungeon-run-leader.md` "Exact waypoints"; that direct step is written as kind `move_hop_exact`, which an older TortoiseBots does not know: its loader skips the row, so a rolled-back binary never sends the hop from short of the foot (it fails closed); the export refuses rows where anything comes between the two, an exact move before a plain `move_direct`, or a `move_hop_exact` not right after an exact move; a via's `check_skip` is the plan check's only and is not written, without a warning), `to` (`move`), each pull group (`pull`: the centre is the
mean of its positions, the radius is its `radius` or the farthest position plus 10 yd, `entries` once each in first-seen order, flags 1 ambush / 2 alliance / 4 horde from `ambush`
and `team`), the `steps` (`use_object` as a `move` to its `stand` and an `open` whose `item` is `requires_item`; `wait_for.gameobject` as `wait_object` with `param` 0/1/2 for
ready/active/active_alt; `hostiles_gone` as `wait_clear`, centred on its `center` or on the last point of the leg; `loot_object` as `loot` with `item` the `expect_item`; `use_item` with
-`item` the `expect_item` and `param` the `requires_item` (the second item the use consumes; the run leader sends the use to the bot that carries both); `ritual`; a `gossip` step and a `wait_for` `unit` are **refused** in the first leg and **cut** the export before a later leg that has one (see **a step the run leader cannot do** below): the run leader has none; `area_trigger` as a `move` to its `stand`, because the run leader has no such step and a bot that walks into the zone takes the trigger itself when the module lists it (`BotAreaTriggers`; `by` `agent` and the `wait_for` `unit` are refused with that reason)), the `boss` (`entries` = `first`, `flags` from `team`; its `loot_item` as a `loot` of the boss entry right after it) and the `after_boss` steps. A pull group's or boss's
+`item` the `expect_item` and `param` the `requires_item` (the second item the use consumes; the run leader sends the use to the bot that carries both); `ritual`; a `gossip` step with an `option` number and `by` `tank` or `carrier` as a `gossip` row (`entry` the NPC, x,y,z its `near`, `param` = `option` + 1, flags 0, `timeout_s` 0; the lead bot talks, the `stand`, `by` and `retry_s` are warnings, `fail_text` is in the comment); a `gossip` by `text` or `by` `agent`, and a `wait_for` `unit` without a `hold`, are **refused** in the first leg and **cut** the export before a later leg that has one (see **a step the run leader cannot do** below): the run leader has none; `area_trigger` as a `move` to its `stand`, because the run leader has no such step and a bot that walks into the zone takes the trigger itself when the module lists it (`BotAreaTriggers`; `by` `agent` and the `wait_for` `unit` are refused with that reason)), the `boss` (`entries` = `first`, `flags` from `team`; its `loot_item` as a `loot` of the boss entry right after it) and the `after_boss` steps. A pull group's or boss's
`approach` points are `move` rows right before its row (comment `pull group N approach`); a `hold` is a `hold` row in its place (`entry` the instance data, `param` 3 = DONE, `flags` 40 =
done by instance data 8 + encounter states 32, `radius`, `timeout_s`, `entries` the group's, or the boss entry, its `first` and the hold's `entries`); a `move` straight before it to its
own spot is left out (the hold walks there, and the fight that starts with a ritual stays the hold's), and a `wait_for.gameobject` straight after it waits at most 60 s (the event's DONE
opens the door). A `ritual` row carries its `participants` in `param`. With `--mmaps` a long hop is measured from the last pull, boss or hold row when one comes after the last move (the
party fought there), so a leg's first move after a boss is not walked back from the move before the fight. Every `run leader hop` warning of the plan check is printed too. **Avoid boxes**: the run leader does not know a plan's `avoid` boxes (the rows have no column for them), so with `--mmaps` and a plan that has boxes the export checks every hop between two anchors (the entrance, move, pull, boss and hold rows) on the plain navmesh, the one the server walks: a hop whose path enters a box gets the corners of the avoid-aware path as `move` rows (comment `path (keeps out of an avoid box)`, hops of at most 25 yd, refined until each hop's plain path is clear), and a hop that cannot be made safe fails the export. Ragefire Chasm needs none (its plain paths already keep out of the basin).
-`skip`, `rest` and `watchdog` are not carried over, as the guide says. A **leg's `companions_loot: false`** cannot be carried either (the rows have no column for it, so the run leader's bots would loot the Zul'Farrak graveyard's chests): such a plan is **refused**, naming the leg, unless `--drop-companions-loot` says to export it without. An `area_trigger` is exported as a `move`, and its comment says `needs AiPlayerbot.BotAreaTriggers = 962 (TortoiseBots #1002)`. A row the module's loader would skip (radius over 100, timeout over 600, more than 16 entries, a
+`skip`, `rest` and `watchdog` are not carried over, as the guide says. **Nothing is dropped silently**: every plan attribute the rows cannot carry is a warning on stderr, one per attribute, naming each place it stands and what the run leader does instead (`skip`, `leave`, `leftover_entries`, a leg's `rest`, `needs_walk`, `patrol`, `boss.tactics`, `boss.companions_loot`, a dropped `companions_loot: false`, `fleers`, `no_cc`, `summoned_kill_first`, `hire_composition`, `level_band`, `raid`, `watchdog`, the entrance and teleports, `avoid` without `--mmaps`, a `stand` of a `loot_object` or `ritual`, an object's `spawn` or `range`, a `loot_object` `batch`, an `area_trigger`'s expect check and timeout); the migration's header should list them. Not warned: what is no run attribute (name, version, status, kind, map, notes, a via's `check_skip`, a pull group's `equiv`, which only the pull-test scenario reads). A **leg's `companions_loot: false`** cannot be carried either (the rows have no column for it, so the run leader's bots would loot the Zul'Farrak graveyard's chests): such a plan is **refused**, naming the leg, unless `--drop-companions-loot` says to export it without. With `--carrier-chests` (which needs `--drop-companions-loot`) such a leg's `loot_object` steps are in the rows instead: one chest per triple, a `move` to the step's `stand`, a `loot` row at the chest's point (its expected item; flags 256, gone is done: a chest of `spawntimesecs` 7200 despawns once looted, so a run resumed after a wipe at one of these rows takes the gone chest as done) and — when the leg has `leftover_entries` — their pull at the chest's point, radius 15, comment `sweep` (the chest's linked trap raises its adds on the opener in the same server call the loot ACK answers, and round 3's eleven idle leftovers aggroed the walk to the next chest: a batch of 3+ chests wiped twice, so `loot_object batch` stays unexpressed and one chest a row keeps the adds two at a time); the run leader's `cmd loot` runs itself, and the four companions' looting still has to be turned off for the leg in the setup, or a module with a trapped-chest rule has to skip them — the warned `companions_loot false` text says so: the setup is looting off for every bot, party loot method free-for-all, the Executioner's Key 8444 on the lead bot, at least 16 free bag slots on the lead bot and a fresh instance (a looted grave respawns after 2 h). The loot row's flags 256 need a TortoiseBots module with the gone-is-done step flag: an older module ignores them and pauses on a despawned grave after a wipe (then a .bot run skip skips the row). Without the option the steps are left out as before. An `area_trigger` is exported as a `move`, and its comment says `needs AiPlayerbot.BotAreaTriggers = 962 (TortoiseBots #1002)`. A row the module's loader would skip (radius over 100, timeout over 600, more than 16 entries, a
step without its entry) is refused here. The written SQL is read back and compared with the rows before the file is written. Uldaman exports 148 rows (14 legs, 133 steps, two
of them holds); with its approach points and the hop measured from the last fight no move is more than 60 yd from the party, so `--mmaps` cuts nothing (the export of dd05724 cut 7 hops into 220 rows, walking the party back from the leg before's last move).
@@ -913,10 +939,18 @@ The terrace exemptions (`ragefireExemptions` in `ragefire_test.go`, settled with
### Zul'Farrak events: Gahz'rilla, the courtyard and Zum'rah
+**The pool stairs (`velratha` pull groups 5 and 6, 2026-10-20).** In the full observe run (`zf-full-19z16`, RUN-LESSONS) the run leader stopped at the pack of four at 1601-1610 1104-1111 and then stood at 1629.2 1099.0 z 9.7, a navmesh **island** at the foot of the stairs: a path from 1595.5 1092.4 to it is `partial` and ends 8.8 yd short at 1621.3 1102.9. Three followers and the healer reached the upper level instead (1629-1636 1070-1099, z 31-46.8, 46 yd from the lead, "reach spell" at a mob 37 yd below) and the gather took 6 min to summon them back. The two groups now carry `approach` rows — 1594 1011, 1598 1028 and 1606 1040 for the five-mob pack (the last point moved from 1582 1070 on 2026-10-21 because that spot was 10.8 yd from the Witch Doctor and inside the patrol's reach, and from 1584 1050 / 1603 1043 the same day because those stood 7.4 and 12.9 yd from the lone Shadowhunter 81610, now on the leg's `leave` list), 1588 1080 and 1584 1088 for the four-mob pack (moved from 1593.6 1089.9, 16.6 yd from a Shadowhunter); the distances are in the plan note — so the run leader's party walks to a stand on the lower floor west of the stair mouth (path corners of the 209 mmaps; not live-run yet). The refused pullback of the same run (144 s) is the run leader's own retry, not the plan.
+
+**The pyramid phase in the plan (2026-10-21, feat/zf-full-module-plan).** The stairs event's phase is instance data **1** (`EVENT_PYRAMID`, 0-8 in `zulfarrak.h`: 3 = `PYRAMID_WAVE_1`, 7 = `PYRAMID_WAVE_3`). The agent's own driver still cannot read it: its `wait_for` holds on units, objects and hostiles, so `stairs_top` waits for the proxy (Nekrum 7796 seen at the foot). For the TortoiseBots run leader the waits carry a `hold` (`wait_for.hold`, the wait's run leader form: plan-export writes the hold row in place of the wait) with a `value` (`hold.value`: the instance data value that ends the event, written as a script value, flags 8 without the encounter flag 32): the cage leg's Weegli wait holds the stairs top to 3, `stairs_top`'s Nekrum wait to 7. The run leader compares for equality, so a value must last (3 and 7 last a whole wave; 2 lasts one instance update). A value is refused on a pull group's or a boss's hold and when it is 0.
+
+**Plan format compatibility (2026-10-21).** `wait_for.hold` and `hold.value` are new fields of plan format version 1; the version number is unchanged (every plan file would have to change with it). The plan reader refuses unknown fields (`DisallowUnknownFields`), so a turtle-agent binary built before feat/zf-full-module-plan (f11df6d) refuses `dungeons/zulfarrak.json` from that branch on (`unknown field "hold"` / `"value"`): run Zul'Farrak with a binary built from this branch or later. Other plan files do not use the fields and load in either build.
+
**Gahz'rilla (PR A, stacked on PR B).** The optional `gahzrilla` leg sits between `velratha_b` and `courtyard`: it walks to the gong stand (1652.84 1168.99 10.67, 3.5 yd from the gong), pulls the eight packs on and near the walk Gahz'rilla takes out of his pool, then the `use_object` step with `via_item` rings the
Gong of Zul'Farrak (141832, spawn row 27140 at 1650.91 1171.88 10.9, lock 459): a bot that carries the Mallet of Zul'Farrak (9240) gets `cmd use 9240 entry:141832@1650.9,1171.9,10.9` (the Mallet is used ON the gong: its on-use spell 11434 is an open-lock effect aimed at a game object; round 3 of the live test sent `cmd open` of the gong, which did nothing and answered nothing); a `wait_for` waits up to 30 s for a living Gahz'rilla (7273) in the bots' view, and the boss fight kills him at 1702.58 1224.19 8.88. Without a Mallet in the lead bot's or the carrier's bags the leg is skipped (see `optional` above); a test gives it with `give_items`.
-What the world database says (tortoise-wow-cbspin 4f133ab): the gong is a goober with lock 459 and event 2488, the client shows it usable, so the Mallet is checked on the server when it is used. Event 2488 respawns the other gong object (guid 209001) and 2 s later summons Gahz'rilla (7273, level 46, faction 107, no spawn row)
+What the world database says (tortoise-wow-cbspin 4f133ab): the gong is a goober with lock 459 and event 2488, the client shows it usable, so the Mallet is checked on the server when it is used. Event 2488 respawns the other gong object (guid 209001, entry 20901, a Turtle decoration copy with no lock and no event; the plan never targets it) and 2 s later summons Gahz'rilla (7273, level 46, faction 107, no spawn row)
at 1664.65 1187.67 -2.89 for 3600 s: the pit under the pool, 22 yd from the stand. His AI casts spell 33007, which fires event 2489: six moves one second apart, 1672.66 1195.93, 1681.74 1204.13, 1686.09 1207.3, 1690.54 1211.53, 1692.57 1213.66 and, 6 s after the summon, 1702.58 1224.19 8.88, which is where he stands (event 2489 has no summon: the "six trolls" of the first draft were those six rows).
+**The Mallet's source (tw_world, 2026-10-20, SELECT only).** `Lock.dbc` row 459 is type 1 (item required) with 9240, so the gong needs item 9240 and no skill or key. Item 9240 is not a quest reward, vendor item, chest or loot row anywhere: it is created by using item **9241 Sacred Mallet** (bind on pickup), whose on-use spell 11435 "Create Mallet of Zul'Farrak" (Effect 24) puts 9240 in the bags. 9241's only source is `creature_loot_template` entry 7996 at 100 %: **Qiaga the Keeper**, level 50 elite, faction 37, one spawn on map 0 at -284.792 -3447.45 188.426 (The Hinterlands); 9239 (the mace of the same name) has no source either. A ZF plan starts inside map 209 with hired level 46 bots and has no step kind for leaving the instance, travelling to map 0, killing a level-50 elite and returning, so **no bot run can obtain the Mallet**: the leg is skipped honestly, and only a test's `give_items` (`additem 9240`) reaches it.
+
The packs: the Velratha leg clears only the strip (and the pack of five at 1712-1720 1181-1190); eight groups remain within about 45 yd of his walk, each member checked against the export: [5649 5649 5650] at 1621-1625 1192-1197, [5650] 1643.6 1200.1, [7246 7246 8095] 1649-1654 1210-1215 (radius 30: the Sandcrawler patrols), [5649] 1652.1 1219.9, [5649] 1667 1233.6,
[5649] 1699.8 1233.8, [5650 7246] at 1700.3 1248.3 and 1713.2 1247.8 and [5649] 1714.4 1218.6 (the one the tank found no firing position for twice in the Velratha leg's batch-2 runs). The four casters at 1662-1667 1254-1260 (47 yd from the walk) are left out; the design list had them as two Shadowhunters and a Witch Doctor, the export has three Shadowhunters (rows 81594, 81592, 81595) and the Witch Doctor 81590.
The gong stand, every pack position and the boss position are on the navmesh and reachable (`TestZulfarrakOnNavmesh`, `TestZulfarrakPlanCheck`, `TestZulfarrakRouteHasNoUnplannedPack`). **Not yet run live with the Mallet.**
@@ -941,13 +975,13 @@ The six legs after `zumrah` were written from the instance script (`src/scripts/
- **`stairs`** (bots' looting off, the way out of the graveyard): the walk back by the courtyard waypoint, the Sandfury Executioner (7274) that stands by the cages, then `use_object` on the Troll Cage 141073 from 3.6 yd (the script's `go_troll_cage` frees the five crew members whichever cage is used) and a unit wait for Weegli (7607) at the top of the stairs, where he starts the event by arriving.
- **`stairs_top`**: the party holds 1887 1274 42, the spot the climbing trolls are sent to, and a `wait_for` `unit` 7796 (`seen`, 600 s) ends when Nekrum is in view. A wait does not fight: the bots defend themselves, and no formation (tank on the stairs, healer behind) can be asked of them. Waves 1 (23 trolls) and 2 (24) come up in groups of 2, 3, 4, ... every 10 s; wave 3 spawns the instant wave 2 is dead. The Wowhead Classic comments say the same: stay on top of the temple while the waves climb.
- **`stairs_base`**: wave 3 (Nekrum Gutchewer 7796, Shadowpriest Sezz'ziz 7275 and five trolls, none with a spawn row) is not sent up: Bly's crew goes down to the foot of the stairs and the party goes down too. `boss` Sezz'ziz with Nekrum first, then an `after_boss` `hostiles_gone` over the foot of the stairs, which is what ends the event (`PYRAMID_KILLED_ALL_TROLLS`). The order is the Wowhead guide's (burn Nekrum down first, he has the fewer abilities and the lower level; a melee interrupts Sezz'ziz's Heal; she also has Renew, Psychic Scream and Shadow Bolt); the script gives none, and the bots cannot be told to interrupt. If her Heal keeps him up, swap the two (boss 7796, first 7275).
-- **`weegli_door`**: a unit wait for Weegli at his place (1877.5 1199.6), the `gossip` by the tank (`text` "blow": "Will you blow up that door now?", index 0, offered only after the event), a unit wait for him `gone`, then a `wait_for` the End Door (146084) `active`. He walks to 1858.6 1146.4, plants a barrel, runs back to 1863.8 1177, the door is used and he despawns at 1827 1184. The plan talks to Weegli before Bly, as the Wowhead comments advise ("talk to the goblin first"): he is gone and the door open before the crew turns hostile, so the fight cannot kill him (a dead or fighting Weegli opens nothing, and the event cannot be restarted). The script has no preference (both orders run the same `DestroyDoor`); the wait for him to be gone keeps Bly's later `DoAction` from planting a second barrel. If Weegli died in the waves nothing opens the door and the first wait fails.
-- **`bly`**: a unit wait for Bly at his place (1883.8 1200.8), the `gossip` by the tank (`text` "settled": his only option is "That's it! I'm tired of helping you out. It's time we settled things on the battlefield!"; a menu that lacks it is asked again for 30 s), a unit wait for him hostile (the script turns him, Raven, Oro and Murta hostile 10 s after the option), then `boss` Bly with Murta, Oro and Raven first. Wowhead: Oro's area damage and Murta's healing make the fight hard, so they die before Bly; Murta is a priest (Holy Smite, Renew, Heal, Power Word: Shield), Oro a warlock (Shadow Bolt, Immolate, Rain of Fire, Curse of Weakness), Raven a rogue (Poison, Backstab, Gouge); Bly has 18k health, Revenge and Shield Bash and no loot but the quest's Divino-matic Rod. Differences: a comment says the crew uses Hearthstones and leaves if the party rests, and that talking to Weegli first then waiting leaves Bly to fight alone; the script has nothing of the kind.
+- **`weegli_door`**: a unit wait for Weegli at his place (1877.5 1199.6; for the run leader it carries a `hold` on instance data 1 = 8, `PYRAMID_KILLED_ALL_TROLLS`), the `gossip` by the tank (`option` 0: "Will you blow up that door now?", the only line of his menu, offered only after the event; plan-export writes it as the gossip row with param 1), then a `wait_for` the End Door (146084) `active`. He walks to 1858.6 1146.4, plants a barrel, runs back to 1863.8 1177, the door is used and he despawns at 1827 1184. The plan talks to Weegli before Bly, as the Wowhead comments advise ("talk to the goblin first"): he is gone and the door open before the crew turns hostile, so the fight cannot kill him (a dead or fighting Weegli opens nothing, and the event cannot be restarted). The script has no preference (both orders run the same `DestroyDoor`); the wait for him to be gone, the first step of the `bly` leg, keeps Bly's later `DoAction` from planting a second barrel. If Weegli died in the waves nothing opens the door and the first wait fails.
+- **`bly`**: a unit wait for Weegli `gone`, a unit wait for Bly at his place (1883.8 1200.8), the `gossip` by the tank (`text` "settled": his only option is "That's it! I'm tired of helping you out. It's time we settled things on the battlefield!"; a menu that lacks it is asked again for 30 s), a unit wait for him hostile (the script turns him, Raven, Oro and Murta hostile 10 s after the option), then `boss` Bly with Murta, Oro and Raven first. Wowhead: Oro's area damage and Murta's healing make the fight hard, so they die before Bly; Murta is a priest (Holy Smite, Renew, Heal, Power Word: Shield), Oro a warlock (Shadow Bolt, Immolate, Rain of Fire, Curse of Weakness), Raven a rogue (Poison, Backstab, Gouge); Bly has 18k health, Revenge and Shield Bash and no loot but the quest's Divino-matic Rod. Differences: a comment says the crew uses Hearthstones and leaves if the party rests, and that talking to Weegli first then waiting leaves Bly to fight alone; the script has nothing of the kind.
- **`ukorz`**: from the doorstep up the spiral ramp (193 yd, the plan check walks through the door because the navmesh has none), the Shadowhunters, Guardians and Abominations of the ramp as pull groups, then `boss` Chief Ukorz Sandscalp (7267) with Ruuzlu (7797) first. Wowhead: Ukorz has Cleave, Wide Slash, Enrage and Berserker Stance, Ruuzlu Cleave and Execute; face them away from the group, melee behind them, kill Ruuzlu first, both are immune to CC and the tank holds both; a comment says Ukorz runs back to his spawn and heals at about 6 %. The bots cannot be told to stand behind them. The comments say the serpents at the top need not be killed; the plan still pulls the two Abomination groups (8120) because the ramp passes within their aggro range.
The event is not restartable in an instance (the script keeps its phase), and the door stays open for the later entries of the same instance (`EVENT_END_DOOR` is saved).
-**A step the run leader cannot do** (a `gossip`, a `wait_for` `unit`, an `area_trigger` `by` `agent`): `plan-export` exports the plan up to, not including, the first leg that has one, and says so: a note on stderr (`plan zulfarrak: exported legs 0-10 (zumrah) only; cut before leg 11 (stairs), which has a step the run leader cannot do: steps[1]: wait_for.unit has no run leader step (unit 7607 seen); that leg and the 5 after it are not in the rows`), a `CUT:` line on stdout, and the end of the plan row's comment in the SQL. `--all-live` goes on with the other plans (exit 0). A plan whose first leg has such a step is refused, as before. The legs of `stairs` to `ukorz` are therefore not in the rows the run leader reads; they run only from the agent's `dungeon_run`.
+**A step the run leader cannot do** (a `gossip` by `text` or `by` `agent`, a `wait_for` `unit` without a `hold`, an `area_trigger` `by` `agent`): `plan-export` exports the plan up to, not including, the first leg that has one, and says so: a note on stderr (`plan zulfarrak: exported legs 0-14 (weegli_door) only; cut before leg 15 (bly), which has a step the run leader cannot do: steps[0]: wait_for.unit has no run leader step (unit 7607 gone); that leg and the 1 after it are not in the rows`), a `CUT:` line on stdout, and the end of the plan row's comment in the SQL. `--all-live` goes on with the other plans (exit 0). A plan whose first leg has such a step is refused, as before. The legs of `bly` and `ukorz` are therefore not in the rows the run leader reads; they run only from the agent's `dungeon_run`, unless the export is made with `--skip-legs bly` (Bly's crew turns hostile after his gossip, which the run leader cannot wait for): then `weegli_door` (the hold on phase 8, Weegli's gossip, the End Door wait) and `ukorz` follow `stairs_base`.
### The Uldaman plan
@@ -1158,7 +1192,7 @@ What the driver does for each action of the plan (the stepper decides which):
TortoiseBots, not to work around). After the cap the run goes on with a note if everyone has 50% health, else it fails. **Report:** `rest_seconds` (the run's total, and per leg),
and `rest_gates[]`, one per gate: `leg`, `seconds` waited, `max_s`, `result` (`rested`, `stalled` = passed with non-drinkers, `gave_up` = the cap came with someone short, `failed`),
`short[]` (who was short when it ended, `Wishe mana 29%`), `drink_orders[]`, `drink_order_sent`, `drink_order_result`, `party_rest_refused`,
- `skipped` and `refills[]`. **Lighter gates (both modes):** a gate that finds everyone at the targets on its first frame costs no time (`skipped: true`, result `rested`);
+ `skipped` and `refills[]`. In an observe run the run leader's own `rest` phases count as rest gates (`result` `run_leader`, and the `rest` trace events carry `"source": "run_leader"`), so `rest_share` is real there too; the time a run spent paused was not counted as rest (a paused status still names the phase it paused in). **Lighter gates (both modes):** a gate that finds everyone at the targets on its first frame costs no time (`skipped: true`, result `rested`);
the gate that opens a leg whose first step is a walk of 60 yd or more lets the party go when everyone is over the walking floors (health 70%, healer mana 40%, other
mana 25%) instead of holding: result `walking`, `skipped: true`, a note, and the party rests on the way; the gate before the first pull still waits for the targets.
A leg's `rest` overrides it: `walk_yd` (`-1`: always hold, 1-5000), `walk_health`, `walk_healer_mana`, `walk_mana`. The report totals them as `rest_gates_skipped` (of which
@@ -1366,7 +1400,7 @@ list). A group run has no `threat`; a remote run has no `moving`; neither has `i
Frame bots (`bots[]`, keyed by `g`; the name is in the party record): `al` alive, `cb` in combat, `st` engine state (`c` combat, `n` non-combat,
`d` dead, `r` reaction), `hp`/`mhp`, `pw`/`mpw` power and its maximum, `pt` power type (0 mana, 1 rage, 2 focus, 3 energy), `x` `y` `z`, `mv` a
movement is active, and when set `tg` target, `fa` facing (degrees 0-180 between where the bot faces and the direction to its hostile target in view), `cs` the
-spell it casts (feed), `act` its last action (feed), plus the agent's `np` (position unknown: x, y, z are 0) and `lead`. Frame mobs (`mobs[]`):
+spell it casts (feed), `act` its last action (feed; the module cuts the name to 16 bytes), `res` and `why` the verdict on that action and the reason a failed check did not run (feed, with TB #924; absent from older modules and traces), plus the agent's `np` (position unknown: x, y, z are 0) and `lead`. Frame mobs (`mobs[]`):
`g`, `hp` percent, `x` `y` `z`, `cb`, and when set `tg`, `cs`, `ev` evading, `cc` crowd-controlled, `thr` the top of the threat list as
`[guid, percent of the top]` pairs (feed only). A group run's mobs are the hostile units in combat or within 60 yd (at most 40), without
threat; a remote run copies the feed's party, hostiles and threat records. `end.counters` has the writer's `frames`, `events`, `party`,
@@ -1382,7 +1416,8 @@ Events (`ev`; fields beside `ev`, a field named like an envelope field is writte
| `order` | an order sent: group `via: "chat"`, `intent` (`pullback`, `attack`, `rest`, `follow`, `command`, ...), `text`; remote `via: "cmd"`, `verb`, `text`, `reply` (`ack`, `err`, `timeout`), `code`, `message`, `ms` (the answer's latency) |
| `refusal` | group mode: a bot's whisper or `TBM:ACTION_ERR` refusing an order: `intent`, `code`, `message` |
| `pull_start`, `pull_end` | a pull: `target`, `entry`, `guid`, position; the end has `outcome` (`won`, `no_engage`, `evaded`, `refused`, `retarget`, `error`), `kills`, `seconds` |
-| `rest` | a rest gate ended: `seconds` waited, `leg` |
+| `rest` | a rest gate ended: `seconds` waited, `leg`; an observe run's run leader rest has `source` `run_leader` |
+| `instance` | the lead bot's dungeon instance id, once, when the feed shows one: `instance`, `map` (the summary's `instance` comes from it when the header has none) |
| `stall` | the [run watchdog](#run-watchdog) fired: `level` (`warn`, `recover`, `fail`), `stalled_s`, `last_progress`, `waiting` (phase, step, last order), `phase`; a recovery has `recovery` (what it did) and `recovery_error`, a failure `error` |
| `walk_start`, `walk_end` | a walk of the plan: target `x y z`; the end has `ok`, `error`, `seconds` |
| `death`, `wipe` | a party member died (`a` its guid, `name`); the whole party was dead (a wipe, recovered or not) |
@@ -1428,13 +1463,14 @@ the others look at what only the agent sees (orders, rest gates, walks, the feed
| `rest_share` | the rest gates took more than this share of the run | 40 % | the run is shorter than 60 s |
| `stale_party` | a mana user's power did not change for this long while someone else's health or power did (the party data stopped updating); full mana and dead members are ignored | 60 s | no frames with a mana user |
| `walk_stops` | the lead stood still (at least 1.5 s) in the middle of a walk of the plan more often than this per 100 yd walked; the last 3 s of a walk are arriving | 4 stops / 100 yd | under 100 yd walked |
-| `threat_off_tank` | a party member other than the tank held the top threat of a living mob during a pull for longer than this; the tank is `header.tank`, else the `tank` role, else whoever holds the top threat most often | 4 s | no threat data (group runs have none) |
+| `threat_off_tank` | a mob in combat is on a party member other than the tank — its `tg` is that member, or the member ties the tank's threat — during a pull for longer than this; a dead tank, a mob with no target (polymorphed, fleeing, evading) and a mob out of combat do not count; the tank is `header.tank` (an observed run names its lead), else the `tank` role, else whoever holds the top threat most often; the detail gives the spans per tank-combat-hour | 4 s | no threat data (group runs have none) |
| `deaths` | any death or wipe | 0 | never |
| `order_refusals` | more than this share of the orders was refused (`err` replies and `refusal` events) | 20 % | fewer than 3 orders |
| `feed_stalls` | the longest gap between two feed frames is over this | 10 s | no feed frames (group runs) |
| `gm_shortcuts` | the honest-run flag: the run used any GM shortcut or was set up with `setup` commands, so a player could not have done it | 0 | never |
| `stuck` | a member that should be moving (the lead in a walk of the plan, or a member more than 10 yd from the lead in one; alive and out of combat) did not move 1 yd for this long; the detail lists where, for a per-dungeon stuck-spot map | 5 s, any stall | no walk of the plan, or no frames |
| `facing` | a bot in combat stands (no movement) within 6 yd of its hostile target for longer than this while its `fa` is over 90 degrees (the server rule, without its casting case: the agent does not see casts) | 2 s | the header lacks the `facing` capability, or no frame has a bot in melee range of its target |
+| `spells` | **report only, never fails**: what each class cast and did, as readings of 1 Hz samples, not facts. Cast episodes per class from `cs` (a run of frames with the same spell is `ceil(duration / base cast time)` casts, at most one per frame; a spell with no known cast time counts once per run, so that number is a lower bound), action mix, verdict letters and reasons, idle share, power at death, taunts, and two proximity/observation readings: **interrupts** (each interrupt action is matched to at most one enemy cast, from 1 s before its first frame to its last frame, and the interrupter's target must be the casting mob when both are known; an action in the frame right after the cast's last is counted apart, because an interrupt that worked and a kick that came too late look the same at 1 Hz; "confirmed cut short" is a cast seen for under 80 % of its cast time) and **crowd control** (an observation, not a success rate: some mob shows a new cc flag within 4 s of a single-target action; it is not tied to the caster or the spell, and a new flag on the caster's own target is counted apart), **taunt latency** (seconds from a hostile's top victim becoming a party member that is not the tank to the tank's taunt or the hostile going back to the tank; the frames are the resolution, about 1 s), **cc hold** (a cc episode is a run of consecutive frames with the mob flagged: only an episode that starts within 4 s of a single-target cc action is joined, and only long crowd control is joined (polymorph, sap, shackle undead, freezing trap, banish, hibernate, entangling roots, seduction, blind, fear, turn undead: the short stuns and disorients are left out); a joined episode under 5 s that ends with the mob still in view and unflagged, alive, was broken; the party's targeting of the mark is not measured) and **pet threat** (the share of mob-frames whose top threat holder's guid starts `0xf140`). A verdict is *unknown* where that bot sent no verdict letter (`res`) within 10 s: it is left out of the idle share and not counted as executed, so an older or mixed trace says `unknown`/`not recorded` instead of guessing. `trace-score` prints the tables under the checks; `--json` has them as `spells` | warnings only: `idle_share` over 15 % of engaged mana-user frames with a verdict and no executed (`O`) action for 4 s; `interrupt_cover` under 30 % of enemy casts (from 5) with an interrupt action during or just after them (the favourable reading is held to the limit); `taunt_latency` over 3 s median (from 3 episodes); `cc_hold` under 50 % held (from 3 episodes); `pet_threat` over 5 % of mob-frames (from 30); `--set idle_share=20`, `--set interrupt_cover=20`, `--set taunt_latency=4`, `--set cc_hold=40`, `--set pet_threat=10`. The uncorrelated crowd-control flag observation has no limit | no bot frame has a `cs` or an `act` |
Not verified live: the traces were built and tested against the fake game, the fake feed and hand-made traces only. Open: whether a
group run's party list stays fresh in a long fight (`stale_party` is there to find out), the feed's facing values, and what the frame
@@ -1603,8 +1639,32 @@ The server needs `AiPlayerbot.DungeonSelfClear = 1` and `AiPlayerbot.RemoteComma
two switches. No bot has been hired or bound at that point.
2. **Prepare, bind, enter.** Exactly the commander mode's: `prepare` (hire or named bots), `commander bind`, `view on`, the enter phase, the kit
check. `lead_bot`/`bots` or `prepare.party "hire"` are required; `preflight`, `enter`, `on_end`, `keep_on_fail`, `team` and `ignore_entry_limit` work as there.
-3. **Start.** `.bot run start ` (the plan's file name in lower case, or `observe.plan`). A run of the same plan that is already going (`busy`) is
- adopted and watched; another plan's, or a finished one, is refused.
+ **`.bot maintenance` is off in observe mode** unless `observe.maintenance` is true — in the kit check and in the hire check's top-up of
+ a reused hire or one short of drinks or ammo: maintenance walks each bot to a vendor, bank or mailbox, and a party scattered over the maps
+ right after the hires' autogear pauses the run leader ("the lead bot is not on the plan's map", #253). The inventory and talent reads still
+ run; a hired party already gets its autogear in `prepare`, and a hire still short of supplies is reported (and fails the step).
+ The start also undoes a boss `loot off` a crashed run left in place (`.bot behavior loot on` for every bot) unless
+ `observe.keep_loot_setting` is true: then the bots' corpse looting stays as the operator set it, as a dungeon whose legs need looting off
+ does. A driven run always sends the undo.
+3. **Start.** `.bot run start [from ...]` (the plan's file name in lower case, or `observe.plan`). A run of the same plan that is already going (`busy`) is
+ adopted and watched; another plan's, or a finished one, is refused. **Partway** (`from_leg`, `from_pull_group`, `from_step`, #248): the run leader begins at
+ that point of its own plan instead of step 1, for a party already standing at that point in the instance (the run leader checks
+ the map and that the party is together; the steps before are neither done nor checked). `from_leg` maps to the leg's number in the exported rows
+ (`from `; an optional leg has none, and plan leg names with an apostrophe make the number necessary), `from_pull_group` to the step of the group's
+ first row (`from step `) when the step count the server lists (`.bot run list`, at the probe) matches the rows the agent rebuilds with the exporter's
+ option sets (plain; cut hops; approach paths; avoid-box routes). When no set matches — the deployed rows were exported with other options, which shift the
+ step numbers — the run is refused at the probe, before a bot is hired, and `from_step` carries the number `.bot run status` shows (from 1) instead.
+ **Checked before and after the start, fail closed.** A leg whose name is one word the command grammar takes and is unique in the plan is sent as
+ `from leg ` (else its number). The start must be **proven**, not trusted: first by the run leader's own start line (`The run starts at step X of
+ Y (leg NAME)`) — the reported step must be the requested one and the leg the expected one (the leg asked for, or the requested step's leg from the plan
+ rows); only a start the module says it moved back to open a scripted event may differ, and it is noted. Any other step or leg means the deployed start
+ is not the plan's: the run is stopped and the watch refused. If no start line comes (an ACK-only reply, changed text, a dropped line), `run status`
+ right after the start must report the requested step (and its leg);
+ if neither source proves it, the newly started run is stopped and the watch refused — no unverified run is watched, and the failed run's unbind leaves
+ no hire bound. The party's place is checked before `run start`: the lead bot must stand within 80 yd of the start step's point (the step's coordinates,
+ or the leg's first point), and a start whose point or whose lead position is not known is refused too, since the place cannot be checked;
+ `observe.allow_far_start` turns each of these refusals into a note. Adopting a run that is already going (`busy`) happens only when its status proves it
+ is at the requested step (or in the leg, for a leg sent by name); a leg sent as a number is never adopted.
4. **Watch.** From here the agent sends **no order**: any `.bot cmd` order but `state`, and any `.bot action`, pauses the run leader. It reads the
`TBM:RUN|||/||||` line the module pushes on every change (through the view feed), asks
`.bot run status` every `poll_s` (15 s) as a safety net, and ignores a line of another plan. Per change the report, the trace (`run_leader`
@@ -1622,21 +1682,38 @@ The server needs `AiPlayerbot.DungeonSelfClear = 1` and `AiPlayerbot.RemoteComma
| `paused_wait_s` | 10 min | a `paused` run is not resumed this long. The run leader pauses itself at 3 dead, an unsupported step (`ritual`) or a ceiling and never goes on alone |
| none | 20 s | no `TBM:RUN` line came after the start, or the module says "no run" |
| none | | the binding ended: the view feed's end-of-binding line fails the run as in commander mode |
+| `maintenance` | false | not a ceiling: sends `.bot maintenance` to every bot at the kit check (off: it walks them to vendors and can scatter the party) |
+| `allow_far_start` | false | not a ceiling: lets a partway start go on although the lead bot stands more than 80 yd from the start step's point, or the point or the lead's position is not known (a note says so; off: the run is refused before `run start`) |
+| `keep_loot_setting` | false | not a ceiling: leaves the bots' corpse looting as the operator set it (the start sends no `.bot behavior loot on`, the undo of a boss `loot off` a crashed run left); a driven run always sends the undo |
**Controls.** `dungeon_stop` stops the run (`.bot run stop`, then the unbind). In an observed run `dungeon_pause`, `dungeon_resume` and `dungeon_skip` send the run leader's own
`.bot run pause|resume|skip` on the watch's next look; the answer is in `report.remote.observe.controls`. The `commander` method takes `{"op":"run","verb":"status"}`
-for a hand-sent run command.
+for a hand-sent run command. A pause the module makes for a player's order (`PauseForPlayer`: the observer's own `cmd summon`, a `move`, ...) is listed in
+`report.remote.observe.player_orders`, one entry per pause (later orders during the same pause are not listed separately), with the step and the order (`step 2/5 (first): cmd summon Healy`); `controls` also records
+`player order: ...` and `resumed after the player order at step N` when the run goes on again, also when a person resumed on the server (a run that stops instead of going on ends the pause with no resume entry). The pause itself is the module's: after a `cmd`
+order the run waits for `dungeon_resume` / `.bot run resume`.
**Report.** `report.remote.mode` is `observe`; `report.remote.observe` has `plan`, the newest `state`, `step`, `steps`, `leg`, `kind`, `phase`, `note` and a
-plain-words `text`, `started`, `lines`, `polls`, `pauses`, `controls`, `ended` and `changes[]` (the newest 300 status changes with their time). `report.legs[]` has one
-entry per leg the run leader entered, with its seconds. `report.current` is "run leader: sentinel · pull 7/41 · pulling". The orders counters stay at zero but for the end's
-`stay`. Not in observe mode: `tune`, `live_reload`, `cc`, `carrier`, `loot_restore`, `object_fallback`, `tank`, `healer`, `recover_wipe` and every start option
-(`from_leg` past 0, `from_pull_group`, `start_here`, `have_items`, `pulltest`, `give_items`) are refused, since the agent steers nothing.
+plain-words `text`, `started`, `lines`, `polls`, `pauses`, `controls`, `player_orders`, `ended` and `changes[]` (the newest 300 status changes with their time). `report.wipes`
+counts the watched party's wipes: one rising edge of "every bot dead" in the feed is one wipe, the latch re-arms only once fewer than
+3 members are dead (a single member revived in place for a poll, dead again the next, does not count a second wipe). A wipe also counts
+when every member has died within 90 s of each other, each death newer than the last counted wipe, even if the lead bot revived in
+between (no frame shows them all dead; the RFC run: five dead within 63 s): that `wipe` event has `"window": true`. Each counted wipe
+also goes to the trace as a `wipe` event with `"observe": true`, so the tracecheck deaths oracle sees it too (the run leader stops and
+recovers on its own; the watch only counts). In an observe run `report.leader_deaths`, `report.resurrections` and `report.seconds_dead`
+(in all and per leg) count the lead bot's deaths and revives from the feed, each death also a `leader_death` trace event; a revive here
+is any return to alive, the run leader's own revive included, not only an accepted resurrection. `report.legs[]` has one
+entry per leg the run leader entered, with its seconds. `report.current` is "run leader: sentinel · pull 7/41 · pulling". `report.instance` is the lead bot's numeric instance id, read from the feed
+(absent until a frame shows one); the trace has one `instance` event (`instance`, `map`) and the summary's `instance` is filled from it when
+the header has none. The orders counters stay at zero but for the end's
+`stay`. Not in observe mode: `tune`, `live_reload`, `cc`, `carrier`, `loot_restore`, `object_fallback`, `tank`, `healer`, `recover_wipe` and the start options that
+move or equip the party (`start_here`, `have_items`, `pulltest`, `give_items`) are refused, since the agent steers nothing; `from_leg`, `from_pull_group` and
+`from_step` only say at which step of the run leader's plan it starts.
**Dashboard.** An observed run's card (`turtle-agent dashboard`) shows the run leader's state in plain text, in the "Now" line and as `run.leader` in
the card JSON: `Run leader: Sentinel · pull 7/41 · pulling`, `Run leader paused: Sentinel · boss 12/41 · 3 dead: regroup ...`, `Run leader: finished all 41 steps`.
-`tools/run-uldaman.sh --mode observe [--hire] [--no-fresh]` builds the params of an observed Uldaman run.
+`tools/run-uldaman.sh --mode observe [--hire] [--no-fresh] [--keep-loot-setting]` builds the params of an observed Uldaman run.
## Remote leader
@@ -1679,7 +1756,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 (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 |
@@ -1855,7 +1932,9 @@ the first step in, and the agent stays **outside and ungrouped**. When a bot of
bot that is not on the plan's map, 6 s apart, **by name**: a bot the feed does not list (it left the group, or the feed dropped it) is
summoned too (the module answers `no-bot` for a bot outside the group, and the run fails saying so).
- **cmd:** `cmd move` legs to the portal on the outer map as for the walk, then up to 8 s for the other bots to stand within 30 yd of it, then
- `.bot cmd enter `. The module answers `TBM:ACTION_PENDING|cmd enter|party||` and one final line:
+ `.bot cmd enter `. With `cmd enter` the lead bot is at the door already when it stands within 30 yd of the portal on the
+ ground plane and no more than 12 yd above or below it: a door in a cave or on a ledge stands above or below the walkable ground, so the
+ 3D 5-yd arrive check of the plain walk would never pass. Without `cmd enter` the lead bot must reach the portal point itself. The module answers `TBM:ACTION_PENDING|cmd enter|party||` and one final line:
`TBM:ACTION_ACK|cmd enter|party||entered` (the party is inside; the run waits for the lead bot on the plan's map) or
`TBM:ACTION_ERR|cmd enter||: [(n of m got in)]`. The wait for the final line is the budget plus 8 s (the module ends the
order with its own `timeout` about 6 s after the budget), at most 130 s (its worst case is 60 + 30 + 30 s and 6 s). Retries are bounded: `busy` 3, `cancelled` 2, `too-far` 2 (a wait for the
@@ -1937,7 +2016,7 @@ part before the bind in one go, then the kit check and the enter phase run as be
(a bot that carries the item already gets none and nothing is booked), selects the bot by GUID (the agent's own group or view for a hired bot, the roster for a named one; the selection is confirmed by the server first and the line goes out as one session operation, like `.instance unbind all`),
sends **`.additem `** and reads `.bot inv` again until the item shows (at most 8 s, else the run fails saying the line may have run on another player or the bags are full). `lead` is the lead bot, `carrier` the run's `carrier` option, else the bot the object steps use (the first bot that is not the lead, tank or healer).
It **refuses without a GM account of level 3** (`.additem` is a developer's command; the account level is read with `.account`, and the run stops before it sends or binds anything). The line is a setup shortcut: `additem 9240 on (test setup: Mallet of Zul'Farrak)` in `setup_shortcuts`,
- so an honest-run check flags it. `give_items` is refused in group and observe mode. Before anything is hired, logged in or sent, the run checks that the agent's game can do what its options need (`give_items`: `.additem` and `.bot inv`; the prepare block; a hired party; an optional leg's bag read) and fails naming the missing ability, so a run that cannot give items does not pay for a hire first. The `.additem` line (like `.instance unbind all`) acts on the selected player and is sent as the chat packet alone: the unit is selected, the server's echo of the selection is awaited, and the line goes out only while that confirmed selection still stands (the core handles a selection and a GM chat line on different threads, so a selection sent right before the line would not be in force); if another request changed the selection meanwhile, nothing is sent and the unit is selected and confirmed again (three tries). It is how a test gets the Mallet for the optional gahzrilla leg below.
+ so an honest-run check flags it. `give_items` is refused in group and observe mode. Before anything is hired, logged in or sent, the run checks that the agent's game can do what its options need (`give_items`: `.additem` and `.bot inv`; the prepare block; a hired party; an optional leg's bag read) and fails naming the missing ability, so a run that cannot give items does not pay for a hire first. The `.additem` line (like `.instance unbind all`) acts on the selected player and is sent as the chat packet alone: the unit is selected, the server's echo of the selection is awaited (an echo that could answer an older select of the unit, with another unit's select sent after it, does not count, nor the target field rewritten by combat or a stun, which leaves the selection unknown; when a new echo could not be told from an old one, or would not come because the field already shows the unit, a neutral selection (none, or the agent itself) is made and confirmed first; reselecting the unit already confirmed needs no echo; `.aura` world buffs and `.respawn` go the same way), and the line goes out only while that confirmed selection still stands (the core handles a selection and a GM chat line on different threads, so a selection sent right before the line would not be in force); if another request changed the selection meanwhile, nothing is sent and the unit is selected and confirmed again (three tries). It is how a test gets the Mallet for the optional gahzrilla leg below.
Every GM line the step sends is a **setup shortcut**: it is in `setup_shortcuts` (for example `.tele name Lcwarra uldaman (prepare fresh)`,
`.instance unbind all on Lcwarra (prepare fresh)`, `gm visible off`) and is a `gm_shortcut` event of kind `setup` in the trace, so `trace-score`'s
@@ -2048,7 +2127,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
@@ -2069,6 +2149,16 @@ with the ordinary per-leg logic (the rest gate at the leg's start, walks, pulls,
to see the bot stay within 4 yd of it (three tries, then the run fails);
4. `cmd summon ` for every other living bot that is not with the lead bot, and a wait until all stand together.
+**The commander's z (issue #292).** `.go xyz` takes the z it is given and never snaps to the ground, so a point with a wrong z put the
+commander under the terrain (or in the air) and the party was brought to it. After step 2 the agent selects its own character, waits
+for the server to show the selection, and sends the plain GM line `.gps` (the reply's lines are read as `KindSystem` text) and reads `GroundZ` (the top surface) and `FloorZ` (the floor under the commander's z). A
+commander more than 0.5 yd from `FloorZ` gets one more `.go xyz X Y FloorZ+0.3` (booked as a GM start line) and `.gps` is read again. The
+start is refused, before the lead bot is touched, with the target z, `GroundZ`, `FloorZ` and the commander's z when it still stands more
+than 0.5 yd off the floor, or when `GroundZ` and `FloorZ` differ and `FloorZ` is more than 3 yd from the target z (the floor under the
+commander is another layer than the point). The check is best effort: a failed or unanswered `.gps`, a reply without its X/Y, `GroundZ` or
+`FloorZ`, a reply about another unit (its X/Y more than 20 yd from the commander) or one with no height data (-200000) leaves the start as
+it was, with one note per run saying the z check could not be made.
+
The two GM lines are the only GM commands; neither is aimed at another player. They count in `remote.gm_commands` and are listed in
`setup_shortcuts` as `... (GM start point)` and in `start.gm_commands`. A party that is at the start point already (the lead bot within 20 yd,
the others within 25 yd of it) sends nothing (`start.method` `already there`); a lead bot that stands within 60 yd of one of the leg's own points (its `to`, vias, step stands, pull group positions, boss), or, with `resume`, within 80 yd (`ResumeMaxOff`) of the leg's route from the previous leg's end through its `to`, its pack positions and its boss, with the party together also sends nothing (`already in the leg`: a resume into the Zum'rah room is not sent back to the courtyard; rfc-live-2's resume into leg 4 was sent back to the leg's first point and the party summoned again, 127 s). The account must be a GM (`.go xyz` is observer level): with a
@@ -3492,7 +3582,8 @@ most sections; `GET /api/heat/` the death places of the latest 20 runs. Bo
at most 600 files, 6 folders deep, listed at most every 90 s, parsed once per change and cached (28 digests). What the data lacks is left out and the legend
says so: the feed has **no creature level** (rings are 20 yd), **no name of who put a crowd control on** (the cyan line needs a `cmd cc` order of the commander),
**no heal spell target** (a healer that targets a friend is taken to heal it), and patrols are only drawn when the plan has them. Runs that started at a later
-section (`from_leg`) are not counted as a clear of the whole dungeon. Botwatch files are not read: they carry no plan name.
+section (`from_leg`) are not counted as a clear of the whole dungeon, nor are runs whose run leader reported packs left alive (`TBM:RUN ... |left `):
+the ghost names the count ("furthest earlier run (4 sections), 2 packs left") and a run's card shows it ("Run leader: finished all 41 steps, 2 packs left"). Botwatch files are not read: they carry no plan name.
**Limits.** The map shows what `bot_view` reports: the module's perception of the party, so a creature none of the bots sees
is not there. A run on a plan the dashboard cannot find or load shows the reason in place of the map. A card of an agent that
@@ -3509,6 +3600,7 @@ disk: `turtle-agent minimap-extract` reads the client's MPQ archives (read only;
turtle-agent minimap-extract --client "/path/to/WoW/Data" --out ~/turtle-agent-minimaps --maps all-instances
turtle-agent minimap-extract --client .../Data --out ~/turtle-agent-minimaps --maps 70,209 --mmaps ~/turtle-agent-mmaps
turtle-agent minimap-extract --client .../Data --list # what each dungeon and raid would get, nothing written
+turtle-agent minimap-extract --client .../Data --out ... --verbose # log every map and every skipped tile
turtle-agent dashboard --minimap-dir ~/turtle-agent-minimaps
```
@@ -3522,7 +3614,7 @@ can have up to three images, written as `..png` + `.json`; the first
| Source | What it is | Where it comes from |
| --- | --- | --- |
| `worldmap` | the map picture the client shows for the dungeon (the 4 x 3 tiles of `Interface\WorldMap\ `, cut to 1002 x 668); a dungeon with several floors gives `.worldmap-2f.png`, ... too | `WorldMapArea.dbc` rows of the map: the picture's edges in world coordinates |
-| `minimap` | the terrain minimap tiles (256 px per 533.33 yd ADT tile) stitched into one image | `Textures\Minimap\md5translate.trs` and `Map.dbc`; a tile `map_.blp` is column A (from world y), row B (from world x). Indoor dungeons (WMOs) have none, or only the terrain around them |
+| `minimap` | the map's minimap picture: the terrain tiles, the client's own indoor WMO minimap tiles, or the WMO tiles drawn over the terrain | `Textures\Minimap\md5translate.trs`, `Map.dbc` and the map's WDT/ADTs: a terrain tile `map_.blp` is column A (from world y), row B (from world x); a WMO tile ` ___.blp` is placed by the WMO's MODF position and yaw and the group boxes of its MOGI (2 px per yard, bottom-left anchored tiles) |
| `navmesh` | a height-shaded top-down render of the server's navmesh (transparent background, 2 px per yard) | `--mmaps`: the `.mmtile` files of the map, parsed in Go (no cgo) |
The JSON beside each image: `map`, `name`, `source`, `width`, `height` (pixels), `px_per_yard`, and the world coordinates of
@@ -3532,8 +3624,58 @@ The image is north up and west left, as the in-game map is: pixel column `c` is
`north - south` yards tall in the plane its plan drawing uses (`x -> -y`, `y -> -x`). The test pins this against the Uldaman
plan: the entrance, Ironaya and Archaedas land on their rooms of the client's Uldaman map.
-The output table lists, for every map, its type, the main image, its size and the other images. Archives it cannot read (a
-compression method used for audio only) are an error; a map no source has an image for is listed as `none`.
+A composed minimap's JSON also says `from`: `wmo` (only the client's indoor WMO tiles: a WMO-only dungeon, or one whose
+terrain tiles are blank) or `terrain+wmo` (the WMO tiles drawn over the terrain). A map with a usable terrain minimap
+keeps the old terrain-only picture byte for byte (no `from`), except the five maps whose indoor part the plan puts over
+the terrain: Deadmines 36, Shadowfang Keep 33, Scholomance 289, Stratholme 329 and Karazhan Crypt 800. The WMO tiles are
+the art the client itself draws indoors. Every placed WMO group that has tiles is drawn, groups whose bounding box
+bottom is higher (`PZ + z0`) over lower ones; outdoor props such as farms and ziggurats are included (they are the
+client's own art), and a group with no tiles is skipped. A placement whose box lies entirely outside the map navmesh
+box grown by 200 yd is not drawn (when `--mmaps` gives a navmesh), and two known placements of another zone are listed
+below. The placement transform is settled and pinned by tests: 2 px per yard, a tile index step of 128 yd, bottom-left
+anchored tiles, `U = 2(x-x0)`, `Vup = 2(y-y0)` with B counting up from the group box minimum, a 180-degree turn,
+`th = +rot.y`, `P = (17066.666 - pos[2], 17066.666 - pos[0], pos[1])` for an ADT placement and `P = 0` for a WDT one.
+A composed picture is capped at 8192 px a side (the scale is lowered, the tiles are still sampled at 2 px per yard),
+at 16384 group drawables, at 4096 tile images (256 MiB of decoded pixels) and at 33.5 M sampled pixels per map; a map
+over a cap gets a reason instead of a picture. A tile the archives lack or that fails to decode is skipped, and a WMO
+or a group with no usable tile never ends the run.
+
+A picture whose art has a known client-data gap carries a one-line `note` in its JSON (readers ignore the key):
+
+| map | note |
+| --- | --- |
+| 189 Scarlet Monastery | Herod's room has navmesh but no client minimap art; the plan point there is off the picture |
+| 819 Timbermaw Hold | part of the WMO has no client minimap art (navmesh areas are drawn red); the entrance is off the picture |
+| 822 Frostmane Hollow | the two Silithus mini-hives (uids 303286 and 303232) belong to another zone and are not drawn |
+
+When a source gives no picture, the extractor writes `.status.json` beside the images with one line per missing
+source: `{"minimap":{"reason":"..."},"navmesh":{"reason":"..."},"worldmap":{"reason":"..."}}` (only the missing ones,
+each reason capped at 160 characters). The dashboard shows it on the disabled slot and otherwise uses its own default
+text. A missing indoor minimap names the case: (A) a WMO-only map, (B) blank terrain tiles, (C) no terrain tiles, then
+why the WMO picture is missing (no WMO placement, no WMO tile lines for it, none of its tiles decoded, or a blank
+composed picture). A map whose pictures all exist gets no status file, and a stale one is removed.
+
+The output table lists, for every map, its type, the main image, its size and the other images; a minimap shows its
+makeup as `minimap(terrain)`, `minimap(wmo)` or `minimap(terrain+wmo)`. Archives it cannot read (a compression method
+used for audio only) are an error; a map no source has an image for is listed as `none`.
+
+**The three picture slots.** `GET /api/minimap/` always answers the same three slots in Zul'Farrak's order:
+`worldmap` (the main picture, then one entry per floor: `World map 2f`, ...), `minimap` and `navmesh`. Each entry has
+`kind`, `floor` (`""` or `2f`), `label`, `available`, and for a missing one a one-line `reason` instead of the picture's
+`url`, `x`, `y`, `w`, `h`. Every entry also keeps the older `name` and `source` fields: `name` is now the same as `label`
+(`World map`), and `source` is the slot's kind. The folder's own `.[-f].json` is the slot of that source;
+the main `.json` stands in only for a source the folder has no file of (an old folder), so a picture is never listed
+twice. A map the folder has nothing for still answers 200 with three missing slots; 404 means no `--minimap-dir` at all.
+The page's picker shows the three, disables a missing one as ` (none)` with its reason on hover, and draws the first
+available picture, so a saved choice of a kind the folder lost falls back instead of leaving a blank map.
+
+A slot's own reason comes from an optional `.status.json` beside the pictures, which `minimap-extract` writes and an
+operator may edit: `{"minimap": {"reason": "..."}, "navmesh": {"reason": "..."}, "worldmap": {"reason": "..."}}`. It is read
+only if it is at most 64 KiB and a file of the folder itself (no symbolic link out, no other name); a malformed or oversized
+file is ignored, unknown kinds are ignored, control characters (a line break, a tab) become spaces, and each reason is cut to
+160 characters. Without it the dashboard uses a
+built-in line per kind (for the minimap: an indoor dungeon whose in-game minimap the client draws from WMO tiles;
+`minimap-extract` composes it from those tiles, and a map it could not compose gets its reason here).
## Navmesh walking
@@ -3801,6 +3943,6 @@ Design and implementation plan: `docs/superpowers/`.
## Licence
-MIT, see [LICENSE](LICENSE). Parts are ported from [agent-wow](https://github.com/agent-wow/agent-wow) (MIT), and
+GNU Affero General Public License v3.0 or later, see [LICENSE](LICENSE). Versions up to the commit that changed the licence were released under the MIT License (kept in [LICENSES/MIT-turtle-agent-before-agpl.txt](LICENSES/MIT-turtle-agent-before-agpl.txt)); copies you already received under MIT stay under MIT. Parts are ported from [agent-wow](https://github.com/agent-wow/agent-wow) (MIT; the files keep their own MIT headers), and
`internal/navmesh/detour` is Detour from recastnavigation (zlib, `internal/navmesh/detour/License.txt`); see
-[NOTICE](NOTICE). World of Warcraft is a trademark of Blizzard Entertainment; this project is not affiliated with it.
+[NOTICE](NOTICE). Dungeon event data derived from [jrad7/mod-dungeon-clear](https://github.com/jrad7/mod-dungeon-clear) (AGPL-3.0-or-later) is marked with its source where it appears. World of Warcraft is a trademark of Blizzard Entertainment; this project is not affiliated with it.
diff --git a/cmd/turtle-agent/commander_gps.go b/cmd/turtle-agent/commander_gps.go
new file mode 100644
index 00000000..91b9940a
--- /dev/null
+++ b/cmd/turtle-agent/commander_gps.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/pjerra/turtle-agent/internal/events"
+)
+
+// The `.gps` read of the start's z check (dungeon.StartHands.GPS). `.gps` is a plain GM line (no ".bot " in front) and the core answers it
+// with several system messages; the one with GroundZ and FloorZ ends the read (a TileX line may follow it).
+const (
+ gpsTotalWait = 3 * time.Second // the whole read
+ gpsQuietWait = 400 * time.Millisecond // the read ends this long after the last line once a line has come
+)
+
+// GPS selects the agent's own character, waits until the server shows it selected, and sends ".gps": the core reports the unit the GM has
+// selected, so a mob or a bot left selected would be read in the commander's place. It returns the system lines that followed, joined by
+// newlines. An error is a selection or a line that could not be sent, or no reply within gpsTotalWait.
+func (g remoteGame) GPS(ctx context.Context) (string, error) {
+ log := g.c.cur().Log()
+ since := log.Last()
+ if err := g.selectThenGM(ctx, g.AgentGUID(), ".gps"); err != nil {
+ return "", err
+ }
+ return readGPSReply(ctx, log, since, gpsTotalWait, gpsQuietWait)
+}
+
+// readGPSReply collects the system lines the log gets after since: it returns at the line that has "FloorZ" (the one that ends the reply),
+// else quiet after the last line once one has come, else at total with an error when there was none.
+func readGPSReply(ctx context.Context, log *events.Log, since uint64, total, quiet time.Duration) (string, error) {
+ tctx, cancel := context.WithTimeout(ctx, total)
+ defer cancel()
+ var lines []string
+ cursor := since
+ for {
+ p := log.Read(events.Query{Since: cursor, Kinds: []string{events.KindSystem}, Limit: events.MaxLimit})
+ for _, e := range p.Events {
+ if e.Text == "" {
+ continue
+ }
+ lines = append(lines, e.Text)
+ if strings.Contains(e.Text, "FloorZ") {
+ return strings.Join(lines, "\n"), nil
+ }
+ }
+ cursor = p.Next
+ if p.More {
+ continue
+ }
+ wctx, cancel := tctx, context.CancelFunc(func() {})
+ if len(lines) > 0 {
+ wctx, cancel = context.WithTimeout(tctx, quiet)
+ }
+ err := log.Wait(wctx, cursor)
+ cancel()
+ if err != nil {
+ if err := ctx.Err(); err != nil {
+ return "", err
+ }
+ if len(lines) == 0 {
+ return "", context.DeadlineExceeded
+ }
+ return strings.Join(lines, "\n"), nil
+ }
+ }
+}
diff --git a/cmd/turtle-agent/commander_gps_test.go b/cmd/turtle-agent/commander_gps_test.go
new file mode 100644
index 00000000..42be8bce
--- /dev/null
+++ b/cmd/turtle-agent/commander_gps_test.go
@@ -0,0 +1,109 @@
+package main
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pjerra/turtle-agent/internal/events"
+ "github.com/pjerra/turtle-agent/internal/opcode"
+ "github.com/pjerra/turtle-agent/internal/packet"
+ "github.com/pjerra/turtle-agent/internal/session"
+)
+
+func gpsSys(l *events.Log, text string) { l.Append(events.Event{Kind: events.KindSystem, Text: text}) }
+
+// The reply is the system lines after the send, up to the one with FloorZ; earlier lines and other kinds are not part of it.
+func TestReadGPSReplyCollectsTheLinesToFloorZ(t *testing.T) {
+ log := events.New(100)
+ gpsSys(log, "old line")
+ since := log.Last()
+ go func() {
+ time.Sleep(20 * time.Millisecond)
+ gpsSys(log, "Map: 70 (Uldaman)")
+ log.Append(events.Event{Kind: events.KindChat, Text: "somebody says hi"})
+ gpsSys(log, "X: 1 Y: 2 Z: 3")
+ gpsSys(log, "GroundZ: 3.000000 FloorZ: 3.000000 Have height data (Map: 1 VMap: 1)")
+ }()
+ got, err := readGPSReply(context.Background(), log, since, 2*time.Second, 400*time.Millisecond)
+ if err != nil || got != "Map: 70 (Uldaman)\nX: 1 Y: 2 Z: 3\nGroundZ: 3.000000 FloorZ: 3.000000 Have height data (Map: 1 VMap: 1)" {
+ t.Fatalf("%q %v", got, err)
+ }
+}
+
+// A reply without a FloorZ line ends on a quiet gap after its last line; no reply at all is an error after the total wait.
+func TestReadGPSReplyQuietAndTimeout(t *testing.T) {
+ log := events.New(100)
+ since := log.Last()
+ gpsSys(log, "There is no such command")
+ start := time.Now()
+ got, err := readGPSReply(context.Background(), log, since, 2*time.Second, 50*time.Millisecond)
+ if err != nil || !strings.Contains(got, "no such command") || time.Since(start) > time.Second {
+ t.Fatalf("%q %v after %s", got, err, time.Since(start))
+ }
+ if _, err := readGPSReply(context.Background(), log, log.Last(), 80*time.Millisecond, 50*time.Millisecond); err == nil {
+ t.Fatal("no reply must be an error")
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := readGPSReply(ctx, log, log.Last(), time.Second, 50*time.Millisecond); err == nil {
+ t.Fatal("a cancelled context must be an error")
+ }
+}
+
+// `.gps` reports the unit the commander has selected, so the read selects the agent's own character (guid 0x2A) first and sends the line
+// only once the server shows that selection. A stranger left selected (a mob, a bot) must not be read in its place.
+func TestGPSSelectsTheCommanderBeforeTheLine(t *testing.T) {
+ const stranger = packet.GUID(0x4D2)
+ r := newRig(t)
+ r.srv.Send(opcode.SMSGUpdateObject, selfBlock(stranger)) // the server shows a stranger selected
+ g := remoteGame{c: &commanderService{cur: func() *session.Session { return r.s }}}
+ type reply struct {
+ text string
+ err error
+ }
+ done := make(chan reply, 1)
+ go func() {
+ text, err := g.GPS(context.Background())
+ done <- reply{text, err}
+ }()
+ // The server applies each selection and echoes it. A neutral selection of nothing may come first (when GPS starts before the session
+ // read the create block, the field is unknown: session.SelectConfirmed); anything else, or a line on anything but the commander, fails.
+ var ops []uint16
+ inForce := stranger
+ for len(ops) == 0 || ops[len(ops)-1] != opcode.CMSGMessagechat {
+ p, err := r.srv.Next(2 * time.Second)
+ if err != nil {
+ t.Fatalf("after %v: %v", ops, err)
+ }
+ switch p.Op {
+ case opcode.CMSGSetSelection:
+ guid := packet.NewReader(p.Body).GUID()
+ if guid != 0x2A && (guid != 0 || len(ops) != 0) {
+ t.Fatalf("selected %#x, not the commander (0x2A) or a first neutral selection of nothing", uint64(guid))
+ }
+ inForce = guid
+ ops = append(ops, p.Op)
+ r.srv.Send(opcode.SMSGUpdateObject, selfValues(guid)) // the server's echo of the selection
+ case opcode.CMSGMessagechat:
+ if inForce != 0x2A {
+ t.Fatalf("the .gps line was sent with %#x selected, not the commander", uint64(inForce))
+ }
+ if !strings.Contains(string(p.Body), ".gps") {
+ t.Fatalf("chat line %q", p.Body)
+ }
+ ops = append(ops, p.Op)
+ r.s.Log().Append(events.Event{Kind: events.KindSystem, Text: "X: 1.000000 Y: 2.000000 Z: 3.000000 Orientation: 0.000000\n" +
+ "GroundZ: 3.000000 FloorZ: 3.000000 Have height data (Map: 1 VMap: 1)"})
+ }
+ }
+ select {
+ case got := <-done:
+ if got.err != nil || !strings.Contains(got.text, "FloorZ: 3.000000") {
+ t.Fatalf("%q %v", got.text, got.err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("GPS did not return")
+ }
+}
diff --git a/cmd/turtle-agent/commander_methods.go b/cmd/turtle-agent/commander_methods.go
index 41429b80..bb2a54de 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)
@@ -376,33 +379,15 @@ func (g remoteGame) GMVisible(ctx context.Context, on bool) error {
// gmSelectConfirmWait is how long the server is given to show a selection before a line that acts on it is given up on.
var gmSelectConfirmWait = 3 * time.Second
-// selectConfirmed selects the unit and waits until the SERVER shows it as the agent's selection (the target field of the agent's own object,
-// which the core sends back after it handled CMSG_SET_SELECTION). The core queues that packet (map thread) apart from a GM's chat line (world
-// thread), so a line sent right after can run on the selection from before: none (the sender itself) or a stale one, which can be another
-// player. Run 6b: `.instance groupunbind all` answered "Glacienne is not in a group" for a selected Lcwarra. An error, with nothing sent, when
-// the server does not show the selection in time.
+// selectConfirmed selects the unit (0: none) and waits until the SERVER is known to have it in force as the agent's selection
+// (session.SelectConfirmed: an echo of the target field of the agent's own object that answers this select and not an older one, nor a
+// combat or stun rewrite of the field). The core queues that packet (map thread) apart from a GM's chat line (world thread), so a line sent
+// right after can run on the selection from before: none (the sender itself) or a stale one, which can be another player. Run 6b:
+// `.instance groupunbind all` answered "Glacienne is not in a group" for a selected Lcwarra; issue #296: a stale echo of the lead, with a
+// stranger's select pending behind it, let `.additem` run on the stranger. An error, with nothing sent, when the server does not show the
+// selection in time.
func (g remoteGame) selectConfirmed(ctx context.Context, guid packet.GUID) error {
- s := g.c.cur()
- if _, err := s.Target(ctx, guid, ""); err != nil {
- return err
- }
- timer := time.NewTimer(gmSelectConfirmWait)
- defer timer.Stop()
- tick := time.NewTicker(25 * time.Millisecond)
- defer tick.Stop()
- for {
- sel, ok := s.ServerSelection()
- if ok && sel == guid {
- return nil
- }
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-timer.C:
- return fmt.Errorf("the server did not show %v as the agent's selection within %s (it shows %v): not sending a line that acts on the selection", guid, gmSelectConfirmWait, sel)
- case <-tick.C:
- }
- }
+ return g.c.cur().SelectConfirmed(ctx, guid, gmSelectConfirmWait)
}
// gmSelectAttempts is how many times selectThenGM selects and confirms again when the selection changed after the confirmation.
@@ -567,28 +552,17 @@ func (g remoteGame) GoXYZ(ctx context.Context, mapID uint32, at dungeon.Point) e
// around the commander. The selection is cleared first and the server is waited for to show it cleared, else the line would act on that
// one unit alone (or fail on a player).
func (g remoteGame) Respawn(ctx context.Context) error {
- s := g.c.cur()
- if err := s.ClearTarget(ctx); err != nil {
- return err
- }
- timer := time.NewTimer(gmSelectConfirmWait)
- defer timer.Stop()
- tick := time.NewTicker(25 * time.Millisecond)
- defer tick.Stop()
- for {
- if sel, ok := s.ServerSelection(); ok && sel == 0 {
- break
- }
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-timer.C:
- sel, _ := s.ServerSelection()
- return fmt.Errorf("the server did not show the selection cleared within %s (it shows %v): not sending .respawn", gmSelectConfirmWait, sel)
- case <-tick.C:
- }
+ return respawnUnselected(ctx, g.c.cur())
+}
+
+// respawnUnselected clears the selection, waits until the server is known to have it cleared (a stale echo of an earlier clear, or the
+// field cleared by the end of a fight, does not count), and sends ".respawn" only while that stands (selectThenGM with no unit).
+func respawnUnselected(ctx context.Context, s *session.Session) error {
+ g := remoteGame{c: &commanderService{cur: func() *session.Session { return s }}}
+ if err := g.selectThenGM(ctx, 0, ".respawn"); err != nil {
+ return fmt.Errorf("respawn with nothing selected: %w", err)
}
- return s.GM(ctx, ".respawn")
+ return nil
}
// WalkSelf is walk_path for the agent's character.
diff --git a/cmd/turtle-agent/dungeon_leader_pulltest.go b/cmd/turtle-agent/dungeon_leader_pulltest.go
index aafd8a57..80935b77 100644
--- a/cmd/turtle-agent/dungeon_leader_pulltest.go
+++ b/cmd/turtle-agent/dungeon_leader_pulltest.go
@@ -2,8 +2,6 @@ package main
import (
"context"
- "fmt"
- "time"
"github.com/pjerra/turtle-agent/internal/brain"
"github.com/pjerra/turtle-agent/internal/dungeon"
@@ -16,28 +14,7 @@ import (
// Respawn sends the character's own ".respawn" with nothing selected (dungeon.Respawner), as the commander's does: the selection is cleared
// first and the server is waited for to show it cleared, else the line would act on that one unit alone.
func (g *sessionGame) Respawn(ctx context.Context) error {
- s := g.cur()
- if err := s.ClearTarget(ctx); err != nil {
- return err
- }
- timer := time.NewTimer(gmSelectConfirmWait)
- defer timer.Stop()
- tick := time.NewTicker(25 * time.Millisecond)
- defer tick.Stop()
- for {
- if sel, ok := s.ServerSelection(); ok && sel == 0 {
- break
- }
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-timer.C:
- sel, _ := s.ServerSelection()
- return fmt.Errorf("the server did not show the selection cleared within %s (it shows %v): not sending .respawn", gmSelectConfirmWait, sel)
- case <-tick.C:
- }
- }
- return s.GM(ctx, ".respawn")
+ return respawnUnselected(ctx, g.cur())
}
// CombatMark is the position of the event log now (dungeon.CombatMeter).
diff --git a/cmd/turtle-agent/dungeon_methods.go b/cmd/turtle-agent/dungeon_methods.go
index 42851000..1bfa1aca 100644
--- a/cmd/turtle-agent/dungeon_methods.go
+++ b/cmd/turtle-agent/dungeon_methods.go
@@ -124,6 +124,10 @@ type dungeonRunParams struct {
// (AUTO-IMPROVE step 0), so its waypoints and pull order are unchecked. Without it such a plan is refused.
AllowDraft bool `json:"allow_draft,omitempty"`
FromLeg *legParam `json:"from_leg,omitempty"`
+ // FromStep is an observed run's exact start step in the run leader's own plan: the step number `.bot run status` shows, from 1
+ // (README "Observe mode"). It is the fallback for a from_leg / from_pull_group whose step cannot be mapped offline (the deployed rows
+ // were exported with other options); observe only, and it excludes from_leg and from_pull_group.
+ FromStep *int `json:"from_step,omitempty"`
// FromPullGroup starts the run inside from_leg, at that pull group (remote mode; README "Start anywhere"): the commander's own GM move
// brings the party to the group's gather spot. StartHere keeps the old from_leg (no move: the run starts where the lead bot stands).
// Pulltest runs one pull group as a scored scenario instead of the plan (README "Pull test").
@@ -278,6 +282,16 @@ type observeParam struct {
MaxRunS float64 `json:"max_run_s,omitempty"`
StallS float64 `json:"stall_s,omitempty"`
PausedWaitS float64 `json:"paused_wait_s,omitempty"`
+ // Maintenance sends `.bot maintenance ` to every bot at the preflight (and at the hire check); off by default. Maintenance
+ // walks a bot to a vendor for repairs and supplies: right after the autogear of a hire that scatters the party across the maps, and
+ // the run leader pauses with "the lead bot is not on the plan's map". The kit check's inventory and talent reads run either way.
+ Maintenance bool `json:"maintenance,omitempty"`
+ // AllowFarStart lets a partway start (from_leg, from_pull_group, from_step) go on although the lead bot stands more than 80 yd
+ // from the start step's point; the note says so. Off by default: a party in the wrong place is refused before `run start`.
+ AllowFarStart bool `json:"allow_far_start,omitempty"`
+ // KeepLootSetting leaves the bots' corpse looting as the operator set it: the start sends no `.bot behavior loot on` (the undo
+ // of a boss `loot off` a crashed run left in place). Off by default. Observe only; a driven run always sends the undo.
+ KeepLootSetting bool `json:"keep_loot_setting,omitempty"`
}
type dungeonRunResult struct {
diff --git a/cmd/turtle-agent/dungeon_modes_test.go b/cmd/turtle-agent/dungeon_modes_test.go
index 9f59c52b..3844cd47 100644
--- a/cmd/turtle-agent/dungeon_modes_test.go
+++ b/cmd/turtle-agent/dungeon_modes_test.go
@@ -63,7 +63,7 @@ func TestDungeonRunModesAreRefusedClearly(t *testing.T) {
r := dungeonRig(t)
for _, c := range []struct{ params, want string }{
{`{"name":"rig","mode":"observe"}`, "needs lead_bot"},
- {`{"name":"rig","mode":"observe","lead_bot":"Tanky","from_leg":1}`, "start inside the plan"},
+ {`{"name":"rig","mode":"observe","lead_bot":"Tanky","start_here":true}`, "move or equip the party"},
{`{"name":"rig","mode":"observe","lead_bot":"Tanky","tank":"Tanky"}`, "the agent sends no order"},
{`{"name":"rig","mode":"commander"}`, "needs lead_bot"},
{`{"name":"rig","mode":"leader","lead_bot":"Tanky"}`, `go with mode "remote"`},
@@ -235,3 +235,32 @@ func TestCheckModeForceStartIsCommanderOnly(t *testing.T) {
t.Errorf("commander: %v %v", remote, err)
}
}
+
+// from_step is an observed run's start in the run leader's own plan: observe only, and it names the start itself (no from_leg or
+// from_pull_group beside it). from_leg and from_pull_group are no longer refused in observe mode (#248).
+func TestCheckModeFromStep(t *testing.T) {
+ for _, c := range []struct {
+ p dungeonRunParams
+ want string
+ }{
+ {dungeonRunParams{Mode: "commander", LeadBot: "Tanky", FromStep: intp(1)}, "mode \"observe\""},
+ {dungeonRunParams{Mode: "observe", LeadBot: "Tanky", FromStep: intp(0)}, "from 1"},
+ {dungeonRunParams{Mode: "observe", LeadBot: "Tanky", FromStep: intp(207), FromLeg: &legParam{index: 10}}, "leave from_leg"},
+ {dungeonRunParams{Mode: "observe", LeadBot: "Tanky", FromStep: intp(5), FromPullGroup: intp(1)}, "leave from_leg"},
+ } {
+ if _, err := checkMode(c.p); err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Errorf("%+v: %v", c.p, err)
+ }
+ }
+ for _, p := range []dungeonRunParams{
+ {Mode: "observe", LeadBot: "Tanky", FromStep: intp(207)},
+ {Mode: "observe", LeadBot: "Tanky", FromLeg: &legParam{index: 10}},
+ {Mode: "observe", LeadBot: "Tanky", FromLeg: &legParam{index: 10}, FromPullGroup: intp(1)},
+ } {
+ if remote, err := checkMode(p); !remote || err != nil {
+ t.Errorf("%+v: remote %v, %v", p, remote, err)
+ }
+ }
+}
+
+func intp(n int) *int { return &n }
diff --git a/cmd/turtle-agent/dungeon_remote.go b/cmd/turtle-agent/dungeon_remote.go
index 351b100d..bb0cef20 100644
--- a/cmd/turtle-agent/dungeon_remote.go
+++ b/cmd/turtle-agent/dungeon_remote.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/pjerra/turtle-agent/internal/dungeon"
+ "github.com/pjerra/turtle-agent/internal/navmesh"
"github.com/pjerra/turtle-agent/internal/rpc"
"github.com/pjerra/turtle-agent/internal/tbm"
"github.com/pjerra/turtle-agent/internal/tracerun"
@@ -27,6 +28,16 @@ func checkMode(p dungeonRunParams) (remote bool, err error) {
if merr != nil {
return false, merr
}
+ if p.FromStep != nil {
+ switch {
+ case mode != modeObserve:
+ return false, bad(`from_step is an observed run's start step in the run leader's plan: it goes with mode "observe" (a driven run starts from from_leg)`)
+ case *p.FromStep < 1:
+ return false, bad("from_step %d: a step number from 1 (the number .bot run status shows)", *p.FromStep)
+ case p.FromLeg != nil || p.FromPullGroup != nil:
+ return false, bad("from_step names the start step itself: leave from_leg and from_pull_group out")
+ }
+ }
if mode == modeObserve {
if err := checkObserve(p, bad); err != nil {
return true, err
@@ -130,11 +141,12 @@ func checkMode(p dungeonRunParams) (remote bool, err error) {
}
// checkObserve refuses what has no meaning in observe mode: the run leader on the server does the walking, the pulling and the object
-// steps, and the agent sends it no order, so the options that steer those (or start inside the plan) are refused instead of ignored.
+// steps, and the agent sends it no order, so the options that steer those (or move or equip the party for a start) are refused instead
+// of ignored. from_leg, from_pull_group and from_step only say at which step of the run leader's own plan it starts (#248).
func checkObserve(p dungeonRunParams, bad func(string, ...any) error) error {
switch {
- case p.FromPullGroup != nil || p.StartHere || p.Pulltest != nil || len(p.HaveItems) > 0 || (p.FromLeg != nil && (p.FromLeg.name != "" || p.FromLeg.index != 0)):
- return bad(`from_leg, from_pull_group, start_here, have_items and pulltest start inside the plan; the run leader starts at the plan's first step (observe mode)`)
+ case p.StartHere || p.Pulltest != nil || len(p.HaveItems) > 0:
+ return bad(`start_here, have_items and pulltest move or equip the party; in observe mode the run leader does that, and from_leg, from_pull_group (or from_step) only say at which of its steps it starts`)
case len(p.GiveItems) > 0:
return bad(`give_items puts items in the bags of the driven run's bots; in observe mode the run leader on the server gives the agent no such step (and its plan leaves an optional item leg out)`)
case len(p.CC) > 0 || p.Carrier != "" || p.LootRestore != "" || p.ObjectFallback != "" || p.Tank != "" || p.Healer != "" || p.RecoverWipe != nil || p.GravesBatch != 0 || p.GravesRamp != nil || p.GravesOpenBy != "":
@@ -142,7 +154,7 @@ func checkObserve(p dungeonRunParams, bad func(string, ...any) error) error {
case p.Tune != "" || p.LiveReload != nil:
return bad(`tune and live_reload belong to the driven run (mode "commander")`)
case p.ForceStart:
- return bad(`force_start passes the unsafe-start gate, which is commander mode only (mode "commander"); observe mode starts at the plan's first step, so it would do nothing`)
+ return bad(`force_start passes the unsafe-start gate, which is commander mode only (mode "commander"); observe mode starts where the run leader's own plan says (from_leg, from_pull_group, from_step), so it would do nothing`)
}
if o := p.Observe; o != nil {
if o.Plan != "" && !planName.MatchString(o.Plan) {
@@ -165,7 +177,7 @@ func observeSpec(o *observeParam) *dungeon.ObserveSpec {
spec := &dungeon.ObserveSpec{}
if o != nil {
sec := func(s float64) time.Duration { return time.Duration(s * float64(time.Second)) }
- spec.Plan, spec.Poll, spec.MaxRun, spec.Stall, spec.PausedWait = o.Plan, sec(o.PollS), sec(o.MaxRunS), sec(o.StallS), sec(o.PausedWaitS)
+ spec.Plan, spec.Poll, spec.MaxRun, spec.Stall, spec.PausedWait, spec.Maintenance, spec.AllowFarStart, spec.KeepLootSetting = o.Plan, sec(o.PollS), sec(o.MaxRunS), sec(o.StallS), sec(o.PausedWaitS), o.Maintenance, o.AllowFarStart, o.KeepLootSetting
}
return spec
}
@@ -215,13 +227,23 @@ func (r *dungeonRunner) startRemote(p dungeonRunParams, tcfg tracerun.Config, pl
}
var observe *dungeon.ObserveSpec
mode, _, _ := runMode(p.Mode)
+ var startFrom dungeon.StartFromResolver
if mode == modeObserve {
observe = observeSpec(p.Observe)
+ var planner *navmesh.Planner
+ if r.remote != nil {
+ planner = r.remote.planner
+ }
+ step := 0
+ if p.FromStep != nil {
+ step = *p.FromStep
+ }
+ startFrom = observeStartFrom(plan, from, p.FromPullGroup, step, planner)
}
// recover_wipe is on by default in a commander run only: an observed run steers nothing (and refuses the option), so its trace
// options say off.
cfg.RecoverWipe = remoteRecoverWipe(p)
- opt := dungeon.RemoteOptions{RunMeta: resolveRunMeta(p.RunMeta, r.runMeta), ForceStart: p.ForceStart, Observe: observe, PlanPath: planPath, TunePath: tunePath, NoReload: p.LiveReload != nil && !*p.LiveReload, StartHere: p.StartHere, HaveItems: p.HaveItems, GiveItems: p.GiveItems, Lead: p.LeadBot, Bots: p.Bots, Tank: p.Tank, Healer: p.Healer, Carrier: p.Carrier, CCBot: p.CCBot, LootRestore: p.LootRestore, ObjectFallback: p.ObjectFallback, GravesBatch: p.GravesBatch, GravesRamp: p.GravesRamp, GravesOpenBy: strings.ToLower(p.GravesOpenBy), Team: p.Team, Preflight: p.Preflight, OnEnd: p.OnEnd, KeepOnFail: p.KeepOnFail, HireMemory: r.keptHires(), ClassMemory: maps.Clone(r.classes), Enter: p.Enter, IgnoreEntryLimit: p.IgnoreEntryLimit}
+ opt := dungeon.RemoteOptions{RunMeta: resolveRunMeta(p.RunMeta, r.runMeta), ForceStart: p.ForceStart, Observe: observe, StartFrom: startFrom, PlanPath: planPath, TunePath: tunePath, NoReload: p.LiveReload != nil && !*p.LiveReload, StartHere: p.StartHere, HaveItems: p.HaveItems, GiveItems: p.GiveItems, Lead: p.LeadBot, Bots: p.Bots, Tank: p.Tank, Healer: p.Healer, Carrier: p.Carrier, CCBot: p.CCBot, LootRestore: p.LootRestore, ObjectFallback: p.ObjectFallback, GravesBatch: p.GravesBatch, GravesRamp: p.GravesRamp, GravesOpenBy: strings.ToLower(p.GravesOpenBy), Team: p.Team, Preflight: p.Preflight, OnEnd: p.OnEnd, KeepOnFail: p.KeepOnFail, HireMemory: r.keptHires(), ClassMemory: maps.Clone(r.classes), Enter: p.Enter, IgnoreEntryLimit: p.IgnoreEntryLimit}
if p.Prepare != nil {
opt.Prepare = *p.Prepare
}
diff --git a/cmd/turtle-agent/main.go b/cmd/turtle-agent/main.go
index cb9d6532..4dca4bf9 100644
--- a/cmd/turtle-agent/main.go
+++ b/cmd/turtle-agent/main.go
@@ -90,6 +90,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return planExportCmd(args[1:], stdout, stderr)
case "plan-check":
return planCheckCmd(args[1:], stdout, stderr)
+ case "route-oracle":
+ return routeOracleCmd(args[1:], stdout, stderr)
case "route-edits":
return routeEditsCmd(ctx, args[1:], stdout, stderr)
case "version":
@@ -115,8 +117,9 @@ func usage(w io.Writer) {
turtle-agent rotation list | show [--level N] | check ...
turtle-agent dashboard [--listen 127.0.0.1:8790] [--agents auto|] [--tests-dir DIR] [--traces-dir DIR] [--plans-dir DIR[,DIR]] [--edit --mmaps-dir DIR]
turtle-agent minimap-extract --client DIR --out DIR [--maps 70,209|all-instances] [--mmaps DIR] [--list]
- turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--mmaps DIR] [--rev SHA] [--stamp YYYYMMDDHHMMSS] [--force] --out FILE|DIR (... | --all-live)
+ turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--carrier-chests] [--skip-legs NAME,...] [--mmaps DIR] [--rev SHA] [--stamp YYYYMMDDHHMMSS] [--force] --out FILE|DIR (... | --all-live)
turtle-agent plan-check [--dir dungeons] --mmaps DIR [--from-leg N [--from-pull-group N]] [--json] ...
+ turtle-agent route-oracle --plans DIR --mmaps DIR --out DIR [--plan NAME]...
turtle-agent route-edits list [--edit-dir DIR] [--json] [] | export [--edit-dir DIR] [--config-dir DIR] --to DIR [vN|latest] | promote [--edit-dir DIR] --worktree PATH vN
turtle-agent version`)
}
diff --git a/cmd/turtle-agent/minimap_cmd.go b/cmd/turtle-agent/minimap_cmd.go
index 624dca27..7c923c09 100644
--- a/cmd/turtle-agent/minimap_cmd.go
+++ b/cmd/turtle-agent/minimap_cmd.go
@@ -10,8 +10,9 @@ import (
"github.com/pjerra/turtle-agent/internal/minimap"
)
-const minimapUsage = "usage: turtle-agent minimap-extract --client --out [--maps 70,209|all-instances] [--mmaps DIR] [--list]\n" +
- " reads the game client's MPQ archives and writes /.png + .json for the dashboard (README \"Minimaps\")"
+const minimapUsage = "usage: turtle-agent minimap-extract --client --out [--maps 70,209|all-instances] [--mmaps DIR] [--list] [--verbose]\n" +
+ " reads the game client's MPQ archives and writes /.png + .json for the dashboard (README \"Minimaps\")\n" +
+ " the minimap picture is the terrain tiles, the client's indoor WMO tiles, or the WMO tiles over the terrain"
// minimapCmd extracts the in-game minimaps of maps from the client the owner owns.
func minimapCmd(args []string, stdout, stderr io.Writer) int {
@@ -21,8 +22,9 @@ func minimapCmd(args []string, stdout, stderr io.Writer) int {
out := fs.String("out", "", "folder to write .png and .json into")
maps := fs.String("maps", "all-instances", `map ids, comma separated, or "all-instances" (every dungeon and raid of Map.dbc)`)
mmaps := fs.String("mmaps", "", "server mmaps folder(s), comma separated: a map with no minimap tiles gets a height-shaded floor render of its navmesh")
- prefer := fs.String("prefer", "", "order of the image sources for .png, comma separated: minimap (the terrain tiles), worldmap (the map picture of the area), navmesh (default worldmap,minimap,navmesh)")
+ prefer := fs.String("prefer", "", "order of the image sources for .png, comma separated: minimap (the terrain and indoor WMO tiles), worldmap (the map picture of the area), navmesh (default worldmap,minimap,navmesh)")
list := fs.Bool("list", false, "only report what each map would get; write nothing")
+ verbose := fs.Bool("verbose", false, "log every map and every skipped tile to stderr")
if err := fs.Parse(args); err != nil {
return exitUsage
}
@@ -40,18 +42,26 @@ func minimapCmd(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "minimap-extract:", err)
return exitUsage
}
- res, err := minimap.Run(minimap.Options{ClientDir: expandHome(*client), OutDir: expandHome(*out), MmapsDirs: splitDirs(*mmaps),
- Maps: ids, All: all, Prefer: pref, ListOnly: *list})
+ opts := minimap.Options{ClientDir: expandHome(*client), OutDir: expandHome(*out), MmapsDirs: splitDirs(*mmaps),
+ Maps: ids, All: all, Prefer: pref, ListOnly: *list}
+ if *verbose {
+ opts.Log = func(format string, args ...any) { fmt.Fprintf(stderr, format+"\n", args...) }
+ }
+ res, err := minimap.Run(opts)
if err != nil {
fmt.Fprintln(stderr, "minimap-extract:", err)
return 1
}
- fmt.Fprintf(stdout, "%-4s %-34s %-8s %-10s %-10s %s\n", "map", "name", "type", "image", "size", "other images")
+ fmt.Fprintf(stdout, "%-4s %-34s %-8s %-22s %-10s %s\n", "map", "name", "type", "image", "size", "other images")
failed := 0
for _, r := range res {
size := "-"
+ image := "-"
if r.Kind != minimap.KindNone {
size = fmt.Sprintf("%dx%d", r.Meta.Width, r.Meta.Height)
+ if len(r.Layers) > 0 {
+ image = r.Layers[0].Label()
+ }
}
note := r.Note
if r.Error != nil {
@@ -61,13 +71,13 @@ func minimapCmd(args []string, stdout, stderr io.Writer) int {
var others []string
for _, l := range r.Layers {
if l.Kind != r.Kind || l.Floor != "" {
- others = append(others, l.Kind+l.Floor)
+ others = append(others, l.Label())
}
}
if len(others) > 0 {
note = "also " + strings.Join(others, ",") + " " + note
}
- fmt.Fprintf(stdout, "%-4d %-34s %-8s %-10s %-10s %s\n", r.ID, r.Name, typeName(r.Type), r.Kind, size, note)
+ fmt.Fprintf(stdout, "%-4d %-34s %-8s %-22s %-10s %s\n", r.ID, r.Name, typeName(r.Type), image, size, note)
}
if *out != "" && !*list {
fmt.Fprintf(stdout, "wrote to %s\n", filepath.Clean(expandHome(*out)))
diff --git a/cmd/turtle-agent/observe_start.go b/cmd/turtle-agent/observe_start.go
new file mode 100644
index 00000000..34682462
--- /dev/null
+++ b/cmd/turtle-agent/observe_start.go
@@ -0,0 +1,250 @@
+package main
+
+import (
+ "fmt"
+ "math"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
+ "github.com/pjerra/turtle-agent/internal/navmesh"
+ "github.com/pjerra/turtle-agent/internal/planexport"
+ "github.com/pjerra/turtle-agent/internal/tbm"
+)
+
+// A partial start of an observed run (#248). The run leader counts the steps of its own SQL plan (`ai_dungeon_step` rows loaded from
+// the exported migration), not the agent's plan legs: the exporter writes a move for every via point and a leg's To, every pull group's
+// approach point, every boss approach, and — with the options the live migrations use — walk rows that cut long hops ("path"), the
+// approach to a pack out of the run leader's reach ("approach path") and avoid-box routes ("path (keeps out of an avoid box)"). So:
+//
+// - from_leg maps to the leg's name (`from leg `) when the name is one the command line takes and is unique in the plan, else to
+// its number in the rows (`from `): the number is not the plan's leg index when an optional leg was left out. The start message
+// names the leg the run leader really started in, which is checked against the plan file (drift between the file and the deployed
+// rows stops the run rather than watching the wrong plan).
+// - from_pull_group needs a step number (`from step `). The rows are rebuilt with the exporter's option sets (plain; cut hops;
+// cut hops and approach paths; with the plan's avoid boxes routed around) and the one whose step count equals the count the server
+// listed (`.bot run list`, before anything is hired) is used. When no option set (or more than one, with different answers)
+// matches, the deployed rows were exported differently and the resolver refuses: observe.from_step takes the number the run
+// leader's status line shows.
+// - from_step is passed through as given and checked against the server's step count when it listed one.
+//
+// The start step's point comes along (PartialStart.Point) for the driver's place check: the party must stand there, not walk the plan
+// from the entrance.
+
+// observeStartFrom is the RemoteOptions.StartFrom of an observed run; nil when the run starts at the first step.
+func observeStartFrom(plan *dungeon.Plan, leg int, group *int, step int, planner *navmesh.Planner) dungeon.StartFromResolver {
+ if group == nil && step == 0 && leg == 0 {
+ return nil
+ }
+ return func(serverSteps int) (dungeon.PartialStart, error) {
+ switch {
+ case step > 0:
+ if serverSteps > 0 && step > serverSteps {
+ return dungeon.PartialStart{}, fmt.Errorf("from_step %d: the run leader's plan %s has %d steps (from 1)", step, plan.Name, serverSteps)
+ }
+ ps := dungeon.PartialStart{Words: "from step " + strconv.Itoa(step), Step: step, Leg: -1}
+ ps.Point, ps.HavePoint, ps.ExpectLeg = observeStepInfo(plan, planner, serverSteps, step)
+ return ps, nil
+ case group != nil:
+ if leg < 0 || leg >= len(plan.Legs) {
+ return dungeon.PartialStart{}, fmt.Errorf("from_pull_group: the plan has legs %v (numbers 0-%d)", legNames(plan), len(plan.Legs)-1)
+ }
+ if n := len(plan.Legs[leg].PullGroups); *group < 0 || *group >= n {
+ return dungeon.PartialStart{}, fmt.Errorf("from_pull_group %d: leg %d (%s) has %d pull groups (numbers 0-%d)", *group, leg, plan.Legs[leg].Name, n, n-1)
+ }
+ if plan.Legs[leg].Optional != nil {
+ return dungeon.PartialStart{}, fmt.Errorf("from_pull_group %d: leg %d (%s) is optional (it needs item %d) and the exporter leaves optional legs out of the run leader's rows", *group, leg, plan.Legs[leg].Name, plan.Legs[leg].Optional.Item)
+ }
+ n, at, ok, err := observeGroupStep(plan, leg, *group, planner, serverSteps)
+ if err != nil {
+ return dungeon.PartialStart{}, err
+ }
+ rowLeg, _ := planexport.LegNumber(plan, leg)
+ return dungeon.PartialStart{Words: "from step " + strconv.Itoa(n), Step: n, Leg: leg, LegNumber: rowLeg, ExpectLeg: plan.Legs[leg].Name, Point: at, HavePoint: ok}, nil
+ default:
+ if leg < 0 || leg >= len(plan.Legs) {
+ return dungeon.PartialStart{}, fmt.Errorf("from_leg %d: the plan has legs %v (numbers 0-%d)", leg, legNames(plan), len(plan.Legs)-1)
+ }
+ rowLeg, ok := planexport.LegNumber(plan, leg)
+ if !ok {
+ return dungeon.PartialStart{}, fmt.Errorf("from_leg %d: leg %q is optional (it needs item %d) and the exporter leaves optional legs out of the run leader's rows; start at a later leg, or pass observe.from_step", leg, plan.Legs[leg].Name, plan.Legs[leg].Optional.Item)
+ }
+ ps := dungeon.PartialStart{Leg: leg, LegNumber: rowLeg}
+ if name, ok := legNameForCommand(plan, leg); ok {
+ ps.Words, ps.LegName = "from leg "+name, name
+ } else if rowLeg > 0 { // leg 0 is the first step: no `from` (the run leader's own beginning)
+ ps.Words = "from " + strconv.Itoa(rowLeg)
+ }
+ ps.Point, ps.HavePoint = legStartPoint(plan, leg)
+ return ps, nil
+ }
+ }
+}
+
+// legNameForCommand says whether a leg's name can be sent in `.bot run start ... from leg `: one word the commander command
+// grammar takes (an apostrophe or a space cannot), at most the 32 characters the exporter keeps, and unique case-insensitively (the
+// module matches names lower case). Else the leg's row number goes, which a busy run cannot be compared with (startMatchesStatus).
+func legNameForCommand(p *dungeon.Plan, leg int) (string, bool) {
+ name := p.Legs[leg].Name
+ if name == "" || len(name) > planexport.MaxLegName || len(strings.Fields(name)) != 1 || tbm.BadOrderWords(name) != "" {
+ return "", false
+ }
+ n := 0
+ for _, l := range p.Legs {
+ if strings.EqualFold(l.Name, name) {
+ n++
+ }
+ }
+ return name, n == 1
+}
+
+// legStartPoint is the plan's first point of a leg: its first via point, else its To (the exporter's first row of the leg).
+func legStartPoint(p *dungeon.Plan, leg int) (dungeon.Point, bool) {
+ if leg < 0 || leg >= len(p.Legs) {
+ return dungeon.Point{}, false
+ }
+ l := p.Legs[leg]
+ if len(l.Via) > 0 {
+ return l.Via[0].Point, true
+ }
+ return l.To, true
+}
+
+// observeGroupStep maps a plan leg's pull group to the run leader's 1-based step number and that step's point.
+func observeGroupStep(plan *dungeon.Plan, leg, group int, planner *navmesh.Planner, serverSteps int) (int, dungeon.Point, bool, error) {
+ if serverSteps <= 0 {
+ return 0, dungeon.Point{}, false, fmt.Errorf("from_pull_group %d (leg %d): the server did not list the plan's step count for %s (.bot run list), so the group's step cannot be mapped; pass observe.from_step (the step .bot run status shows)", group, leg, plan.Name)
+ }
+ cands, buildErrs := observePlanRows(plan, planner)
+ if len(cands) == 0 {
+ return 0, dungeon.Point{}, false, fmt.Errorf("from_pull_group %d (leg %d): the plan's rows cannot be rebuilt here (%s), so the group's step number is not known; pass observe.from_step (the step .bot run status shows)", group, leg, strings.Join(buildErrs, "; "))
+ }
+ var counts, steps []int
+ var at dungeon.Point
+ haveAt := false
+ for _, r := range cands {
+ counts = append(counts, r.StepCount())
+ if r.StepCount() != serverSteps {
+ continue
+ }
+ if n, ok := r.GroupStep(plan, leg, group); ok {
+ steps = append(steps, n)
+ if p, ok2 := r.StepPoint(n); ok2 {
+ if !haveAt {
+ at, haveAt = p, true
+ } else if pointDist(at, p) > 1 {
+ haveAt = false // the option sets put the same step number in different places: no point to compare the party with
+ }
+ }
+ }
+ }
+ if len(steps) == 0 {
+ return 0, dungeon.Point{}, false, fmt.Errorf("from_pull_group %d (leg %d): the server's plan %s has %d steps, and the plan file's exported rows give %s steps: the rows on the server were exported with other options (walk rows for cut hops, approach paths and avoid-box routes change the step numbers), so the group's step number cannot be computed offline. Pass observe.from_step (the step .bot run status shows) instead", group, leg, plan.Name, serverSteps, distinctSteps(counts))
+ }
+ slices.Sort(steps)
+ if steps[0] != steps[len(steps)-1] {
+ return 0, dungeon.Point{}, false, fmt.Errorf("from_pull_group %d (leg %d): the exporter's option sets that match the server's %d steps put the group at steps %v: too ambiguous to pick one; pass observe.from_step", group, leg, serverSteps, distinctSteps(steps))
+ }
+ return steps[0], at, haveAt, nil
+}
+
+// observeStepInfo is the point of the module's step step and the plan leg it belongs to, when an exporter option set reproduces the
+// server's step count (else neither; the start still goes). The leg name is the expectation for the run leader's start reply.
+func observeStepInfo(plan *dungeon.Plan, planner *navmesh.Planner, serverSteps, step int) (dungeon.Point, bool, string) {
+ if serverSteps <= 0 || step < 1 {
+ return dungeon.Point{}, false, ""
+ }
+ cands, _ := observePlanRows(plan, planner)
+ var at dungeon.Point
+ have, haveLeg := false, false
+ legName := ""
+ for _, r := range cands {
+ if r.StepCount() != serverSteps || step > len(r.Steps) {
+ continue
+ }
+ s := r.Steps[step-1]
+ p := dungeon.Point{X: s.X, Y: s.Y, Z: s.Z}
+ if !have {
+ at, have = p, true
+ } else if pointDist(at, p) > 1 {
+ at, have = dungeon.Point{}, false
+ }
+ name := ""
+ if pi, ok := planexport.PlanLeg(plan, s.Leg); ok {
+ name = plan.Legs[pi].Name
+ }
+ if !haveLeg {
+ legName, haveLeg = name, name != ""
+ } else if name != legName {
+ legName, haveLeg = "", false
+ }
+ }
+ if !haveLeg {
+ legName = ""
+ }
+ return at, have, legName
+}
+
+// observePlanRows rebuilds the plan's rows with the exporter option sets an applied migration may have used. The step count the server
+// lists picks the one that was deployed. Errors are per option set (a plan the exporter refuses one way may export another way).
+func observePlanRows(p *dungeon.Plan, planner *navmesh.Planner) ([]planexport.Rows, []string) {
+ base := planexport.Options{Source: "observe mode step mapping", GMCrossDirect: true, DropCompanionsLoot: true}
+ var out []planexport.Rows
+ var errs []string
+ add := func(o planexport.Options, what string) {
+ res, err := planexport.Build(p, o)
+ if err != nil {
+ errs = append(errs, what+": "+err.Error())
+ return
+ }
+ out = append(out, res.Rows)
+ }
+ add(base, "plain")
+ if planner == nil {
+ return out, errs
+ }
+ // The same rows the plan-export command writes with --mmaps (it cuts long hops), with and without --approach-hops; the avoid boxes
+ // of the plan are marked on the cutter and routed around, as the exporter does.
+ aware := planner.Scratch()
+ defer aware.Close()
+ boxes := dungeon.NavBoxes(p.Avoid)
+ if len(boxes) > 0 {
+ aware.SetAvoid(p.Map, boxes)
+ }
+ cut := cutWith(aware)
+ o := base
+ o.Cut = cut
+ add(o, "cut hops")
+ o.ApproachHops = true
+ add(o, "cut hops and approach paths")
+ if len(boxes) > 0 {
+ naive := planner.Scratch()
+ defer naive.Close()
+ route := routeAround(naive, aware, boxes)
+ ro := base
+ ro.Cut, ro.Route = cut, route
+ add(ro, "cut hops and avoid-box routes")
+ ro.ApproachHops = true
+ add(ro, "cut hops, approach paths and avoid-box routes")
+ }
+ return out, errs
+}
+
+// pointDist is the distance between two points of a map, in yards.
+func pointDist(a, b dungeon.Point) float64 {
+ dx, dy, dz := float64(a.X-b.X), float64(a.Y-b.Y), float64(a.Z-b.Z)
+ return math.Sqrt(dx*dx + dy*dy + dz*dz)
+}
+
+// distinctSteps lists the numbers once each, in the order they came, as a comma list.
+func distinctSteps(ns []int) string {
+ var out []string
+ for _, n := range ns {
+ s := strconv.Itoa(n)
+ if !slices.Contains(out, s) {
+ out = append(out, s)
+ }
+ }
+ return strings.Join(out, ", ")
+}
diff --git a/cmd/turtle-agent/observe_start_test.go b/cmd/turtle-agent/observe_start_test.go
new file mode 100644
index 00000000..29a1fb05
--- /dev/null
+++ b/cmd/turtle-agent/observe_start_test.go
@@ -0,0 +1,129 @@
+package main
+
+import (
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
+ "github.com/pjerra/turtle-agent/internal/planexport"
+)
+
+// startPlan is three legs, the middle one optional: the run leader's leg numbers skip it.
+const startPlan = `{"version":1,"name":"Start","status":"mapped (test)","map":70,"entrance":{"x":0,"y":0,"z":0},"legs":[
+ {"name":"first","to":{"x":10,"y":0,"z":0},"pull_groups":[
+ {"entries":[100],"positions":[{"x":20,"y":0,"z":0}],"approach":[{"x":15,"y":0,"z":0}]},
+ {"entries":[101],"positions":[{"x":25,"y":0,"z":0}]}]},
+ {"name":"gong","to":{"x":0,"y":0,"z":0},"optional":{"item":9240,"name":"Mallet"},"steps":[{"use_object":{"entry":900,"near":{"x":2,"y":0,"z":0},"requires_item":{"entry":9240}}}],"boss":{"entry":7,"position":{"x":5,"y":0,"z":0}}},
+ {"name":"third","to":{"x":30,"y":0,"z":0},"pull_groups":[{"entries":[102],"positions":[{"x":40,"y":0,"z":0}]}]}]}`
+
+func startTestPlan(t *testing.T) *dungeon.Plan {
+ t.Helper()
+ p, err := dungeon.Parse("start.json", []byte(startPlan))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func TestObserveStartFromStep(t *testing.T) {
+ p := startTestPlan(t)
+ r := observeStartFrom(p, 0, nil, 207, nil)
+ ps, err := r(226)
+ if err != nil || ps.Words != "from step 207" || ps.Step != 207 {
+ t.Fatalf("%+v %v", ps, err)
+ }
+ if _, err := r(206); err == nil || !strings.Contains(err.Error(), "206 steps") {
+ t.Fatalf("a step past the end: %v", err)
+ }
+ if _, err := r(0); err != nil { // an older list without a count does not bound it
+ t.Fatalf("no listed count: %v", err)
+ }
+ if observeStartFrom(p, 0, nil, 0, nil) != nil {
+ t.Fatal("a run from the first step has a resolver")
+ }
+}
+
+// from_leg starts at the leg's first step; the number is the run leader's (an optional leg takes none), and no step count is needed.
+func TestObserveStartFromLeg(t *testing.T) {
+ p := startTestPlan(t)
+ ps, err := observeStartFrom(p, 2, nil, 0, nil)(0)
+ if err != nil || ps.Words != "from leg third" || ps.LegName != "third" || ps.Leg != 2 || ps.LegNumber != 1 || !ps.HavePoint || ps.Point != (dungeon.Point{X: 30, Y: 0, Z: 0}) {
+ t.Fatalf("leg 2: %+v %v", ps, err)
+ }
+ if observeStartFrom(p, 0, nil, 0, nil) != nil {
+ t.Fatal("leg 0 is the first step: it has a resolver")
+ }
+ if _, err := observeStartFrom(p, 1, nil, 0, nil)(0); err == nil || !strings.Contains(err.Error(), "optional") {
+ t.Fatalf("the optional leg: %v", err)
+ }
+ if _, err := observeStartFrom(p, 9, nil, 0, nil)(0); err == nil || !strings.Contains(err.Error(), "numbers 0-2") {
+ t.Fatalf("a leg the plan does not have: %v", err)
+ }
+}
+
+// from_pull_group maps to the step number of the group's first row, but only when the rows the agent rebuilds have the step count the
+// server lists (the deployed rows may have been exported with other options); else it asks for from_step.
+func TestObserveStartFromPullGroup(t *testing.T) {
+ p := startTestPlan(t)
+ res, err := planexport.Build(p, planexport.Options{Source: "test", GMCrossDirect: true, DropCompanionsLoot: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ rows := res.Rows
+ groupOfLeg2, ok := rows.GroupStep(p, 2, 0)
+ if !ok {
+ t.Fatal("leg 2 group 0 has no step")
+ }
+ group := 0
+ ps, err := observeStartFrom(p, 2, &group, 0, nil)(rows.StepCount())
+ if err != nil || ps.Words != "from step "+strconv.Itoa(groupOfLeg2) || ps.Step != groupOfLeg2 || ps.ExpectLeg != "third" || !ps.HavePoint {
+ t.Fatalf("leg 2 group 0: %+v (want step %d, leg third) %v", ps, groupOfLeg2, err)
+ }
+ if _, err := observeStartFrom(p, 2, &group, 0, nil)(rows.StepCount() + 3); err == nil || !strings.Contains(err.Error(), "observe.from_step") {
+ t.Fatalf("a deployed plan of another row count: %v", err)
+ }
+ if _, err := observeStartFrom(p, 2, &group, 0, nil)(0); err == nil || !strings.Contains(err.Error(), "did not list the plan's step count") {
+ t.Fatalf("no listed count: %v", err)
+ }
+}
+
+// A leg name with an apostrophe cannot go on the command line: the number goes, and the start message's leg name is the only check then.
+func TestObserveStartLegNameFallsBackToTheNumber(t *testing.T) {
+ p := startTestPlan(t)
+ p.Legs[2].Name = "high_priestess_mar'li"
+ ps, err := observeStartFrom(p, 2, nil, 0, nil)(0)
+ if err != nil || ps.Words != "from 1" || ps.LegName != "" || ps.LegNumber != 1 {
+ t.Fatalf("%+v %v", ps, err)
+ }
+ // A duplicate name (case-insensitively) is not unique: the number goes too.
+ p = startTestPlan(t)
+ p.Legs[2].Name = "FIRST"
+ ps, err = observeStartFrom(p, 2, nil, 0, nil)(0)
+ if err != nil || ps.Words != "from 1" || ps.LegName != "" {
+ t.Fatalf("duplicate name: %+v %v", ps, err)
+ }
+}
+
+// A from_step start knows the plan leg of that step when the rows are reproduced: the start reply's leg is checked against it.
+func TestObserveStartFromStepKnowsItsLeg(t *testing.T) {
+ p := startTestPlan(t)
+ res, err := planexport.Build(p, planexport.Options{Source: "test", GMCrossDirect: true, DropCompanionsLoot: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ n, ok := res.Rows.GroupStep(p, 2, 0)
+ if !ok {
+ t.Fatal("no group step")
+ }
+ ps, err := observeStartFrom(p, 0, nil, n, nil)(res.Rows.StepCount())
+ if err != nil || ps.Step != n || ps.ExpectLeg != "third" || !ps.HavePoint {
+ t.Fatalf("%+v %v", ps, err)
+ }
+ // Without the server's step count the point and the leg are unknown: the start still goes (the place check then asks for
+ // allow_far_start).
+ ps, err = observeStartFrom(p, 0, nil, n, nil)(0)
+ if err != nil || ps.ExpectLeg != "" || ps.HavePoint {
+ t.Fatalf("no count: %+v %v", ps, err)
+ }
+}
diff --git a/cmd/turtle-agent/plan_export_cmd.go b/cmd/turtle-agent/plan_export_cmd.go
index 325ef557..4f213ac6 100644
--- a/cmd/turtle-agent/plan_export_cmd.go
+++ b/cmd/turtle-agent/plan_export_cmd.go
@@ -19,7 +19,7 @@ import (
"github.com/pjerra/turtle-agent/internal/planexport"
)
-const planExportUsage = "usage: turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--gm-cross-direct] [--approach-hops] [--mmaps DIR] [--rev SHA] [--stamp YYYYMMDDHHMMSS] [--force] --out FILE|DIR (... | --all-live)"
+const planExportUsage = "usage: turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--carrier-chests] [--gm-cross-direct] [--approach-hops] [--skip-legs NAME,...] [--mmaps DIR] [--rev SHA] [--stamp YYYYMMDDHHMMSS] [--force] --out FILE|DIR (... | --all-live)"
// migrationName is what a TortoiseBots world migration is called (data/sql/world/).
var migrationName = regexp.MustCompile(`^\d{14}_world\.sql$`)
@@ -38,8 +38,10 @@ func planExportCmd(args []string, stdout, stderr io.Writer) int {
rev := fs.String("rev", "", "the turtle-agent commit named in ai_dungeon_plan.source (default: git rev-parse of the plan folder, if it is a checkout)")
stamp := fs.String("stamp", "", "the migration's timestamp, YYYYMMDDHHMMSS (default: now, UTC)")
dropLoot := fs.Bool("drop-companions-loot", false, "export a plan with a leg whose companions_loot is false anyway, without it (the rows cannot carry it: the run leader's bots loot; refused without)")
+ carrierChests := fs.Bool("carrier-chests", false, "with --drop-companions-loot, export such a leg's loot_object steps as carrier rows: a move to its stand, the loot (gone-is-done flag 256), and — when the leg has leftover_entries — their sweep pull at the point; the bots' looting still has to be turned off for the leg in the setup, or a module's trapped-chest rule has to skip them; flag 256 needs a TortoiseBots module with the gone-is-done step flag: an older module ignores it and pauses on a despawned grave after a wipe (then a .bot run skip skips the row)")
gmCrossDirect := fs.Bool("gm-cross-direct", false, "export a gm_cross waypoint as a move_direct (a straight walk to the landing point) instead of refusing the plan; only for a gap that is a drop the line crosses, and not tried live")
approach := fs.Bool("approach-hops", false, "with --mmaps, also cut the hop to a pull, boss or hold that is more than 50 yd from the anchor before it into move rows along the navmesh path (the run leader's own hops are at most 8 of 40 yd)")
+ skipLegs := fs.String("skip-legs", "", "leg names (comma list) left out of the rows, each with a warning; the later legs close up (Zul'Farrak's bly: a boss that turns hostile after his gossip, which the run leader cannot do). An unknown name is refused")
force := fs.Bool("force", false, "let --out replace an existing file (never edit a migration that is applied)")
if err := fs.Parse(args); err != nil {
return exitUsage
@@ -58,6 +60,9 @@ func planExportCmd(args []string, stdout, stderr io.Writer) int {
case !*allLive && len(names) == 0:
fmt.Fprintln(stderr, planExportUsage)
return fail("name at least one plan, or pass --all-live")
+ case *carrierChests && !*dropLoot:
+ fmt.Fprintln(stderr, planExportUsage)
+ return fail("--carrier-chests needs --drop-companions-loot (the companions_loot-false leg's chest rows are not exported without it)")
}
if *allLive {
m, err := filepath.Glob(filepath.Join(*dir, "*.json"))
@@ -100,7 +105,12 @@ func planExportCmd(args []string, stdout, stderr io.Writer) int {
return fail("%s exists: a migration that is applied is never edited; export to a new name, or pass --force", target)
}
var planner *navmesh.Planner
- opt := planexport.Options{DropCompanionsLoot: *dropLoot, GMCrossDirect: *gmCrossDirect, ApproachHops: *approach}
+ opt := planexport.Options{DropCompanionsLoot: *dropLoot, CarrierChests: *carrierChests, GMCrossDirect: *gmCrossDirect, ApproachHops: *approach}
+ for _, n := range strings.Split(*skipLegs, ",") {
+ if n = strings.TrimSpace(n); n != "" {
+ opt.SkipLegs = append(opt.SkipLegs, n)
+ }
+ }
var naive *navmesh.Planner
if *mmaps != "" {
planner = navmesh.NewPlanner(*mmaps)
diff --git a/cmd/turtle-agent/plan_export_cmd_test.go b/cmd/turtle-agent/plan_export_cmd_test.go
index ab37c007..9d0599bf 100644
--- a/cmd/turtle-agent/plan_export_cmd_test.go
+++ b/cmd/turtle-agent/plan_export_cmd_test.go
@@ -128,3 +128,69 @@ func TestPlanExportCutsAtAStepTheRunLeaderCannotDo(t *testing.T) {
t.Fatalf("first leg: %d %q", code, errs)
}
}
+
+// --skip-legs leaves a named leg out (the shipped Zul'Farrak plan then reaches the End Door and Ukorz past Bly), and a name that is no leg
+// of the plan is refused before anything is written.
+func TestPlanExportSkipLegs(t *testing.T) {
+ dir := t.TempDir()
+ code, out, errs := runExport("--dir", "../../dungeons", "--drop-companions-loot", "--skip-legs", "bly", "--stamp", "20261021005900", "--rev", "abc12345", "--out", dir, "zulfarrak")
+ if code != exitOK {
+ t.Fatalf("exit %d: %s", code, errs)
+ }
+ if !strings.Contains(out, "zulfarrak: map 209, 16 legs") || strings.Contains(out, "CUT") {
+ t.Fatalf("output %q", out)
+ }
+ data, err := os.ReadFile(filepath.Join(dir, "20261021005900_world.sql"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), "'gossip', 7607,") || !strings.Contains(string(data), "'ukorz'") || strings.Contains(string(data), "'bly'") {
+ t.Fatal("the rows lack the Weegli gossip or Ukorz, or still have Bly")
+ }
+ if !strings.Contains(errs, "leg 16 (bly): left out of the export by the exporter's skip-legs option") {
+ t.Fatalf("stderr %q", errs)
+ }
+ code, _, errs = runExport("--dir", "../../dungeons", "--drop-companions-loot", "--skip-legs", "bli", "--stamp", "20261021005901", "--out", dir, "zulfarrak")
+ if code == exitOK || !strings.Contains(errs, `skip-legs names "bli"`) {
+ t.Fatalf("a misspelt leg: %d %q", code, errs)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "20261021005901_world.sql")); err == nil {
+ t.Fatal("a file was written for a refused export")
+ }
+}
+
+// --carrier-chests needs --drop-companions-loot (refused alone); with it, the zumrah leg's 31 Shallow Grave steps are 31 triples of
+// move, loot (flag 256) and sweep pull in the rows, before the trigger and the boss.
+func TestPlanExportCarrierChests(t *testing.T) {
+ dir := t.TempDir()
+ if code, _, errs := runExport("--dir", "../../dungeons", "--skip-legs", "bly", "--carrier-chests", "--stamp", "20261021200000", "--out", dir, "zulfarrak"); code != exitUsage || !strings.Contains(errs, "--carrier-chests needs --drop-companions-loot") {
+ t.Fatalf("without --drop-companions-loot: %d %q", code, errs)
+ }
+ if code, _, errs := runExport("--dir", "../../dungeons", "--skip-legs", "bly", "--carrier-chests", "--drop-companions-loot", "--stamp", "20261021200100", "--rev", "abc12345", "--out", dir, "zulfarrak"); code != exitOK {
+ t.Fatalf("exit %d: %s", code, errs)
+ }
+ data, err := os.ReadFile(filepath.Join(dir, "20261021200100_world.sql"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), "'loot', 128403,") {
+ t.Fatal("no grave loot row in the migration")
+ }
+ rows, err := planexport.Parse(string(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := rows[0]
+ n := 0
+ for _, s := range r.Steps {
+ if s.Entry == 128403 {
+ n++
+ if s.Flags&planexport.FlagGoneIsDone == 0 {
+ t.Fatalf("a grave loot row without flag 256: %+v", s)
+ }
+ }
+ }
+ if n != 31 {
+ t.Fatalf("%d grave loot rows, want 31", n)
+ }
+}
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/cmd/turtle-agent/route_oracle_bosses_text.go b/cmd/turtle-agent/route_oracle_bosses_text.go
new file mode 100644
index 00000000..f63e9ae6
--- /dev/null
+++ b/cmd/turtle-agent/route_oracle_bosses_text.go
@@ -0,0 +1,139 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "text/tabwriter"
+
+ "github.com/pjerra/turtle-agent/internal/routeoracle"
+)
+
+// failedLine says why a leg's live plan did not reach its objective and which stored break it
+// ran into.
+func failedLine(l *routeoracle.BossLeg) string {
+ s := fmt.Sprintf("leg %d (%s): %s, the live path ends %.1f yd short of the %s at %.1f,%.1f,%.1f (from %.1f,%.1f,%.1f)",
+ l.Leg, l.Name, l.Status, l.Live.EndGapYd, l.Objective, l.To.X, l.To.Y, l.To.Z, l.From.X, l.From.Y, l.From.Z)
+ if l.Status == "partial" {
+ s += fmt.Sprintf("; it stops at %.1f,%.1f,%.1f", l.Live.End.X, l.Live.End.Y, l.Live.End.Z)
+ }
+ if len(l.BreakHits) == 0 {
+ return s + "; no stored break bridges it or lies within 15 yd of the end or of the line to the objective"
+ }
+ for _, b := range l.BreakHits {
+ s += fmt.Sprintf("; stored break leg %d via %d (%s) at %.1f,%.1f,%.1f (%s)", b.Leg, b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Why)
+ }
+ return s
+}
+
+// failedBossLegs is the report's failed legs, one line each: the fair failures only (a
+// non-comparable continuation is not a finding about the plan).
+func failedBossLegs(rep routeoracle.BossesReport) []string {
+ var out []string
+ for i := range rep.Legs {
+ if rep.Legs[i].Failed() {
+ out = append(out, failedLine(&rep.Legs[i]))
+ }
+ }
+ return out
+}
+
+// bossesText is the plan's human table: the summary, one line per leg, the failed legs with the
+// breaks they hit, the non-comparable continuations, then what the complete legs say about the
+// stored hand data (packs off the live path, hand points farther than 10 yd, floor jumps).
+func bossesText(rep routeoracle.BossesReport) ([]byte, error) {
+ var buf bytes.Buffer
+ w := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0)
+ fmt.Fprintf(w, "%s (map %d, status %q, %d avoid boxes), bosses mode, objectives: %s\n", rep.Plan, rep.Map, rep.Status, rep.Avoid, rep.Objectives)
+ if rep.Objectives == routeoracle.ObjectivesBosses {
+ fmt.Fprintf(w, "boss-only chain: entrance -> boss 1 -> boss 2 ...; a leg without a boss is folded into the next boss (name a+b), no stored to point is an objective; legs after the last boss are not planned: %v\n", rep.Unplanned)
+ }
+ fmt.Fprintln(w, rep.Summary())
+ fmt.Fprintln(w, "each leg is one avoid-aware navmesh plan from the previous objective to this leg's objective: no hand points, no break hops;")
+ fmt.Fprintln(w, "a leg that does not reach it FAILED and scores nothing; a leg planned from a failed leg's end is non-comparable (NC) and scores nothing")
+ fmt.Fprintf(w, "plan time %.0f ms\n", rep.PlanMs)
+ fmt.Fprintln(w, "packs: a pack position within its radius plus 18 yd of the live path (8 yd of height), the same definition as the walk mode; hand: via/to/approach/stand points within 10 yd of it (redundant); NC: non-comparable; bridged: the NC leg planned from the previous objective")
+ fmt.Fprintln(w, "leg\tname\tobjective\tfrom\tto\tstatus\tgap\tlive\tpacks\thand\tzj\tbreaks hit\tNC\tbridged")
+ for i := range rep.Legs {
+ l := &rep.Legs[i]
+ nc := ""
+ if l.NonComparable {
+ nc = "NC"
+ }
+ if l.Status == "complete" {
+ fmt.Fprintf(w, "%d\t%s\t%s\t%.0f,%.0f\t%.0f,%.0f\t%s\t%.1f\t%.1f\t%d/%d\t%d/%d\t%d\t-\t%s\t%s\n",
+ l.Leg, l.Name, l.Objective, l.From.X, l.From.Y, l.To.X, l.To.Y, l.Status, l.Live.EndGapYd, l.Live.LiveLenYd,
+ l.PacksOnPath, len(l.Packs), l.HandRedundant, len(l.Hand), len(l.ZJumps), nc, bridgedOf(l))
+ continue
+ }
+ fmt.Fprintf(w, "%d\t%s\t%s\t%.0f,%.0f\t%.0f,%.0f\t%s\t%.1f\t%.1f\t-\t-\t-\t%d\t%s\t%s\n",
+ l.Leg, l.Name, l.Objective, l.From.X, l.From.Y, l.To.X, l.To.Y, l.Status, l.Live.EndGapYd, l.Live.LiveLenYd, len(l.BreakHits), nc, bridgedOf(l))
+ }
+ for i := range rep.Legs {
+ l := &rep.Legs[i]
+ switch {
+ case l.Failed():
+ fmt.Fprintf(w, "FAILED %s\n", failedLine(l))
+ if l.Reason != "" {
+ fmt.Fprintf(w, " planner: %s\n", l.Reason)
+ }
+ case l.NonComparable:
+ fmt.Fprintf(w, "NON-COMPARABLE leg %d (%s): %s, %s: %s\n", l.Leg, l.Name, l.Status, l.NonComparableWhy, l.Reason)
+ if b := l.Bridged; b != nil && b.Status != "complete" {
+ fmt.Fprintf(w, " BRIDGED leg %d is a second failure: %s\n", l.Leg, failedLine(b))
+ }
+ }
+ }
+ for i := range rep.Legs {
+ l := &rep.Legs[i]
+ if !l.Counted() {
+ continue
+ }
+ for _, g := range l.Packs {
+ if !g.OnPath {
+ fmt.Fprintf(w, "leg %d (%s): pack group %d of stored leg %d (entries %v) nearest position %.1f yd (dz %.1f) from the live path, radius %.0f yd\n",
+ l.Leg, l.Name, g.Group, legOf(g.Leg), g.Entries, g.NearYd, g.DZYd, g.RadiusYd)
+ }
+ }
+ for _, h := range l.Hand {
+ if !h.Redundant {
+ fmt.Fprintf(w, "leg %d (%s): hand %s (stored leg %d, seq %d) at %.1f,%.1f,%.1f is %.1f yd from the live path\n",
+ l.Leg, l.Name, h.Kind, legOf(h.Leg), h.Seq, h.At.X, h.At.Y, h.At.Z, h.NearYd)
+ }
+ }
+ for _, b := range l.Breaks {
+ fmt.Fprintf(w, "leg %d (%s): stored break (stored leg %d) via %d (%s) at %.1f,%.1f,%.1f: live path passes it %v\n",
+ l.Leg, l.Name, legOf(b.Leg), b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Touched)
+ }
+ for _, j := range l.ZJumps {
+ fmt.Fprintf(w, "leg %d (%s): z jump %.1f yd at %.1f,%.1f,%.1f\n", l.Leg, l.Name, j.DropYd, j.At[0], j.At[1], j.At[2])
+ }
+ }
+ fmt.Fprintln(w, "tiles used (sha-256):")
+ for _, t := range rep.Tiles {
+ fmt.Fprintf(w, " %s\t%s\t%d bytes\n", t.Name, t.Sha256, t.Bytes)
+ }
+ if err := w.Flush(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+// bridgedOf is the table cell of a non-comparable leg's bridged re-plan.
+func bridgedOf(l *routeoracle.BossLeg) string {
+ if l.Bridged == nil {
+ return "-"
+ }
+ if l.Bridged.Status == "complete" {
+ return fmt.Sprintf("complete %.0f yd, packs %d/%d, hand %d/%d", l.Bridged.Live.LiveLenYd, l.Bridged.PacksOnPath, len(l.Bridged.Packs),
+ l.Bridged.HandRedundant, len(l.Bridged.Hand))
+ }
+ return fmt.Sprintf("%s, %.0f yd short", l.Bridged.Status, l.Bridged.Live.EndGapYd)
+}
+
+// legOf is a folded row's stored leg index (0 when the field is absent).
+func legOf(p *int) int {
+ if p == nil {
+ return 0
+ }
+ return *p
+}
diff --git a/cmd/turtle-agent/route_oracle_cmd.go b/cmd/turtle-agent/route_oracle_cmd.go
new file mode 100644
index 00000000..39e84c0b
--- /dev/null
+++ b/cmd/turtle-agent/route_oracle_cmd.go
@@ -0,0 +1,290 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "path/filepath"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
+ "github.com/pjerra/turtle-agent/internal/navmesh"
+ "github.com/pjerra/turtle-agent/internal/routeoracle"
+)
+
+// exitRouteOracle: route-oracle could not report every plan (a failing plan is not a usage error).
+const exitRouteOracle = 12
+
+const routeOracleUsage = "usage: turtle-agent route-oracle --plans DIR --mmaps DIR --out DIR [--mode walk|bosses] [--objectives plan|bosses] [--plan NAME]..."
+
+// The two modes. walk (the default) plans each plan's own stored sequence hop by hop and
+// scores the stored hops against the navmesh; bosses plans entrance -> each leg's objective
+// live, with no hand points and no break hops, to see what a boss list alone could do.
+const (
+ modeWalk = "walk"
+ modeBosses = "bosses"
+)
+
+// routeOracleDefaultPlans are the plans the oracle reports when no --plan is given.
+var routeOracleDefaultPlans = []string{"wailing_caverns", "ragefire_chasm", "uldaman", "zulfarrak", "deadmines"}
+
+// planList is a repeatable --plan flag.
+type planList struct{ names *[]string }
+
+func (l *planList) String() string {
+ if l.names == nil {
+ return ""
+ }
+ return strings.Join(*l.names, ",")
+}
+func (l *planList) Set(v string) error {
+ *l.names = append(*l.names, v)
+ return nil
+}
+
+// routeOracleCmd compares each plan's stored route with what the navmesh plans for its
+// objective chain (entrance, then per leg its boss, else its to/last step point), and writes
+// /.txt (a human table) and /.json (the machine report, Stage 1's
+// fixture). Every leg is planned twice: plain, and on a scratch planner with the plan's avoid
+// boxes; the report's metrics are against the avoid-aware path, the one a run plans with.
+func routeOracleCmd(args []string, stdout, stderr io.Writer) int {
+ fs := flag.NewFlagSet("route-oracle", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ plansDir := fs.String("plans", "dungeons", "the folder with the plan files")
+ mmaps := fs.String("mmaps", "", "folder with the server's mmaps (.mmap and *.mmtile); required")
+ out := fs.String("out", "", "folder for .txt and .json (-bosses.* in bosses mode, -bosses-only.* with --objectives bosses); required")
+ objectives := fs.String("objectives", routeoracle.ObjectivesPlan, "bosses mode only. plan: each leg's own objective (its boss, else its to point); bosses: ONLY the chain entrance -> boss 1 -> boss 2 ... (a leg without a boss is folded into the next boss; no stored to point is an objective)")
+ mode := fs.String("mode", modeWalk, "walk: score the plan's stored hops on the navmesh; bosses: plan entrance -> each boss live, with no hand points")
+ var plans []string
+ fs.Var(&planList{&plans}, "plan", "a plan name; repeatable (default: "+strings.Join(routeOracleDefaultPlans, ", ")+")")
+ if err := fs.Parse(args); err != nil {
+ return exitUsage
+ }
+ if len(plans) == 0 {
+ plans = routeOracleDefaultPlans
+ }
+ if *mmaps == "" || *out == "" || fs.NArg() > 0 {
+ fmt.Fprintln(stderr, routeOracleUsage)
+ return exitUsage
+ }
+ if *mode != modeWalk && *mode != modeBosses {
+ fmt.Fprintf(stderr, "route-oracle: unknown --mode %q (want %s or %s)\n", *mode, modeWalk, modeBosses)
+ return exitUsage
+ }
+ if *objectives != routeoracle.ObjectivesPlan && *objectives != routeoracle.ObjectivesBosses {
+ fmt.Fprintf(stderr, "route-oracle: unknown --objectives %q (want %s or %s)\n", *objectives, routeoracle.ObjectivesPlan, routeoracle.ObjectivesBosses)
+ return exitUsage
+ }
+ if *mode == modeWalk && *objectives != routeoracle.ObjectivesPlan {
+ fmt.Fprintf(stderr, "route-oracle: --objectives %s needs --mode %s (the walk mode plans the plan's own stored sequence)\n", *objectives, modeBosses)
+ return exitUsage
+ }
+ outDir, err := safeOutDir(*out)
+ if err != nil {
+ fmt.Fprintf(stderr, "route-oracle: %v\n", err)
+ return exitUsage
+ }
+ *out = outDir
+ planner := navmesh.NewPlanner(*mmaps)
+ defer planner.Close()
+
+ failed := false
+ for _, n := range plans {
+ if !planName.MatchString(n) {
+ fmt.Fprintf(stderr, "route-oracle: %q is not a plan name (a-z, 0-9, _ and -)\n", n)
+ return exitUsage
+ }
+ p, err := dungeon.Load(filepath.Join(*plansDir, n+".json"))
+ if err != nil {
+ fmt.Fprintf(stderr, "route-oracle: %v\n", err)
+ failed = true
+ continue
+ }
+ // The plan file's own name must be a plan name and the one asked for: the report is
+ // written under the name, so a name read from the JSON ("../escape") must never
+ // reach a path — the file names below come from the validated CLI name, and Oracle
+ // re-checks the plan's own.
+ if err := routeoracle.CheckName(p.Name); err != nil {
+ fmt.Fprintf(stderr, "route-oracle: %s.json: %v\n", n, err)
+ failed = true
+ continue
+ }
+ if p.Name != n {
+ fmt.Fprintf(stderr, "route-oracle: %s.json says name %q, want %q\n", n, p.Name, n)
+ failed = true
+ continue
+ }
+ t0 := time.Now()
+ var summary string
+ var tiles []routeoracle.TileFile
+ if *mode == modeBosses {
+ rep, err := routeoracle.Bosses(planner, *mmaps, p, *objectives)
+ if err == nil {
+ err = writeBosses(rep, *out, n)
+ }
+ if err != nil {
+ fmt.Fprintf(stderr, "route-oracle: %v\n", err)
+ failed = true
+ continue
+ }
+ summary, tiles = rep.Summary(), rep.Tiles
+ for _, f := range failedBossLegs(rep) {
+ summary += "\n " + f
+ }
+ } else {
+ rep, err := routeoracle.Oracle(planner, *mmaps, p)
+ if err == nil {
+ err = writeOracle(rep, *out, n)
+ }
+ if err != nil {
+ fmt.Fprintf(stderr, "route-oracle: %v\n", err)
+ failed = true
+ continue
+ }
+ summary, tiles = rep.Summary(), rep.Tiles
+ }
+ fmt.Fprintf(stdout, "%s (%.0f ms wall)\n%s\n", p.Name, float64(time.Since(t0).Microseconds())/1000, summary)
+ for _, t := range tiles {
+ fmt.Fprintf(stdout, " %s %s (%d bytes)\n", t.Name, t.Sha256, t.Bytes)
+ }
+ }
+ if failed {
+ return exitRouteOracle
+ }
+ return exitOK
+}
+
+// writeOracle writes the plan's .txt and .json into dir, under the validated
+// CLI name (never the plan JSON's own), and refuses a destination that resolves outside dir.
+func writeOracle(rep routeoracle.Report, dir, name string) error {
+ txt, err := oracleText(rep)
+ if err != nil {
+ return err
+ }
+ return writeReport(rep, txt, dir, name)
+}
+
+// writeBosses writes the plan's -bosses.txt and -bosses.json, under the validated
+// CLI name like writeOracle.
+func writeBosses(rep routeoracle.BossesReport, dir, name string) error {
+ txt, err := bossesText(rep)
+ if err != nil {
+ return err
+ }
+ stem := name + "-bosses"
+ if rep.Objectives == routeoracle.ObjectivesBosses {
+ stem = name + "-bosses-only"
+ }
+ return writeReport(rep, txt, dir, stem)
+}
+
+// writeReport writes .json (the report) and .txt (the table) into dir.
+func writeReport(rep any, txt []byte, dir, name string) error {
+ b, err := json.MarshalIndent(rep, "", " ")
+ if err != nil {
+ return err
+ }
+ return writeFilesIn(dir, map[string][]byte{name + ".json": append(b, '\n'), name + ".txt": txt})
+}
+
+// oracleText is the plan's human table: one line per leg, then the failed legs' hops, the
+// touched breaks, the packs off the path, the stored points that are not redundant, the
+// path's reasons, and the files used with their SHA-256.
+func oracleText(rep routeoracle.Report) ([]byte, error) {
+ var buf bytes.Buffer
+ w := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0)
+ fmt.Fprintf(w, "%s (map %d, status %q, %d avoid boxes)\n", rep.Plan, rep.Map, rep.Status, rep.Avoid)
+ fmt.Fprintln(w, rep.Summary())
+ fmt.Fprintln(w, "metrics against the avoid-aware route (a scratch planner with SetAvoid of the plan's boxes); failed and indeterminate legs score nothing")
+ fmt.Fprintf(w, "plan time %.0f ms\n", rep.PlanMs)
+ fmt.Fprintln(w, "leg\tname\tobjective\tfrom\tto\tplain\tavoid\tgap\tlive\tstored\tratio\tmaxd\tmeand\tzj\tbreaks\tpacks\tredundant")
+ for i := range rep.Legs {
+ lr := &rep.Legs[i]
+ fmt.Fprintf(w, "%d\t%s\t%s\t%.0f,%.0f\t%.0f,%.0f\t%s\t%s\t%.1f\t%.1f\t%.1f\t%.2f\t%.1f\t%.1f\t%d\t%d/%d\t%d/%d\t%d/%d\n",
+ lr.Leg, lr.Name, lr.Objective, lr.From.X, lr.From.Y, lr.To.X, lr.To.Y,
+ lr.Plain.Status, lr.Avoid.Status, lr.Avoid.EndGapYd, lr.Avoid.LiveLenYd,
+ lr.StoredLenYd, lr.Ratio, lr.StoredMaxYd, lr.StoredMeanYd, len(lr.ZJumps),
+ lr.BreaksTouched, len(lr.Breaks), lr.PacksOnPath, len(lr.Pulls),
+ redundantOf(lr), approachViaOf(lr))
+ }
+ for i := range rep.Legs {
+ lr := &rep.Legs[i]
+ if lr.Reason != "" {
+ fmt.Fprintf(w, "leg %d (%s): %s: %s\n", lr.Leg, lr.Name, lr.Status, lr.Reason)
+ }
+ if lr.Status == "complete" {
+ continue // a complete leg's hops say nothing the table does not
+ }
+ for j := range lr.Segments {
+ seg := &lr.Segments[j]
+ mark := ""
+ if seg.NonComparable {
+ mark = ", non-comparable (planned on from the point the partial path reached)"
+ }
+ fmt.Fprintf(w, "leg %d (%s): hop %d (%s) %.1f,%.1f -> %.1f,%.1f: plain %s, avoid %s, live %.1f yd, stored %.1f yd, ratio %.2f%s\n",
+ lr.Leg, lr.Name, seg.Seq, seg.Kind, seg.From.X, seg.From.Y, seg.At.X, seg.At.Y,
+ seg.Plain.Status, seg.Avoid.Status, seg.Avoid.LiveLenYd, seg.StoredLenYd, seg.Ratio, mark)
+ }
+ for _, b := range lr.Breaks {
+ if b.Touched {
+ fmt.Fprintf(w, "leg %d (%s): break via %d (%s) at %.1f,%.1f,%.1f: %s\n",
+ lr.Leg, lr.Name, b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Why)
+ }
+ }
+ for _, g := range lr.Pulls {
+ if !g.OnPath {
+ fmt.Fprintf(w, "leg %d (%s): pull group %d (entries %v) nearest position %.1f yd (dz %.1f) off the path (radius %.0f yd)\n",
+ lr.Leg, lr.Name, g.Group, g.Entries, g.NearYd, g.DZYd, g.RadiusYd)
+ }
+ }
+ for _, s := range lr.Stored {
+ if !s.Redundant && isApproachVia(s.Kind) {
+ fmt.Fprintf(w, "leg %d (%s): stored %s (seq %d) at %.1f,%.1f,%.1f is %.1f yd from the route\n",
+ lr.Leg, lr.Name, s.Kind, s.Seq, s.At.X, s.At.Y, s.At.Z, s.NearYd)
+ }
+ }
+ for _, j := range lr.ZJumps {
+ fmt.Fprintf(w, "leg %d (%s): z jump %.1f yd at %.1f,%.1f,%.1f\n", lr.Leg, lr.Name, j.DropYd, j.At[0], j.At[1], j.At[2])
+ }
+ }
+ fmt.Fprintln(w, "tiles used (sha-256):")
+ for _, t := range rep.Tiles {
+ fmt.Fprintf(w, " %s\t%s\t%d bytes\n", t.Name, t.Sha256, t.Bytes)
+ }
+ if err := w.Flush(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+// redundantOf is how many of the leg's stored approach and via points are redundant.
+func redundantOf(lr *routeoracle.LegReport) int {
+ n := 0
+ for _, s := range lr.Stored {
+ if s.Redundant && isApproachVia(s.Kind) {
+ n++
+ }
+ }
+ return n
+}
+
+// approachViaOf is how many approach and via points the leg stores.
+func approachViaOf(lr *routeoracle.LegReport) int {
+ n := 0
+ for _, s := range lr.Stored {
+ if isApproachVia(s.Kind) {
+ n++
+ }
+ }
+ return n
+}
+
+// isApproachVia says whether the stored point is an approach or via point (the redundancy
+// check's kind; a boss's approach is one too).
+func isApproachVia(kind string) bool {
+ return kind == "approach" || kind == "via" || kind == "boss_approach"
+}
diff --git a/cmd/turtle-agent/route_oracle_cmd_test.go b/cmd/turtle-agent/route_oracle_cmd_test.go
new file mode 100644
index 00000000..ebf118b3
--- /dev/null
+++ b/cmd/turtle-agent/route_oracle_cmd_test.go
@@ -0,0 +1,224 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/navmesh/navfixture"
+)
+
+// The command's flags and failures, without navmesh: the oracle's own tests cover the
+// report over a synthetic mesh (internal/routeoracle).
+func TestRouteOracleCmdFlags(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ code int
+ want string // a prefix of stderr
+ }{
+ {"no mmaps", []string{"--out", t.TempDir()}, exitUsage, routeOracleUsage},
+ {"no out", []string{"--mmaps", "/nowhere"}, exitUsage, routeOracleUsage},
+ {"a stray argument", []string{"--mmaps", "/nowhere", "--out", t.TempDir(), "extra"}, exitUsage, routeOracleUsage},
+ {"a bad plan name", []string{"--mmaps", "/nowhere", "--out", t.TempDir(), "--plan", "Wailing"}, exitUsage, "route-oracle: \"Wailing\" is not a plan name"},
+ {"a missing plan file", []string{"--mmaps", "/nowhere", "--out", t.TempDir(), "--plan", "nowhere"}, exitRouteOracle, "route-oracle: "},
+ }
+ for _, c := range cases {
+ var stdout, stderr bytes.Buffer
+ if got := routeOracleCmd(c.args, &stdout, &stderr); got != c.code {
+ t.Errorf("%s: exit %d, want %d (stderr %q)", c.name, got, c.code, stderr.String())
+ }
+ if c.want != "" && !strings.HasPrefix(stderr.String(), c.want) {
+ t.Errorf("%s: stderr %q, want it to start with %q", c.name, stderr.String(), c.want)
+ }
+ }
+}
+
+// The plan JSON's own name never reaches a path: a plan file whose name is a traversal
+// ("../escape") is refused, and nothing is written outside --out. (With the check removed,
+// the report would be written to the parent of --out as escape.json, and this test's
+// look-outside assertion would fail.)
+func TestRouteOracleRefusesATraversalName(t *testing.T) {
+ dungeons := t.TempDir()
+ outParent := t.TempDir()
+ out := filepath.Join(outParent, "out")
+ plan := `{"version":1,"name":"../escape","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"hall","to":{"x":46,"y":12,"z":0},"boss":{"entry":1,"position":{"x":48,"y":48,"z":0}}}]}`
+ if err := os.WriteFile(filepath.Join(dungeons, "escape.json"), []byte(plan), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ var stdout, stderr bytes.Buffer
+ code := routeOracleCmd([]string{"--plans", dungeons, "--mmaps", "/nowhere", "--out", out, "--plan", "escape"}, &stdout, &stderr)
+ if code != exitRouteOracle {
+ t.Fatalf("exit %d, want %d (stderr %q)", code, exitRouteOracle, stderr.String())
+ }
+ if !strings.Contains(stderr.String(), "not a plan name") {
+ t.Errorf("stderr %q, want the name refusal", stderr.String())
+ }
+ // Nothing outside --out: no report in the parent (the traversal target) and no --out made.
+ if _, err := os.Stat(filepath.Join(outParent, "escape.json")); err == nil {
+ t.Fatal("the report escaped to the parent of --out")
+ }
+ if _, err := os.Stat(out); err == nil {
+ if es, err := os.ReadDir(out); err == nil && len(es) > 0 {
+ t.Errorf("--out holds files for a refused plan: %v", es)
+ }
+ }
+}
+
+// A plan file whose name differs from the file it is read from is refused: the report's
+// name is the requested one, so the pairing must hold.
+func TestRouteOracleRefusesANameMismatch(t *testing.T) {
+ dungeons := t.TempDir()
+ plan := `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"hall","to":{"x":46,"y":12,"z":0},"boss":{"entry":1,"position":{"x":48,"y":48,"z":0}}}]}`
+ if err := os.WriteFile(filepath.Join(dungeons, "lie.json"), []byte(plan), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ var stdout, stderr bytes.Buffer
+ if code := routeOracleCmd([]string{"--mmaps", "/nowhere", "--out", t.TempDir(), "--plans", dungeons, "--plan", "lie"}, &stdout, &stderr); code != exitRouteOracle {
+ t.Fatalf("exit %d (stderr %q)", code, stderr.String())
+ }
+ if !strings.Contains(stderr.String(), `says name "corridor", want "lie"`) {
+ t.Errorf("stderr %q", stderr.String())
+ }
+}
+
+// A run over a fixture mesh end to end, with --plans: the two files land under the
+// requested name. (cgo: the fixture mesh is planned through the vendored Detour.)
+func TestRouteOracleWritesTheFixtureReports(t *testing.T) {
+ dungeons := t.TempDir()
+ out := t.TempDir()
+ plan := `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"hall","to":{"x":46,"y":12,"z":0},"pull_groups":[{"entries":[100],"positions":[{"x":44,"y":12,"z":0}]}]}]}`
+ if err := os.WriteFile(filepath.Join(dungeons, "corridor.json"), []byte(plan), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ mmaps := navfixture.WriteDir(t, 0, navfixture.LCorridor(0))
+ var stdout, stderr bytes.Buffer
+ if code := routeOracleCmd([]string{"--plans", dungeons, "--mmaps", mmaps, "--out", out, "--plan", "corridor"}, &stdout, &stderr); code != exitOK {
+ t.Fatalf("exit %d (stderr %q)", code, stderr.String())
+ }
+ for _, f := range []string{"corridor.json", "corridor.txt"} {
+ b, err := os.ReadFile(filepath.Join(out, f))
+ if err != nil || len(b) < 50 {
+ t.Errorf("%s: err %v len %d", f, err, len(b))
+ }
+ }
+}
+
+// An unknown --mode is a usage error, refused before anything is made or planned.
+func TestRouteOracleRefusesAnUnknownMode(t *testing.T) {
+ out := filepath.Join(t.TempDir(), "out")
+ for _, mode := range []string{"boss", "Walk", "", "walk,bosses"} {
+ var stdout, stderr bytes.Buffer
+ code := routeOracleCmd([]string{"--mmaps", "/nowhere", "--out", out, "--mode", mode}, &stdout, &stderr)
+ if code != exitUsage {
+ t.Errorf("mode %q: exit %d, want %d (stderr %q)", mode, code, exitUsage, stderr.String())
+ }
+ if !strings.HasPrefix(stderr.String(), "route-oracle: unknown --mode") {
+ t.Errorf("mode %q: stderr %q", mode, stderr.String())
+ }
+ }
+ if _, err := os.Stat(out); err == nil {
+ t.Error("--out was made for a refused mode")
+ }
+}
+
+// The default mode is walk: without --mode the walk files are written and no bosses file is;
+// with --mode bosses the -bosses files are written and the walk files are not. The plan
+// has a gap in its mesh, so the bosses report carries a failed leg.
+func TestRouteOracleModeDefaultsToWalk(t *testing.T) {
+ dungeons := t.TempDir()
+ plan := `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"hall","to":{"x":46,"y":12,"z":0},"pull_groups":[{"entries":[100],"positions":[{"x":44,"y":12,"z":0}]}]},
+ {"name":"far","to":{"x":48,"y":44,"z":0},"boss":{"entry":2,"position":{"x":48,"y":48,"z":0}}}]}`
+ if err := os.WriteFile(filepath.Join(dungeons, "corridor.json"), []byte(plan), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ var polys []navfixture.Poly
+ for _, p := range navfixture.LCorridor(0) {
+ if !(p[0][0] == 46 && p[0][1] == 26) {
+ polys = append(polys, p)
+ }
+ }
+ mmaps := navfixture.WriteDir(t, 0, polys)
+ exists := func(dir, f string) bool { _, err := os.Stat(filepath.Join(dir, f)); return err == nil }
+
+ walkOut := t.TempDir()
+ var stdout, stderr bytes.Buffer
+ if code := routeOracleCmd([]string{"--plans", dungeons, "--mmaps", mmaps, "--out", walkOut, "--plan", "corridor"}, &stdout, &stderr); code != exitOK {
+ t.Fatalf("default: exit %d (stderr %q)", code, stderr.String())
+ }
+ if !exists(walkOut, "corridor.json") || !exists(walkOut, "corridor.txt") || exists(walkOut, "corridor-bosses.json") || exists(walkOut, "corridor-bosses.txt") {
+ t.Errorf("default mode did not write the walk files alone")
+ }
+
+ bossOut := t.TempDir()
+ stdout.Reset()
+ stderr.Reset()
+ if code := routeOracleCmd([]string{"--plans", dungeons, "--mmaps", mmaps, "--out", bossOut, "--plan", "corridor", "--mode", "bosses"}, &stdout, &stderr); code != exitOK {
+ t.Fatalf("bosses: exit %d (stderr %q)", code, stderr.String())
+ }
+ if !exists(bossOut, "corridor-bosses.json") || !exists(bossOut, "corridor-bosses.txt") || exists(bossOut, "corridor.json") || exists(bossOut, "corridor.txt") {
+ t.Errorf("bosses mode did not write the -bosses files alone")
+ }
+ // The failed leg is listed with its reason on stdout and in the table.
+ if !strings.Contains(stdout.String(), "1 complete, 1 failed") || !strings.Contains(stdout.String(), "leg 1 (far): partial") {
+ t.Errorf("stdout %q", stdout.String())
+ }
+ txt, err := os.ReadFile(filepath.Join(bossOut, "corridor-bosses.txt"))
+ if err != nil || !strings.Contains(string(txt), "FAILED leg 1 (far)") {
+ t.Errorf("table: err %v\n%s", err, txt)
+ }
+}
+
+// --objectives: an unknown value is a usage error; boss-only objectives need the bosses mode;
+// the default is the plan's own objectives (-bosses.*), and boss-only writes
+// -bosses-only.* beside them without touching the others.
+func TestRouteOracleObjectivesFlag(t *testing.T) {
+ out := filepath.Join(t.TempDir(), "out")
+ for _, c := range []struct {
+ args []string
+ want string
+ }{
+ {[]string{"--objectives", "boss"}, "unknown --objectives"},
+ {[]string{"--objectives", "bosses"}, "needs --mode bosses"},
+ {[]string{"--mode", "walk", "--objectives", "bosses"}, "needs --mode bosses"},
+ } {
+ var stdout, stderr bytes.Buffer
+ args := append([]string{"--mmaps", "/nowhere", "--out", out}, c.args...)
+ if code := routeOracleCmd(args, &stdout, &stderr); code != exitUsage || !strings.Contains(stderr.String(), c.want) {
+ t.Errorf("%v: exit %d stderr %q, want %q", c.args, code, stderr.String(), c.want)
+ }
+ }
+ if _, err := os.Stat(out); err == nil {
+ t.Error("--out was made for a refused flag")
+ }
+
+ plans, mmaps := outFixture(t)
+ dir := t.TempDir()
+ run := func(extra ...string) int {
+ var stdout, stderr bytes.Buffer
+ args := append([]string{"--plans", plans, "--mmaps", mmaps, "--out", dir, "--plan", "corridor", "--mode", "bosses"}, extra...)
+ code := routeOracleCmd(args, &stdout, &stderr)
+ if code != exitOK {
+ t.Logf("stderr %q", stderr.String())
+ }
+ return code
+ }
+ has := func(f string) bool { _, err := os.Stat(filepath.Join(dir, f)); return err == nil }
+ if run() != exitOK || !has("corridor-bosses.json") || has("corridor-bosses-only.json") {
+ t.Error("the default objectives did not write the plan-objectives files alone")
+ }
+ // The fixture plan has no boss: the boss-only chain is empty and every leg is unplanned.
+ if run("--objectives", "bosses") != exitOK || !has("corridor-bosses-only.json") || !has("corridor-bosses-only.txt") {
+ t.Error("--objectives bosses did not write the boss-only files")
+ }
+ b, _ := os.ReadFile(filepath.Join(dir, "corridor-bosses-only.json"))
+ if !strings.Contains(string(b), `"objectives": "bosses"`) || !strings.Contains(string(b), `"unplanned_legs"`) {
+ t.Errorf("boss-only json: %s", b)
+ }
+}
diff --git a/cmd/turtle-agent/route_oracle_out.go b/cmd/turtle-agent/route_oracle_out.go
new file mode 100644
index 00000000..82c61753
--- /dev/null
+++ b/cmd/turtle-agent/route_oracle_out.go
@@ -0,0 +1,132 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// The report files are written under --out and nowhere else, whatever the folder already
+// holds. Two things guard that, and a lexical check is neither:
+//
+// - safeOutDir refuses a --out that is, or has a component that is, a symlink, so the
+// folder the reports land in is the real one the user named;
+// - writeFilesIn refuses a destination that already exists as anything but a regular file
+// (a symlink, even a dangling one, a directory), checks both before writing either, and
+// writes each file to a new temporary file of its own (O_EXCL) in a directory handle it
+// opened (os.Root: no component may leave the folder) and renames it over the target.
+// A rename replaces whatever is at the target, a symlink included, without following it,
+// so a link planted between the check and the write still overwrites nothing outside.
+
+// safeOutDir makes dir (and the folders above it that are missing) and returns its absolute
+// path, or says which component is a symlink. Nothing is created through a symlink: the
+// existing components are checked before anything is made, and all of them again after.
+func safeOutDir(dir string) (string, error) {
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return "", err
+ }
+ if err := noSymlinkIn(abs); err != nil {
+ return "", err
+ }
+ if err := os.MkdirAll(abs, 0o755); err != nil {
+ return "", err
+ }
+ if err := noSymlinkIn(abs); err != nil {
+ return "", err
+ }
+ fi, err := os.Lstat(abs)
+ if err != nil {
+ return "", err
+ }
+ if !fi.IsDir() {
+ return "", fmt.Errorf("--out %s is not a folder", abs)
+ }
+ return abs, nil
+}
+
+// noSymlinkIn walks the absolute path from the root down and refuses a component that is a
+// symlink; the walk stops at the first component that does not exist.
+func noSymlinkIn(abs string) error {
+ vol := filepath.VolumeName(abs)
+ rest := strings.TrimPrefix(abs, vol)
+ cur := vol + string(filepath.Separator)
+ for _, part := range strings.Split(rest, string(filepath.Separator)) {
+ if part == "" {
+ continue
+ }
+ cur = filepath.Join(cur, part)
+ fi, err := os.Lstat(cur)
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if fi.Mode()&fs.ModeSymlink != 0 {
+ return fmt.Errorf("--out %s contains a symlink (%s): name the real folder", abs, cur)
+ }
+ }
+ return nil
+}
+
+// writeFilesIn writes the named files (base name -> content) into dir without following a
+// symlink, and writes nothing when any destination is not a plain file or absent.
+func writeFilesIn(dir string, files map[string][]byte) error {
+ root, err := os.OpenRoot(dir)
+ if err != nil {
+ return err
+ }
+ defer root.Close()
+ for name := range files {
+ if name != filepath.Base(name) || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
+ return fmt.Errorf("report file name %q is not a plain file name", name)
+ }
+ fi, err := root.Lstat(name)
+ switch {
+ case errors.Is(err, fs.ErrNotExist):
+ case err != nil:
+ return err
+ case !fi.Mode().IsRegular():
+ return fmt.Errorf("report %s exists in %s and is not a regular file (%s): refusing to write through it", name, dir, fi.Mode().Type())
+ }
+ }
+ for name, content := range files {
+ if err := writeThroughTemp(root, name, content); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// writeThroughTemp writes content to a fresh temporary file in root and renames it to name.
+func writeThroughTemp(root *os.Root, name string, content []byte) (err error) {
+ var tmp string
+ var f *os.File
+ for i := 0; ; i++ {
+ tmp = fmt.Sprintf(".%s.%d.%d.tmp", name, os.Getpid(), i)
+ f, err = root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+ if err == nil {
+ break
+ }
+ if !errors.Is(err, fs.ErrExist) || i > 100 {
+ return err
+ }
+ }
+ defer func() {
+ if err != nil {
+ f.Close()
+ root.Remove(tmp)
+ }
+ }()
+ if _, err = f.Write(content); err != nil {
+ return err
+ }
+ if err = f.Close(); err != nil {
+ return err
+ }
+ return root.Rename(tmp, name)
+}
diff --git a/cmd/turtle-agent/route_oracle_out_test.go b/cmd/turtle-agent/route_oracle_out_test.go
new file mode 100644
index 00000000..64aaabaf
--- /dev/null
+++ b/cmd/turtle-agent/route_oracle_out_test.go
@@ -0,0 +1,159 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/navmesh/navfixture"
+)
+
+// outFixture is a one-leg plan on the fixture mesh: the plans folder and the mmaps folder.
+func outFixture(t *testing.T) (plans, mmaps string) {
+ t.Helper()
+ plans = t.TempDir()
+ plan := `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"hall","to":{"x":46,"y":12,"z":0},"pull_groups":[{"entries":[100],"positions":[{"x":44,"y":12,"z":0}]}]}]}`
+ if err := os.WriteFile(filepath.Join(plans, "corridor.json"), []byte(plan), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return plans, navfixture.WriteDir(t, 0, navfixture.LCorridor(0))
+}
+
+func runOracle(plans, mmaps, out, mode string) (int, string) {
+ var stdout, stderr bytes.Buffer
+ code := routeOracleCmd([]string{"--plans", plans, "--mmaps", mmaps, "--out", out, "--plan", "corridor", "--mode", mode}, &stdout, &stderr)
+ return code, stderr.String()
+}
+
+func readOr(t *testing.T, p string) string {
+ t.Helper()
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return "<" + err.Error() + ">"
+ }
+ return string(b)
+}
+
+// A report file that already exists as a symlink to somewhere outside --out is never written
+// through: the target keeps its content (or stays absent when the link dangles), nothing at all
+// is written, and the command fails. With the check and the rename removed, os.WriteFile followed
+// the link and truncated the target.
+func TestRouteOracleDoesNotWriteThroughASymlinkedReport(t *testing.T) {
+ plans, mmaps := outFixture(t)
+ for _, mode := range []string{"walk", "bosses"} {
+ stem := "corridor"
+ if mode == "bosses" {
+ stem = "corridor-bosses"
+ }
+ for _, ext := range []string{".txt", ".json"} {
+ for _, dangling := range []bool{false, true} {
+ outside := t.TempDir()
+ victim := filepath.Join(outside, "victim")
+ if !dangling {
+ if err := os.WriteFile(victim, []byte("KEEP"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ out := t.TempDir()
+ if err := os.Symlink(victim, filepath.Join(out, stem+ext)); err != nil {
+ t.Fatal(err)
+ }
+ code, stderr := runOracle(plans, mmaps, out, mode)
+ name := mode + " " + ext + " dangling=" + map[bool]string{false: "no", true: "yes"}[dangling]
+ if code != exitRouteOracle || !strings.Contains(stderr, "not a regular file") {
+ t.Errorf("%s: exit %d stderr %q, want the refusal", name, code, stderr)
+ }
+ if dangling {
+ if _, err := os.Lstat(victim); err == nil {
+ t.Errorf("%s: the dangling link's target was created", name)
+ }
+ } else if got := readOr(t, victim); got != "KEEP" {
+ t.Errorf("%s: the file outside --out now holds %q", name, got)
+ }
+ // Nothing else was written: the other report is absent, and no temp file is left.
+ es, _ := os.ReadDir(out)
+ if len(es) != 1 {
+ t.Errorf("%s: --out holds %d entries, want only the planted link", name, len(es))
+ }
+ }
+ }
+ }
+}
+
+// A destination that is a folder is refused too.
+func TestRouteOracleRefusesAFolderAsAReport(t *testing.T) {
+ plans, mmaps := outFixture(t)
+ out := t.TempDir()
+ if err := os.Mkdir(filepath.Join(out, "corridor.json"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ code, stderr := runOracle(plans, mmaps, out, "walk")
+ if code != exitRouteOracle || !strings.Contains(stderr, "not a regular file") {
+ t.Errorf("exit %d stderr %q", code, stderr)
+ }
+ if _, err := os.Stat(filepath.Join(out, "corridor.txt")); err == nil {
+ t.Error("the other report was written")
+ }
+}
+
+// --out that is a symlink, or has a symlink component, is refused before anything is made or
+// written, and nothing lands in the link's target.
+func TestRouteOracleRefusesASymlinkedOut(t *testing.T) {
+ plans, mmaps := outFixture(t)
+ base := t.TempDir()
+ real := filepath.Join(base, "real")
+ if err := os.Mkdir(real, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ link := filepath.Join(base, "link")
+ if err := os.Symlink(real, link); err != nil {
+ t.Fatal(err)
+ }
+ for _, out := range []string{link, filepath.Join(link, "sub"), filepath.Join(link, "a", "b")} {
+ code, stderr := runOracle(plans, mmaps, out, "walk")
+ if code != exitUsage || !strings.Contains(stderr, "contains a symlink") {
+ t.Errorf("--out %s: exit %d stderr %q, want a usage refusal", out, code, stderr)
+ }
+ }
+ if es, _ := os.ReadDir(real); len(es) != 0 {
+ t.Errorf("the link's target holds %v: something was made through the link", es)
+ }
+ // A real folder still works.
+ if code, stderr := runOracle(plans, mmaps, filepath.Join(base, "fresh", "deep"), "walk"); code != exitOK {
+ t.Errorf("real --out: exit %d stderr %q", code, stderr)
+ }
+}
+
+// The write itself does not follow a link planted after the check: the rename replaces the
+// link and the file it pointed at is untouched.
+func TestWriteThroughTempReplacesALinkWithoutFollowingIt(t *testing.T) {
+ dir := t.TempDir()
+ victim := filepath.Join(t.TempDir(), "victim")
+ if err := os.WriteFile(victim, []byte("KEEP"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(victim, filepath.Join(dir, "r.txt")); err != nil {
+ t.Fatal(err)
+ }
+ root, err := os.OpenRoot(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer root.Close()
+ if err := writeThroughTemp(root, "r.txt", []byte("report")); err != nil {
+ t.Fatal(err)
+ }
+ if got := readOr(t, victim); got != "KEEP" {
+ t.Errorf("the file behind the link holds %q", got)
+ }
+ fi, err := os.Lstat(filepath.Join(dir, "r.txt"))
+ if err != nil || !fi.Mode().IsRegular() || readOr(t, filepath.Join(dir, "r.txt")) != "report" {
+ t.Errorf("r.txt: %v %v", fi, err)
+ }
+ if es, _ := os.ReadDir(dir); len(es) != 1 {
+ t.Errorf("temp file left behind: %v", es)
+ }
+}
diff --git a/cmd/turtle-agent/run_uldaman_script_test.go b/cmd/turtle-agent/run_uldaman_script_test.go
index 4dc1ff3f..357ec11f 100644
--- a/cmd/turtle-agent/run_uldaman_script_test.go
+++ b/cmd/turtle-agent/run_uldaman_script_test.go
@@ -346,3 +346,33 @@ func TestRunUldamanScriptStartAndPulltestParams(t *testing.T) {
}
}
}
+
+// --keep-loot-setting is an observed run's option: it becomes observe.keep_loot_setting, and a driven run refuses it.
+func TestRunUldamanScriptKeepLootSetting(t *testing.T) {
+ bash, err := exec.LookPath("bash")
+ if err != nil {
+ t.Skip("no bash")
+ }
+ out, err := exec.Command(bash, "../../tools/run-uldaman.sh", "--mode", "observe", "--keep-loot-setting", "--print-params").Output()
+ if err != nil {
+ t.Fatalf("%v: %s", err, out)
+ }
+ var p dungeonRunParams
+ dec := json.NewDecoder(bytes.NewReader(out))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&p); err != nil {
+ t.Fatalf("%s: %v", out, err)
+ }
+ if p.Observe == nil || !p.Observe.KeepLootSetting {
+ t.Errorf("observe.keep_loot_setting did not reach the params: %+v", p.Observe)
+ }
+ if remote, err := checkMode(p); !remote || err != nil {
+ t.Fatalf("checkMode %v %v", remote, err)
+ }
+ cmd := exec.Command(bash, "../../tools/run-uldaman.sh", "--keep-loot-setting", "--print-params")
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err == nil || !strings.Contains(stderr.String(), "--keep-loot-setting goes with --mode observe") {
+ t.Errorf("commander mode took --keep-loot-setting: %v %q", err, stderr.String())
+ }
+}
diff --git a/cmd/turtle-agent/select_confirm_test.go b/cmd/turtle-agent/select_confirm_test.go
index 61e458b6..472fe48c 100644
--- a/cmd/turtle-agent/select_confirm_test.go
+++ b/cmd/turtle-agent/select_confirm_test.go
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
+ "fmt"
"strings"
"sync"
"testing"
@@ -58,6 +59,7 @@ func TestGMLineWaitsForTheServerToShowTheSelection(t *testing.T) {
// The server shows a stranger as selected and never changes it.
r := newRig(t)
r.srv.Send(opcode.SMSGUpdateObject, selfBlock(stranger))
+ waitUntil(t, func() bool { g, ok := r.s.ServerSelection(); return ok && g == stranger }) // the field is known: no neutral selection
g := remoteGame{c: &commanderService{cur: func() *session.Session { return r.s }}}
err := g.selectThenGM(context.Background(), lead, ".instance groupunbind all")
if err == nil || !strings.Contains(err.Error(), "did not show") {
@@ -70,6 +72,7 @@ func TestGMLineWaitsForTheServerToShowTheSelection(t *testing.T) {
// The selection lands late (after 150 ms): the line goes out after it.
r = newRig(t)
r.srv.Send(opcode.SMSGUpdateObject, selfBlock(stranger))
+ waitUntil(t, func() bool { g, ok := r.s.ServerSelection(); return ok && g == stranger }) // the field is known: no neutral selection
g = remoteGame{c: &commanderService{cur: func() *session.Session { return r.s }}}
go func() {
time.Sleep(150 * time.Millisecond)
@@ -191,8 +194,7 @@ func TestGMLineRunsOnTheConfirmedSelectionWhenSelectionAndChatAreOnSeparateThrea
func TestGMLineIsNotSentWhenTheConfirmedSelectionChanged(t *testing.T) {
const lead, stranger = packet.GUID(0x23A), packet.GUID(0x4D2)
r := newRig(t)
- r.srv.Send(opcode.SMSGUpdateObject, selfBlock(lead)) // the server shows the lead...
- waitUntil(t, func() bool { g, ok := r.s.ServerSelection(); return ok && g == lead })
+ ownSelection(t, r, lead) // the server shows the lead...
if _, err := r.s.Target(context.Background(), stranger, ""); err != nil { // ...but another request selected the stranger since
t.Fatal(err)
}
@@ -205,22 +207,338 @@ func TestGMLineIsNotSentWhenTheConfirmedSelectionChanged(t *testing.T) {
for _, c := range chats(r, 300*time.Millisecond) {
t.Fatalf("a line was sent: %q", c)
}
- // Selected and shown: the line goes out right after the test's own selection; GMOnConfirmed adds no selection packet of its own.
+ // Selected again, but the server has not shown it: it still shows the lead from before the stranger was selected, and that stale echo
+ // (sent again) does not confirm the new select of the lead (issue #296).
if _, err := r.s.Target(context.Background(), lead, ""); err != nil {
t.Fatal(err)
}
+ r.srv.Send(opcode.SMSGUpdateObject, selfValues(lead))
+ time.Sleep(100 * time.Millisecond)
+ if r.s.SelectionConfirmed(lead) {
+ t.Fatal("a stale echo of the lead confirmed the new select")
+ }
+ if err := r.s.GMOnConfirmed(context.Background(), lead, ".additem 9240"); !errors.Is(err, session.ErrSelectionChanged) {
+ t.Fatalf("stale echo: %v", err)
+ }
+ // The server shows the stranger, then the lead: now the line goes out, and GMOnConfirmed adds no selection packet of its own.
+ r.srv.Send(opcode.SMSGUpdateObject, selfValues(stranger))
+ r.srv.Send(opcode.SMSGUpdateObject, selfValues(lead))
+ waitUntil(t, func() bool { return r.s.SelectionConfirmed(lead) })
if err := r.s.GMOnConfirmed(context.Background(), lead, ".additem 9240"); err != nil {
t.Fatal(err)
}
- var ops []uint16
+ if ops := selOps(r, 300*time.Millisecond); len(ops) != 2 || ops[0] != "sel 0x23a" || ops[1] != "chat" {
+ t.Fatalf("ops %v", ops)
+ }
+}
+
+// selOps are the selections (as "sel 0x..") and chat lines (as "chat") the server received, in order, until nothing came for d.
+func selOps(r *agentRig, d time.Duration) []string {
+ var out []string
for {
- p, err := r.srv.Next(300 * time.Millisecond)
+ p, err := r.srv.Next(d)
if err != nil {
- break
+ return out
}
- ops = append(ops, p.Op)
+ switch p.Op {
+ case opcode.CMSGSetSelection:
+ out = append(out, fmt.Sprintf("sel %#x", uint64(packet.NewReader(p.Body).GUID())))
+ case opcode.CMSGMessagechat:
+ out = append(out, "chat")
+ }
+ }
+}
+
+// The selection-log rule (session.SelectionConfirmed): an echo confirms a select only when it cannot answer an older one.
+func TestSelectionEchoMustAnswerTheSelectItConfirms(t *testing.T) {
+ const lead, stranger = packet.GUID(0x23A), packet.GUID(0x4D2)
+
+ // A fresh echo: the server showed nothing selected, the lead is selected, the echo shows it.
+ r := newRig(t)
+ r.srv.Send(opcode.SMSGUpdateObject, selfBlock(0))
+ waitUntil(t, func() bool { _, ok := r.s.ServerSelection(); return ok })
+ if r.s.SelectionConfirmed(0) {
+ t.Fatal("a create block confirmed a selection the session never sent")
+ }
+ if _, err := r.s.Target(context.Background(), lead, ""); err != nil {
+ t.Fatal(err)
+ }
+ if r.s.SelectionConfirmed(lead) {
+ t.Fatal("confirmed before any echo")
+ }
+ r.srv.Send(opcode.SMSGUpdateObject, selfValues(lead))
+ waitUntil(t, func() bool { return r.s.SelectionConfirmed(lead) })
+
+ // A stale echo: lead, then stranger, then lead again are sent; an echo of the lead may answer the first, with the stranger still
+ // pending, so it confirms nothing; the stranger's echo and then the lead's do.
+ r = newRig(t)
+ r.srv.Send(opcode.SMSGUpdateObject, selfBlock(0))
+ for _, g := range []packet.GUID{lead, stranger, lead} {
+ if _, err := r.s.Target(context.Background(), g, ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ r.srv.Send(opcode.SMSGUpdateObject, selfValues(lead))
+ time.Sleep(100 * time.Millisecond)
+ if r.s.SelectionConfirmed(lead) || r.s.SelectionConfirmed(stranger) {
+ t.Fatal("a stale echo confirmed a selection")
+ }
+ if !r.s.SelectionAmbiguous(lead) {
+ t.Fatal("lead before stranger, unresolved: a new select of the lead is ambiguous")
+ }
+ r.srv.Send(opcode.SMSGUpdateObject, selfValues(stranger))
+ time.Sleep(100 * time.Millisecond)
+ if r.s.SelectionConfirmed(lead) || r.s.SelectionConfirmed(stranger) {
+ t.Fatal("the stranger's echo, with the lead sent after it, confirmed a selection")
+ }
+ r.srv.Send(opcode.SMSGUpdateObject, selfValues(lead))
+ waitUntil(t, func() bool { return r.s.SelectionConfirmed(lead) })
+ if r.s.SelectionAmbiguous(lead) {
+ t.Fatal("ambiguous after the log was resolved")
+ }
+}
+
+// The core sends no update when a selection does not change the target field: selecting the unit already confirmed is confirmed at once,
+// with no echo, and the line goes out without a wait.
+func TestGMLineOnTheUnchangedSelectionNeedsNoEcho(t *testing.T) {
+ const lead = packet.GUID(0x23A)
+ r := newRig(t)
+ ownSelection(t, r, lead)
+ g := remoteGame{c: &commanderService{cur: func() *session.Session { return r.s }}}
+ start := time.Now()
+ for i := 0; i < 3; i++ {
+ if err := g.selectThenGM(context.Background(), lead, ".levelup 2"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if d := time.Since(start); d > time.Second {
+ t.Fatalf("three lines on the unchanged selection took %s", d)
+ }
+ if ops := selOps(r, 300*time.Millisecond); len(ops) != 6 || ops[1] != "chat" || ops[3] != "chat" || ops[5] != "chat" {
+ t.Fatalf("ops %v", ops)
+ }
+}
+
+// A core that applies several selections in one update echoes only the last value, and only when it changed. With the lead confirmed and a
+// stranger's select pending, an echo of a new select of the lead could not be told from the old one: selectConfirmed selects none first,
+// waits for that echo, then selects the lead, and the line runs on the lead.
+func TestGMLineSelectsNeutralFirstWhenAnEchoWouldBeAmbiguous(t *testing.T) {
+ const lead, stranger = packet.GUID(0x23A), packet.GUID(0x4D2)
+ r := newRig(t)
+ ownSelection(t, r, lead)
+ if _, err := r.s.Target(context.Background(), stranger, ""); err != nil { // pending: the server has not shown it
+ t.Fatal(err)
+ }
+ if ops := selOps(r, 100*time.Millisecond); len(ops) != 1 || ops[0] != "sel 0x4d2" {
+ t.Fatalf("ops %v", ops)
+ }
+ g := remoteGame{c: &commanderService{cur: func() *session.Session { return r.s }}}
+ done := make(chan error, 1)
+ go func() { done <- g.selectThenGM(context.Background(), lead, ".levelup 2") }()
+ inForce, shown := lead, lead
+ var ops []string
+ for len(ops) < 3 {
+ p, err := r.srv.Next(2 * time.Second)
+ if err != nil {
+ t.Fatalf("ops %v: %v", ops, err)
+ }
+ switch p.Op {
+ case opcode.CMSGSetSelection:
+ // The stranger's select and this one are applied in one update; the echo shows the last value, if it changed.
+ inForce = packet.NewReader(p.Body).GUID()
+ ops = append(ops, fmt.Sprintf("sel %#x", uint64(inForce)))
+ if inForce != shown {
+ shown = inForce
+ r.srv.Send(opcode.SMSGUpdateObject, selfValues(inForce))
+ }
+ case opcode.CMSGMessagechat:
+ ops = append(ops, fmt.Sprintf("chat on %#x", uint64(inForce)))
+ }
+ }
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ if ops[0] != "sel 0x0" || ops[1] != "sel 0x23a" || ops[2] != "chat on 0x23a" {
+ t.Fatalf("ops %v", ops)
+ }
+}
+
+// ownSelection makes g the confirmed selection the way a run does: the create block (which says nothing about the selection), the
+// session's own select of g and the server's echo of it. The select packet is read off the wire.
+func ownSelection(t *testing.T, r *agentRig, g packet.GUID) {
+ t.Helper()
+ r.srv.Send(opcode.SMSGUpdateObject, selfBlock(0x777))
+ waitUntil(t, func() bool { _, ok := r.s.ServerSelection(); return ok })
+ var err error
+ if g == 0 {
+ err = r.s.ClearTarget(context.Background())
+ } else {
+ _, err = r.s.Target(context.Background(), g, "")
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.srv.Send(opcode.SMSGUpdateObject, selfValues(g))
+ waitUntil(t, func() bool { return r.s.SelectionConfirmed(g) })
+ if ops := selOps(r, 100*time.Millisecond); len(ops) != 1 {
+ t.Fatalf("ops %v", ops)
+ }
+}
+
+// coreLike plays the core for the selection: it applies each CMSG_SET_SELECTION as it reads it and echoes the target field when it
+// changed; rewrite changes the FIELD alone (combat, a stun), not the selection; a chat line is recorded with the selection in force.
+type coreLike struct {
+ r *agentRig
+ sel packet.GUID // what GM commands act on
+ field packet.GUID // UNIT_FIELD_TARGET as the client last saw it
+ ops []string
+}
+
+// run reads until want ops (selections and chat lines) were seen.
+func (c *coreLike) run(t *testing.T, want int) {
+ t.Helper()
+ for len(c.ops) < want {
+ p, err := c.r.srv.Next(2 * time.Second)
+ if err != nil {
+ t.Fatalf("ops %v: %v", c.ops, err)
+ }
+ switch p.Op {
+ case opcode.CMSGSetSelection:
+ c.sel = packet.NewReader(p.Body).GUID()
+ c.ops = append(c.ops, fmt.Sprintf("sel %#x", uint64(c.sel)))
+ if c.sel != c.field {
+ c.field = c.sel
+ c.r.srv.Send(opcode.SMSGUpdateObject, selfValues(c.field))
+ }
+ case opcode.CMSGMessagechat:
+ c.ops = append(c.ops, fmt.Sprintf("chat on %#x", uint64(c.sel)))
+ }
+ }
+}
+
+func (c *coreLike) rewrite(t *testing.T, field packet.GUID) {
+ t.Helper()
+ c.field = field
+ c.r.srv.Send(opcode.SMSGUpdateObject, selfValues(field))
+ waitUntil(t, func() bool { g, _ := c.r.s.ServerSelection(); return g == field })
+}
+
+// Unit::AttackStop (the mob the agent fought and had selected died) writes 0 into the target field and leaves the selection on the mob. The
+// old dedupe took that 0 for a cleared selection, so `.respawn` went out on the selected mob before the clear landed. Now the clear goes
+// through a neutral selection (the agent itself: the field already shows 0, so a clear would get no echo), and the line runs with nothing
+// selected.
+func TestRespawnAfterAttackStopWaitsForTheRealClear(t *testing.T) {
+ const mob = packet.GUID(0xF130000000000077)
+ r := newRig(t)
+ ownSelection(t, r, mob)
+ c := &coreLike{r: r, sel: mob, field: mob}
+ c.rewrite(t, 0) // AttackStop
+ if r.s.SelectionConfirmed(0) {
+ t.Fatal("the combat rewrite confirmed a cleared selection")
+ }
+ done := make(chan error, 1)
+ go func() {
+ done <- remoteGame{c: &commanderService{cur: func() *session.Session { return r.s }}}.Respawn(context.Background())
+ }()
+ c.run(t, 3)
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"sel 0x2a", "sel 0x0", "chat on 0x0"}; strings.Join(c.ops, ",") != strings.Join(want, ",") {
+ t.Fatalf("ops %v, want %v", c.ops, want)
+ }
+}
+
+// Unit::Attack writes the victim into the field without selecting it: with nothing selected, an attack on the lead shows the lead. A line
+// for the lead must not go out on that; it selects the lead through a neutral selection (the field already shows the lead, so a select of
+// it would get no echo) and runs on the lead.
+func TestGMLineAfterAnAttackOnThePlayerSelectsItForReal(t *testing.T) {
+ const lead = packet.GUID(0x23A)
+ r := newRig(t)
+ ownSelection(t, r, 0)
+ c := &coreLike{r: r, sel: 0, field: 0}
+ c.rewrite(t, lead) // Attack
+ if r.s.SelectionConfirmed(lead) {
+ t.Fatal("the attack confirmed the victim as the selection")
+ }
+ g := remoteGame{c: &commanderService{cur: func() *session.Session { return r.s }}}
+ done := make(chan error, 1)
+ go func() { done <- g.selectThenGM(context.Background(), lead, ".levelup 2") }()
+ c.run(t, 3)
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"sel 0x0", "sel 0x23a", "chat on 0x23a"}; strings.Join(c.ops, ",") != strings.Join(want, ",") {
+ t.Fatalf("ops %v, want %v", c.ops, want)
+ }
+}
+
+// selfFlags is a values update of the agent's own UNIT_FIELD_FLAGS (in combat, stunned).
+func selfFlags(flags uint32) []byte {
+ w := objtest.Header(1).U8(uint8(objects.BlockValues)).PackedGUID(0x2A)
+ objtest.Values(w, 192, map[uint16]uint32{objects.UnitFlags: flags})
+ return w.Bytes()
+}
+
+// Codex's case (#296 review): the lead X is confirmed, a select of G is pending, the agent is in combat and the core rewrites the target
+// field to G (Unit::Attack). That must not confirm G: the selection the GM line acts on can still be X. Nothing is selected or sent until
+// combat ended (and SelectionCombatSettle passed); then G is selected again, through a neutral selection because its old send was fenced
+// off, and the line goes out after the echo of that new send.
+func TestGMLineWaitsOutCombatWhenTheFieldIsRewrittenToThePendingUnit(t *testing.T) {
+ old := session.SelectionCombatSettle
+ session.SelectionCombatSettle = 100 * time.Millisecond
+ defer func() { session.SelectionCombatSettle = old }()
+ const x, g = packet.GUID(0x23A), packet.GUID(0x4D2)
+ r := newRig(t)
+ ownSelection(t, r, x)
+ if _, err := r.s.Target(context.Background(), g, ""); err != nil { // pending: the server has not applied it
+ t.Fatal(err)
+ }
+ if ops := selOps(r, 100*time.Millisecond); len(ops) != 1 || ops[0] != "sel 0x4d2" {
+ t.Fatalf("ops %v", ops)
+ }
+ r.srv.Send(opcode.SMSGUpdateObject, selfFlags(objects.UnitFlagInCombat))
+ r.srv.Send(opcode.SMSGUpdateObject, selfValues(g)) // the attack's rewrite, exactly the pending unit
+ waitUntil(t, func() bool { sel, _ := r.s.ServerSelection(); return sel == g })
+ gm := remoteGame{c: &commanderService{cur: func() *session.Session { return r.s }}}
+ done := make(chan error, 1)
+ go func() { done <- gm.selectThenGM(context.Background(), g, ".levelup 2") }()
+ if ops := selOps(r, 400*time.Millisecond); len(ops) != 0 {
+ t.Fatalf("sent while in combat: %v", ops)
+ }
+ if r.s.SelectionConfirmed(g) {
+ t.Fatal("the combat rewrite confirmed the pending unit")
+ }
+ cooled := time.Now()
+ c := &coreLike{r: r, sel: g, field: g}
+ r.srv.Send(opcode.SMSGUpdateObject, selfFlags(0))
+ c.run(t, 3)
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ if d := time.Since(cooled); d < 100*time.Millisecond {
+ t.Fatalf("the line went out %s after combat, inside the settle", d)
+ }
+ if want := []string{"sel 0x0", "sel 0x4d2", "chat on 0x4d2"}; strings.Join(c.ops, ",") != strings.Join(want, ",") {
+ t.Fatalf("ops %v, want %v", c.ops, want)
+ }
+}
+
+// A fight that outlasts the wait: an error, and nothing selected or sent.
+func TestGMLineInCombatPastTheWaitSendsNothing(t *testing.T) {
+ old := gmSelectConfirmWait
+ gmSelectConfirmWait = 300 * time.Millisecond
+ defer func() { gmSelectConfirmWait = old }()
+ const lead = packet.GUID(0x23A)
+ r := newRig(t)
+ ownSelection(t, r, lead)
+ r.srv.Send(opcode.SMSGUpdateObject, selfFlags(objects.UnitFlagStunned))
+ waitUntil(t, func() bool { return !r.s.SelectionConfirmed(lead) })
+ gm := remoteGame{c: &commanderService{cur: func() *session.Session { return r.s }}}
+ if err := gm.selectThenGM(context.Background(), lead, ".levelup 2"); err == nil || !strings.Contains(err.Error(), "in combat or stunned") {
+ t.Fatalf("%v", err)
}
- if n := len(ops); n != 2 || ops[0] != opcode.CMSGSetSelection || ops[1] != opcode.CMSGMessagechat {
+ if ops := selOps(r, 200*time.Millisecond); len(ops) != 0 {
t.Fatalf("ops %v", ops)
}
}
diff --git a/cmd/turtle-agent/trace_cmd.go b/cmd/turtle-agent/trace_cmd.go
index 1384b728..1988f81c 100644
--- a/cmd/turtle-agent/trace_cmd.go
+++ b/cmd/turtle-agent/trace_cmd.go
@@ -33,7 +33,7 @@ func traceScoreCmd(args []string, stdout, stderr io.Writer) int {
asJSON := fs.Bool("json", false, "print the verdicts as JSON (one object per trace)")
summary := fs.Bool("summary", false, "write .summary.json beside each trace")
var sets thresholdFlags
- fs.Var(&sets, "set", "change a threshold: check=value (rest_share, stale_party, walk_stops, threat_off_tank, order_refusals, feed_stalls)")
+ fs.Var(&sets, "set", "change a threshold: check=value (rest_share, stale_party, walk_stops, threat_off_tank, order_refusals, feed_stalls, stuck, facing, and the spell warnings idle_share, interrupt_cover, taunt_latency, cc_hold, pet_threat)")
if err := fs.Parse(args); err != nil {
fmt.Fprintln(stderr, traceScoreUsage)
return exitUsage
@@ -77,14 +77,18 @@ func traceScoreCmd(args []string, stdout, stderr io.Writer) int {
return exitUsage
}
}
+ report := tracecheck.Spells(f, cfg)
if *asJSON {
- _ = json.NewEncoder(stdout).Encode(map[string]any{"run": f.Header.Run, "file": path, "verdict": verdict, "failed": failed, "checks": checks})
+ _ = json.NewEncoder(stdout).Encode(map[string]any{"run": f.Header.Run, "file": path, "verdict": verdict, "failed": failed, "checks": checks, "spells": report})
continue
}
fmt.Fprintf(stdout, "%s %s %s %s %.0f s %s\n", f.Header.Run, f.Header.Source, f.Header.Mode, f.Header.Plan, f.Seconds(), strings.ToUpper(verdict))
for _, c := range checks {
fmt.Fprintf(stdout, " %-4s %-16s %s\n", strings.ToUpper(c.Verdict), c.ID, describeCheck(c))
}
+ for _, l := range report.Lines() {
+ fmt.Fprintln(stdout, l)
+ }
}
return code
}
@@ -93,5 +97,8 @@ func describeCheck(c trace.Check) string {
if c.Verdict == trace.NA {
return "n/a: " + c.Detail
}
+ if c.ID == tracecheck.IDSpells { // report only: the detail is the line, the section below has the tables
+ return "report: " + c.Detail
+ }
return fmt.Sprintf("%g %s (limit %g): %s", c.Value, c.Unit, c.Threshold, c.Detail)
}
diff --git a/cmd/turtle-agent/trace_methods_test.go b/cmd/turtle-agent/trace_methods_test.go
index 84bbe32b..881c5cae 100644
--- a/cmd/turtle-agent/trace_methods_test.go
+++ b/cmd/turtle-agent/trace_methods_test.go
@@ -202,7 +202,7 @@ func TestBrainSessionHasARunIDAndATrace(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if f.Header.Source != "brain" || f.Header.Run != "r-brain-1" || f.End == nil || f.End.Reason != "stopped" || len(f.Oracles) != 10 || len(f.Party) != 1 || !f.Party[0].Lead {
+ if f.Header.Source != "brain" || f.Header.Run != "r-brain-1" || f.End == nil || f.End.Reason != "stopped" || len(f.Oracles) != 11 || len(f.Party) != 1 || !f.Party[0].Lead {
t.Fatalf("%+v", f.Header)
}
var states []string
@@ -273,7 +273,7 @@ func TestTraceScoreCommand(t *testing.T) {
Failed []string
Checks []trace.Check
}
- if err := json.Unmarshal([]byte(lines[1]), &v); err != nil || v.Run != "r-dead" || v.Verdict != "fail" || len(v.Failed) != 1 || v.Failed[0] != "deaths" || len(v.Checks) != 10 {
+ if err := json.Unmarshal([]byte(lines[1]), &v); err != nil || v.Run != "r-dead" || v.Verdict != "fail" || len(v.Failed) != 1 || v.Failed[0] != "deaths" || len(v.Checks) != 11 {
t.Fatalf("%v %+v", err, v)
}
// --summary writes the summary beside the trace.
@@ -282,7 +282,7 @@ func TestTraceScoreCommand(t *testing.T) {
}
b, err := os.ReadFile(filepath.Join(dir, "r-clean.summary.json"))
var s trace.Summary
- if err != nil || json.Unmarshal(b, &s) != nil || s.Run != "r-clean" || s.Status != "complete" || s.Verdict != "pass" || len(s.Oracles) != 10 || s.Bytes == 0 || s.Src != "agent" || s.Scorer == "" {
+ if err != nil || json.Unmarshal(b, &s) != nil || s.Run != "r-clean" || s.Status != "complete" || s.Verdict != "pass" || len(s.Oracles) != 11 || s.Bytes == 0 || s.Src != "agent" || s.Scorer == "" {
t.Fatalf("%v %s", err, b)
}
// Usage errors.
@@ -310,8 +310,55 @@ func TestBrainTraceFinishEndsASessionThatStoppedEarly(t *testing.T) {
t.Fatal("still listed as live")
}
f, err := trace.Read(filepath.Join(dir, "r-early.jsonl"))
- if err != nil || f.End == nil || f.End.Reason != "stopped" || len(f.Oracles) != 10 || f.Party[0].G != "0x2a" {
+ if err != nil || f.End == nil || f.End.Reason != "stopped" || len(f.Oracles) != 11 || f.Party[0].G != "0x2a" {
t.Fatalf("%v %+v", err, f)
}
bt.finish("r-early") // a second call is harmless
}
+
+// trace-score prints the spells section under the checks and puts the report in --json; a warning in it never makes the run fail.
+func TestTraceScorePrintsTheSpellsSection(t *testing.T) {
+ dir := t.TempDir()
+ w, err := trace.Open(trace.Options{Dir: dir, RunID: "r-spells"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ w.Header(trace.Header{Source: "dungeon_run", Mode: "remote", Plan: "rfc", Start: time.Unix(1_000_000, 0).UTC(), StartUnix: 1_000_000, IntervalMS: 1000,
+ Party: []trace.Member{{G: "0x11", Name: "Tanky"}, {G: "0x12", Name: "Healy"}}})
+ for s := int64(0); s < 40; s++ {
+ res, why := "O", ""
+ if s >= 10 && s < 30 { // twenty idle seconds with a mob on the tank
+ res, why = "I", "oom"
+ }
+ w.Frame(trace.Frame{Envelope: trace.Envelope{T: s * 1000}, Via: "feed",
+ Bots: []trace.Bot{{G: "0x11", Alive: true, Combat: true, State: "c", HP: 9, MaxHP: 10, Power: 50, MaxPow: 100, PType: 1, Action: "sunder armor", Res: "O"},
+ {G: "0x12", Alive: true, Combat: true, State: "c", HP: 9, MaxHP: 10, Power: 500, MaxPow: 1000, Action: "heal on party", Res: res, Why: why}},
+ Mobs: []trace.Mob{{G: "0xf1", HPPct: 80, Combat: true, Target: "0x11"}}})
+ }
+ w.End(trace.End{Envelope: trace.Envelope{T: 40_000}, Ended: time.Unix(1_000_040, 0).UTC(), Reason: "done", Seconds: 40})
+ w.Close()
+ var out, errOut bytes.Buffer
+ code := run(context.Background(), []string{"trace-score", "--set", "rest_share=100", w.Path()}, &out, &errOut)
+ text := out.String()
+ for _, want := range []string{"PASS spells", "Spells (report only", "priest Healy", "idle share: 50.0 %", "WARN idle_share", "why not: oom 20"} {
+ if !strings.Contains(text, want) {
+ t.Errorf("missing %q in\n%s", want, text)
+ }
+ }
+ if code == exitTraceFail && strings.Contains(text, "FAIL spells") {
+ t.Fatalf("the spells check failed the run:\n%s", text)
+ }
+ out.Reset()
+ run(context.Background(), []string{"trace-score", "--json", w.Path()}, &out, &errOut)
+ var v struct {
+ Spells struct {
+ Idle struct {
+ Pct float64 `json:"pct"`
+ } `json:"idle"`
+ Warnings []string `json:"warnings"`
+ } `json:"spells"`
+ }
+ if err := json.Unmarshal(out.Bytes(), &v); err != nil || v.Spells.Idle.Pct != 50 || len(v.Spells.Warnings) != 1 {
+ t.Fatalf("%v %s", err, out.String())
+ }
+}
diff --git a/docs/DUNGEON-PROGRESS.md b/docs/DUNGEON-PROGRESS.md
index 5837a78d..a8b0bcaf 100644
--- a/docs/DUNGEON-PROGRESS.md
+++ b/docs/DUNGEON-PROGRESS.md
@@ -33,8 +33,8 @@ Legend: ✓ done, — not done. PR numbers are in the `pjerra/turtle-agent` repo
| The Stormwind Stockade | 23-28 | [zone 717](https://www.wowhead.com/classic/zone=717) (22-30) | `dungeons/stormwind_stockade.json` (5 legs) | — | — | — | — | Not started (draft plan only) |
| Razorfen Kraul | 26-31 | [zone 491](https://www.wowhead.com/classic/zone=491) (24-40) | `dungeons/razorfen_kraul.json` (7 legs) | — | — | — | — | Not started (draft plan only) |
| Gnomeregan | 27-33 | [zone 721](https://www.wowhead.com/classic/zone=721) (24-40) | `dungeons/gnomeregan.json` (5 legs) | — | — | — | — | Not started (draft plan only) |
-| Scarlet Monastery: Graveyard | 30-35 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_graveyard.json` (3 legs) | — | — | — | — | Not started (draft plan only) |
-| Scarlet Monastery: Library | 33-38 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_library.json` (2 legs) | — | — | — | — | Not started (draft plan only) |
+| Scarlet Monastery: Graveyard | 30-35 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_graveyard.json` (4 legs) | — | — | — | — | Not started (draft plan only) |
+| Scarlet Monastery: Library | 33-38 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_library.json` (3 legs) | — | — | — | — | Not started (draft plan only) |
| Scarlet Monastery: Armory | 35-40 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_armory.json` (2 legs) | — | — | — | — | Not started (draft plan only) |
| Razorfen Downs | 35-40 | [zone 722](https://www.wowhead.com/classic/zone=722) (33-47) | `dungeons/razorfen_downs.json` (4 legs) | — | — | — | — | Not started (draft plan only) |
| Scarlet Monastery: Cathedral | 37-42 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_cathedral.json` (3 legs) | — | — | — | — | Not started (draft plan only) |
diff --git a/docs/LIVE-CHECKS.md b/docs/LIVE-CHECKS.md
index f941fb1b..7b759fb7 100644
--- a/docs/LIVE-CHECKS.md
+++ b/docs/LIVE-CHECKS.md
@@ -595,7 +595,7 @@ Run again with a level 40 character of each class that has its spells (`.learn a
## Run traces and checks (issue #73, data-collection step 1)
-The trace writer, the recorder and the ten checks run only against the fake game, the fake feed and hand-made traces (README "Run traces
+The trace writer, the recorder and the eleven checks run only against the fake game, the fake feed and hand-made traces (README "Run traces
and checks"). Nothing below has run live. Use the agent slot only, `trace_meta` to note the build, and keep the traces: they are the data.
1. **A short group run.** `dungeon_run {"name":"uldaman","gm":false,"run_id":"live-1","trace_meta":{"tb_sha":"","build":""}}` with a
@@ -1055,3 +1055,6 @@ README "Observe mode" and "Exporting plans for the TortoiseBots run leader". Nee
6. **The module pauses itself.** At 3 dead the status is `paused` with the note; the watch waits `paused_wait_s` (10 min) and then stops the run with that note as the failure. Is 10 minutes the right wait, and does a person's `.bot run resume` on the server end the wait (the next status line is `running`)?
7. **Legs and the dashboard card.** The report has one leg entry per leg name the module sent (`legs[]` with seconds), and the card of `turtle-agent dashboard` reads "Run leader: · n/m · ". Does the `phase` word `pull` cover both the approach and the fight well enough for the card?
8. **`ritual` steps.** The run leader pauses on Uldaman's two `ritual` steps (not run yet): the observed run ends `failed` after `paused_wait_s` unless a person does the ritual by hand (`.bot cmd ritual`), then `.bot run skip` and `resume`. Record how a run through the altars goes and whether `dungeon_skip` is enough.
+9. **Partial start (#248).** From a party standing inside the instance, `dungeon_run {"mode":"observe", "from_leg": 10, ...}` must send `.bot run start wailing_caverns from leg serpentis` (a one-word, unique leg name; else `from 10`-style number) and the run leader's first status line must show the leg's first step (Wailing Caverns leg 10: step 207 of 226; `from 207` was the hand-started run of 2026-10-20). With `from_pull_group`, check that the server's listed step count matched the agent's rebuilt rows (the report's "starts partway" note names the step): when it did not, the probe must refuse before hiring and name `observe.from_step`. Then check the start is proven: the start message's step and leg are compared with the request (a reply at another step for `from_step`/`from_pull_group`, another leg, or a planted deployment of another plan version must stop the run and refuse the watch — only the module's documented "moved back to the step that starts the event" may differ, and is noted); an ACK-only start (no message) is proven by `run status` or, at another step, the run is stopped and the watch refused; a busy run at another step is not adopted. Check the party-placed start: a start elsewhere must pause/refuse as the module's `not-in-instance`/`party-scattered`; a lead bot more than 80 yd from the step's point, a step whose point is not known and a start without the lead's position are refused before `run start` (`observe.allow_far_start` turns each into a note).
+10. **Server trace (#249).** With `run_meta.server_trace: true` the start must go out and be acknowledged: the report has no "server trace state unknown", `.bot trace status` shows the run's id while it runs, and `.bot trace stop` ends it at the end (the server's summary joins the agent's trace on the run id). Before this fix the agent's own CheckCommand refused the whole `trace` group.
+11. **No maintenance at the observe start (#253).** With `prepare.party "hire"` (hires autogeared a moment before), the preflight must show `maintenance: "not sent (observe mode: observe.maintenance is off)"` for every bot, the hire check must not send `.bot maintenance` to a reused hire or one short of drinks or ammo (its warning says so), and no hire must leave the instance's map: the run leader must not pause with "the lead bot is not on the plan's map". `observe.maintenance: true` brings the `.bot maintenance` lines back in both places.
diff --git a/docs/PLANS.md b/docs/PLANS.md
index ce4d7a17..4407d104 100644
--- a/docs/PLANS.md
+++ b/docs/PLANS.md
@@ -42,16 +42,16 @@ positions are the creatures' own, so one up to 4 yd off the floor or 2.5 yd to t
| 47 | `razorfen_kraul`: Razorfen Kraul | 26-31 | 7 | draft | yes | Agathelos' Ward (GO_AGATHELOS_WARD) is opened by the keep script before Agathelos the Raging; Willix the Importer escort (optional) |
| 816 | `dragonmaw_retreat`: Dragonmaw Retreat | 26-31 | 5 | draft | yes | none known |
| 90 | `gnomeregan`: Gnomeregan | 27-33 | 5 | draft | yes | the gnome-face doors and their six buttons (GO_GNOME_FACE_1-6, GO_BUTTON_1-6) and the bomb faces before Thermaplugg; Explosive Charges (GO_EXPLOSIVE_CHARGE) to open the north and south cave-ins (GO_CAVE_IN_NORTH/SOUTH) on the way in; Grubbis (7361) has no spawn row |
-| 189 | `scarlet_graveyard`: Scarlet Monastery: Graveyard | 30-35 | 3 | draft | yes | none known |
+| 189 | `scarlet_graveyard`: Scarlet Monastery: Graveyard | 30-35 | 4 | draft | yes | none known |
| 802 | `crescent_grove`: Crescent Grove | 31-36 | 5 | draft | yes | Keeper Ranathos carries npc_flags (questgiver): a gossip or dialogue starts his fight |
-| 189 | `scarlet_library`: Scarlet Monastery: Library | 33-38 | 2 | draft | yes | Houndmaster Loksey's hounds (SPELL_SUMMONSCARLETHOUND) |
+| 189 | `scarlet_library`: Scarlet Monastery: Library | 33-38 | 3 | draft | yes | Houndmaster Loksey's hounds (SPELL_SUMMONSCARLETHOUND); Brother Wystan (61983) is a Turtle addition: EventAI, spell list 180317 (Fireball 8402); he patrols (movement_type 2), the boss step meets him on his path |
| 818 | `stormwrought_ruins`: Stormwrought Ruins | 33-38 | 11 | draft | yes | secret door and four switches (GO_SECRET_DOOR_GUID, GO_SECRET_SWITCH_1-4), Nether altar (GO_NETHER_ALTAR_GUID); Librarian Theodorus' elemental (SPELL_SUMMON_THEODORUS_ELEMENTAL), volatile fungus (GO_VOLATILE_FUNGUS) |
| 189 | `scarlet_armory`: Scarlet Monastery: Armory | 35-40 | 2 | draft | yes | Herod's door (GO_HEROD_DOOR) opens through the armory script |
| 129 | `razorfen_downs`: Razorfen Downs | 35-40 | 4 | draft | yes | Tuten'kash (7355) has no spawn row: the Gong (GO_GONG) summons him; Belnistrasz idol-room ritual (GO_BELNISTRASZ_BRAZIER, GO_IDOL_*_FIRE) summons Plaguemaw the Rotting; escort quest, optional |
| 189 | `scarlet_cathedral`: Scarlet Monastery: Cathedral | 37-42 | 3 | draft | yes | chapel door (GO_CHAPPEL_DOOR) before Mograine and Whitemane; Mograine and Whitemane are one event: Whitemane resurrects Mograine |
| 70 | `uldaman`: Uldaman | 41-45 | 14 | live-checked | yes | Keystone (needs the Staff of Prehistoria, made from the Gni'kiv Medallion and the Shaft of Tsol) opens the Seal of Khaz'Mul for Ironaya; Altar of the Keepers and Altar of Archaedas: ritual of three bots |
| 815 | `gilneas_city`: Gilneas City | 42-47 | 7 | draft | yes | Harlow family chest (GO_HARLOW_FAMILY_CHEST) after the Regent-Lord and Regent-Lady; Genn Greymane (61418) is script-driven (genn_greymane): his role (final fight or event) is unverified |
-| 209 | `zulfarrak`: Zul'Farrak | 44-48 | 18 | live-checked | yes | Zum'rah: area trigger 962 makes him hostile; stairs event with Sergeant Bly's crew, Nekrum and Sezz'ziz; Weegli's gossip (or Bly's) has him blow the End Door for Ukorz and Ruuzlu; Gahz'rilla: the optional gahzrilla leg (Gong with the Mallet of Zul'Farrak, 9240; skipped without it) |
+| 209 | `zulfarrak`: Zul'Farrak | 44-48 | 18 | live-checked | yes | Zum'rah: area trigger 962 makes him hostile; stairs event with Sergeant Bly's crew, Nekrum and Sezz'ziz (its phase is instance data 1 = 7; the plan waits on Nekrum seen, no phase step yet); Weegli's gossip (or Bly's) has him blow the End Door for Ukorz and Ruuzlu; Gahz'rilla: the optional gahzrilla leg (Gong with the Mallet of Zul'Farrak, 9240; it drops from Qiaga the Keeper 7996 outside the instance and 9241 must be used to make 9240, so no bot run can obtain it; skipped without give_items) |
| 349 | `maraudon_purple`: Maraudon: Purple | 44-49 | 6 | draft | yes | Celebras the Cursed's Scepter of Celebras event is optional; Vylestem vines (GO_VYLESTEM_VINE, GO_HEALED_CELEBRIAN_VINE) round Celebras' room |
| 349 | `maraudon_orange`: Maraudon: Orange | 45-50 | 6 | draft | yes | Celebras the Cursed's Scepter of Celebras event is optional and not on this route; Princess Theradras: the party enters her cave by the inner passage (join of the orange and purple routes unverified) |
| 109 | `sunken_temple`: The Temple of Atal'Hakkar | 47-52 | 6 | draft | yes | six Atal'ai statues (GO_ATALAI_STATUE_1-6) lit in order summon Atal'alarion; the six troll mini-bosses (5712-5717) are the statues' guards; four Eternal Flames (GO_ETERNAL_FLAME_1-4) summon the Avatar of Hakkar (8443, no spawn row); Jammal'an's barrier (GO_JAMMALAN_BARRIER) and the Hakkar doors (GO_HAKKAR_DOOR_1-2); Shade of Eranikus needs the Essence of Eranikus path and the Altar of Hakkar |
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/dungeons/scarlet_graveyard.json b/dungeons/scarlet_graveyard.json
index e83397af..5c74261b 100644
--- a/dungeons/scarlet_graveyard.json
+++ b/dungeons/scarlet_graveyard.json
@@ -25,7 +25,8 @@
"notes": [
"DRAFT: a skeleton from the world database export of map 189 (tools/dungeongen, spec tools/dungeongen/plans.json). The boss order is the usual clear order, the packs are the spawn clusters nearest each boss; walls, doors, stairs and events are unknown to it. Not mapped, not live-checked: do the GM-invisible mapping walk and correct the route before the first run (AUTO-IMPROVE step 0); dungeon_run refuses it without allow_draft.",
"A wait_for on a door a lever or key opens needs timeout_s 60 (the default): a linked door can open 30 s after the use.",
- "One of four wings of map 189; the packs are the ones inside the wing's box."
+ "One of four wings of map 189; the packs are the ones inside the wing's box.",
+ "Duke Dreadmoore (61972) is a Turtle addition in the crypt level (z -3.7): script npc_duke_dreadmoore sets him to 20 % health on reset and yells on aggro and death; his crypt pack is needs_walk until the mapping walk"
],
"legs": [
{
@@ -117,35 +118,40 @@
]
},
{
- "entries": [4283, 4075],
+ "entries": [6547],
"positions": [
- {"x": 1842.88, "y": 1132.93, "z": -3.69593},
- {"x": 1851.36, "y": 1132.14, "z": -3.69674}
+ {"x": 1841.81, "y": 1144.84, "z": -2.65429}
]
},
{
- "entries": [6547],
+ "entries": [4293, 4293],
"positions": [
- {"x": 1841.81, "y": 1144.84, "z": -2.65429}
+ {"x": 1807.84, "y": 1165.98, "z": 6.90374},
+ {"x": 1807.48, "y": 1170.39, "z": 6.90373}
]
},
{
- "entries": [6547, 4075, 6547, 4306, 6547, 4306, 4293, 4293],
+ "entries": [4075, 4293],
"positions": [
- {"x": 1843.91, "y": 1161.27, "z": -3.20069},
- {"x": 1855.6, "y": 1154.76, "z": -3.69664},
- {"x": 1858.79, "y": 1146.48, "z": -2.60957},
- {"x": 1860.63, "y": 1146.78, "z": -3.69654},
- {"x": 1852.55, "y": 1160.99, "z": -2.59734},
- {"x": 1853.09, "y": 1162.9, "z": -3.69651},
- {"x": 1860.12, "y": 1162.95, "z": -3.69654},
- {"x": 1863.07, "y": 1162.91, "z": -3.69654}
+ {"x": 1782.75, "y": 1121.98, "z": 7.57362},
+ {"x": 1784.35, "y": 1118.79, "z": 7.49035}
]
- },
+ }
+ ],
+ "boss": {
+ "entry": 3983,
+ "position": {"x": 1786.55, "y": 1124.44, "z": 7.57362}
+ }
+ },
+ {
+ "name": "duke_dreadmoore",
+ "to": {"x": 1842.88, "y": 1132.93, "z": -3.69593},
+ "pull_groups": [
{
- "entries": [61971],
+ "entries": [4283, 4075],
"positions": [
- {"x": 1871.03, "y": 1154.02, "z": -3.69666}
+ {"x": 1842.88, "y": 1132.93, "z": -3.69593},
+ {"x": 1851.36, "y": 1132.14, "z": -3.69674}
]
},
{
@@ -155,7 +161,7 @@
]
},
{
- "entries": [4293, 6547, 4306, 61971, 4306, 4293, 61971, 4075, 4306, 61971, 6547, 6547, 4306, 4306, 6547, 4306, 6547, 4293, 6547, 4306, 4293, 61971, 4306, 6547, 4075, 4306, 4293, 61971, 61971, 61971, 61972, 4075, 6547, 4306, 61971, 4075, 61971],
+ "entries": [4293, 6547, 4306, 61971, 4306, 4293, 61971, 4075, 4306, 61971, 6547, 6547, 4306, 4306, 6547, 4306, 6547, 4293, 6547, 4306, 4293, 61971, 4306, 6547, 4075, 4306, 4293, 61971, 61971, 61971, 4075, 6547, 4306, 61971, 4075, 61971],
"positions": [
{"x": 1875.51, "y": 1119.35, "z": -3.69628},
{"x": 1876.82, "y": 1121.16, "z": -2.60685},
@@ -187,7 +193,6 @@
{"x": 1872.53, "y": 1085.2, "z": -3.69665},
{"x": 1873.45, "y": 1156.85, "z": -3.69652},
{"x": 1850.61, "y": 1085.48, "z": -3.6965},
- {"x": 1881.82, "y": 1087.25, "z": -3.6965},
{"x": 1853.25, "y": 1082.63, "z": -3.69659},
{"x": 1865.08, "y": 1080.05, "z": -2.61565},
{"x": 1863.26, "y": 1079.79, "z": -3.69677},
@@ -205,35 +210,38 @@
]
},
{
- "entries": [4293, 4075],
+ "entries": [6547, 4306, 4075, 6547, 6547, 4306, 4293, 4293],
"positions": [
- {"x": 1784.35, "y": 1118.79, "z": 7.49035},
- {"x": 1782.75, "y": 1121.98, "z": 7.57362}
+ {"x": 1858.79, "y": 1146.48, "z": -2.60957},
+ {"x": 1860.63, "y": 1146.78, "z": -3.69654},
+ {"x": 1855.6, "y": 1154.76, "z": -3.69664},
+ {"x": 1852.55, "y": 1160.99, "z": -2.59734},
+ {"x": 1843.91, "y": 1161.27, "z": -3.20069},
+ {"x": 1853.09, "y": 1162.9, "z": -3.69651},
+ {"x": 1860.12, "y": 1162.95, "z": -3.69654},
+ {"x": 1863.07, "y": 1162.91, "z": -3.69654}
]
},
{
- "entries": [4293, 4293],
+ "entries": [61971],
"positions": [
- {"x": 1807.84, "y": 1165.98, "z": 6.90374},
- {"x": 1807.48, "y": 1170.39, "z": 6.90373}
+ {"x": 1871.03, "y": 1154.02, "z": -3.69666}
]
}
],
"boss": {
- "entry": 3983,
- "position": {"x": 1786.55, "y": 1124.44, "z": 7.57362}
+ "entry": 61972,
+ "position": {"x": 1881.82, "y": 1087.25, "z": -3.6965}
}
},
{
"name": "scorn",
- "to": {"x": 1798.83, "y": 1246.83, "z": 18.4078},
+ "to": {"x": 1837.69, "y": 1249.07, "z": 18.2407},
"pull_groups": [
{
- "entries": [4308, 4308, 4308],
+ "entries": [6426],
"positions": [
- {"x": 1798.83, "y": 1246.83, "z": 18.4078},
- {"x": 1796.2, "y": 1247.53, "z": 18.4118},
- {"x": 1800.67, "y": 1249.87, "z": 18.425}
+ {"x": 1837.69, "y": 1249.07, "z": 18.2407}
]
},
{
@@ -243,23 +251,34 @@
]
},
{
- "entries": [6426],
+ "entries": [4308, 4308, 4308],
"positions": [
- {"x": 1837.69, "y": 1249.07, "z": 18.2407}
+ {"x": 1800.67, "y": 1249.87, "z": 18.425},
+ {"x": 1798.83, "y": 1246.83, "z": 18.4078},
+ {"x": 1796.2, "y": 1247.53, "z": 18.4118}
]
},
{
- "entries": [6426],
+ "entries": [4308, 4308, 4308, 4075],
"positions": [
- {"x": 1834.93, "y": 1279.38, "z": 18.2407}
+ {"x": 1781.75, "y": 1265.2, "z": 17.7005},
+ {"x": 1777.24, "y": 1262.44, "z": 17.6542},
+ {"x": 1778.28, "y": 1267.77, "z": 17.6649},
+ {"x": 1778.59, "y": 1275.54, "z": 17.6707}
+ ]
+ },
+ {
+ "entries": [6427],
+ "positions": [
+ {"x": 1797.23, "y": 1283.1, "z": 18.613}
]
},
{
"entries": [4308, 4308, 4308],
"positions": [
- {"x": 1819.47, "y": 1300.64, "z": 17.5862},
+ {"x": 1811.79, "y": 1302.46, "z": 17.8061},
{"x": 1815.85, "y": 1300.29, "z": 17.6923},
- {"x": 1811.79, "y": 1302.46, "z": 17.8061}
+ {"x": 1819.47, "y": 1300.64, "z": 17.5862}
]
},
{
@@ -333,18 +352,18 @@
]
},
{
- "entries": [4075, 4308, 4308, 4308],
+ "entries": [6427, 6489, 6488, 6490],
"positions": [
- {"x": 1778.59, "y": 1275.54, "z": 17.6707},
- {"x": 1778.28, "y": 1267.77, "z": 17.6649},
- {"x": 1777.24, "y": 1262.44, "z": 17.6542},
- {"x": 1781.75, "y": 1265.2, "z": 17.7005}
+ {"x": 1757.61, "y": 1248.3, "z": 18.1575},
+ {"x": 1749.61, "y": 1247.4, "z": 18.1576},
+ {"x": 1749.61, "y": 1247.4, "z": 18.1576},
+ {"x": 1749.61, "y": 1247.4, "z": 18.1576}
]
},
{
- "entries": [6427],
+ "entries": [6426],
"positions": [
- {"x": 1797.23, "y": 1283.1, "z": 18.613}
+ {"x": 1834.93, "y": 1279.38, "z": 18.2407}
]
},
{
@@ -379,15 +398,6 @@
"positions": [
{"x": 1795.38, "y": 1362.76, "z": 18.9709}
]
- },
- {
- "entries": [6427, 6489, 6488, 6490],
- "positions": [
- {"x": 1757.61, "y": 1248.3, "z": 18.1575},
- {"x": 1749.61, "y": 1247.4, "z": 18.1576},
- {"x": 1749.61, "y": 1247.4, "z": 18.1576},
- {"x": 1749.61, "y": 1247.4, "z": 18.1576}
- ]
}
],
"boss": {
diff --git a/dungeons/scarlet_library.json b/dungeons/scarlet_library.json
index d0f26a24..129bdf03 100644
--- a/dungeons/scarlet_library.json
+++ b/dungeons/scarlet_library.json
@@ -26,6 +26,7 @@
"DRAFT: a skeleton from the world database export of map 189 (tools/dungeongen, spec tools/dungeongen/plans.json). The boss order is the usual clear order, the packs are the spawn clusters nearest each boss; walls, doors, stairs and events are unknown to it. Not mapped, not live-checked: do the GM-invisible mapping walk and correct the route before the first run (AUTO-IMPROVE step 0); dungeon_run refuses it without allow_draft.",
"A wait_for on a door a lever or key opens needs timeout_s 60 (the default): a linked door can open 30 s after the use.",
"Needs: Houndmaster Loksey's hounds (SPELL_SUMMONSCARLETHOUND)",
+ "Needs: Brother Wystan (61983) is a Turtle addition: EventAI, spell list 180317 (Fireball 8402); he patrols (movement_type 2), the boss step meets him on his path",
"One of four wings of map 189."
],
"legs": [
@@ -104,165 +105,163 @@
{
"entries": [4287],
"positions": [
- {"x": 211.489, "y": -312.366, "z": 18.6126}
+ {"x": 174.665, "y": -312.681, "z": 18.6126}
]
},
{
- "entries": [4287, 4287],
+ "entries": [4287],
"positions": [
- {"x": 228.658, "y": -310.084, "z": 18.6126},
- {"x": 229.038, "y": -304.547, "z": 18.6126}
+ {"x": 155.754, "y": -285.667, "z": 19.2011}
]
},
{
- "entries": [4299, 4540, 4299],
+ "entries": [4287],
"positions": [
- {"x": 240.758, "y": -301.743, "z": 20.1611},
- {"x": 243.046, "y": -303.767, "z": 20.1611},
- {"x": 247.023, "y": -304.425, "z": 20.1611}
+ {"x": 153.936, "y": -268.826, "z": 19.2844}
]
},
{
- "entries": [4540, 4540, 4291],
+ "entries": [4288, 4304],
"positions": [
- {"x": 263.371, "y": -302.978, "z": 20.1611},
- {"x": 265.073, "y": -312.405, "z": 20.079},
- {"x": 267.932, "y": -303.047, "z": 20.1611}
+ {"x": 167.109, "y": -267.366, "z": 18.6246},
+ {"x": 168.105, "y": -268.833, "z": 18.6996}
]
},
{
- "entries": [4291],
+ "entries": [4287],
"positions": [
- {"x": 232.174, "y": -325.762, "z": 18.617}
+ {"x": 203.703, "y": -268.734, "z": 19.2844}
]
},
{
- "entries": [4287, 4296, 4287],
+ "entries": [4287, 4287, 4296],
"positions": [
+ {"x": 218.136, "y": -286.012, "z": 19.2829},
{"x": 220.711, "y": -288.573, "z": 19.2829},
- {"x": 223.447, "y": -286.047, "z": 19.2829},
- {"x": 218.136, "y": -286.012, "z": 19.2829}
+ {"x": 223.447, "y": -286.047, "z": 19.2829}
]
},
{
- "entries": [4287],
+ "entries": [4287, 4287],
"positions": [
- {"x": 203.703, "y": -268.734, "z": 19.2844}
+ {"x": 267.888, "y": -233.814, "z": 18.614},
+ {"x": 271.381, "y": -233.791, "z": 18.614}
]
},
{
- "entries": [4304, 4288],
+ "entries": [4304, 4304, 4304],
"positions": [
- {"x": 168.105, "y": -268.833, "z": 18.6996},
- {"x": 167.109, "y": -267.366, "z": 18.6246}
+ {"x": 123.754, "y": -261.435, "z": 18.6299},
+ {"x": 123.09, "y": -258.452, "z": 18.6299},
+ {"x": 120.958, "y": -257.75, "z": 18.6299}
]
- },
+ }
+ ],
+ "boss": {
+ "entry": 3974,
+ "position": {"x": 119.938, "y": -260.869, "z": 18.6299}
+ }
+ },
+ {
+ "name": "brother_wystan",
+ "to": {"x": 211.489, "y": -312.366, "z": 18.6126},
+ "pull_groups": [
{
"entries": [4287],
"positions": [
- {"x": 153.936, "y": -268.826, "z": 19.2844}
+ {"x": 211.489, "y": -312.366, "z": 18.6126}
]
},
{
- "entries": [4287],
+ "entries": [4287, 4287],
"positions": [
- {"x": 155.754, "y": -285.667, "z": 19.2011}
+ {"x": 228.658, "y": -310.084, "z": 18.6126},
+ {"x": 229.038, "y": -304.547, "z": 18.6126}
]
},
{
- "entries": [4287],
+ "entries": [4299, 4540, 4299],
"positions": [
- {"x": 174.665, "y": -312.681, "z": 18.6126}
+ {"x": 240.758, "y": -301.743, "z": 20.1611},
+ {"x": 243.046, "y": -303.767, "z": 20.1611},
+ {"x": 247.023, "y": -304.425, "z": 20.1611}
]
},
{
- "entries": [4304, 4304, 4304],
+ "entries": [4540, 4540, 4291],
"positions": [
- {"x": 123.754, "y": -261.435, "z": 18.6299},
- {"x": 123.09, "y": -258.452, "z": 18.6299},
- {"x": 120.958, "y": -257.75, "z": 18.6299}
+ {"x": 263.371, "y": -302.978, "z": 20.1611},
+ {"x": 265.073, "y": -312.405, "z": 20.079},
+ {"x": 267.932, "y": -303.047, "z": 20.1611}
]
},
{
- "entries": [4287, 4287],
+ "entries": [4540, 4299],
"positions": [
- {"x": 267.888, "y": -233.814, "z": 18.614},
- {"x": 271.381, "y": -233.791, "z": 18.614}
+ {"x": 272.412, "y": -326.647, "z": 18.6172},
+ {"x": 272.568, "y": -331.08, "z": 18.6172}
]
- }
- ],
- "boss": {
- "entry": 3974,
- "position": {"x": 119.938, "y": -260.869, "z": 18.6299}
- }
- },
- {
- "name": "arcanist_doan",
- "to": {"x": 211.785, "y": -326.959, "z": 18.5334},
- "pull_groups": [
+ },
{
- "entries": [4291, 4299, 4540, 4540],
+ "entries": [4291, 4291],
"positions": [
- {"x": 211.785, "y": -326.959, "z": 18.5334},
- {"x": 211.974, "y": -332.415, "z": 18.5356},
- {"x": 218.595, "y": -338.548, "z": 18.5348},
- {"x": 223.27, "y": -338.545, "z": 18.5335}
+ {"x": 253.912, "y": -332.617, "z": 18.6172},
+ {"x": 247.954, "y": -335.179, "z": 18.6174}
]
},
{
- "entries": [4291, 4291],
+ "entries": [4291],
"positions": [
- {"x": 247.954, "y": -335.179, "z": 18.6174},
- {"x": 253.912, "y": -332.617, "z": 18.6172}
+ {"x": 232.174, "y": -325.762, "z": 18.617}
]
},
{
- "entries": [4299, 4540],
+ "entries": [4540, 4540, 4291, 4299],
"positions": [
- {"x": 272.568, "y": -331.08, "z": 18.6172},
- {"x": 272.412, "y": -326.647, "z": 18.6172}
+ {"x": 223.27, "y": -338.545, "z": 18.5335},
+ {"x": 218.595, "y": -338.548, "z": 18.5348},
+ {"x": 211.785, "y": -326.959, "z": 18.5334},
+ {"x": 211.974, "y": -332.415, "z": 18.5356}
]
},
{
- "entries": [4540, 4291],
+ "entries": [4540, 4540],
"positions": [
- {"x": 251.835, "y": -363.646, "z": 20.4781},
- {"x": 253.363, "y": -367.029, "z": 20.482}
+ {"x": 230.638, "y": -375.109, "z": 23.8613},
+ {"x": 234.038, "y": -380.116, "z": 23.8826}
]
},
{
- "entries": [4299],
+ "entries": [4540],
"positions": [
- {"x": 265.031, "y": -379.047, "z": 20.078}
+ {"x": 220.82, "y": -386.671, "z": 23.8437}
]
},
{
- "entries": [4540, 4291],
+ "entries": [4540, 4540],
"positions": [
- {"x": 265.949, "y": -386.511, "z": 20.1611},
- {"x": 263.192, "y": -386.943, "z": 20.1605}
+ {"x": 208.394, "y": -404.917, "z": 23.8828},
+ {"x": 210.902, "y": -407.296, "z": 23.8744}
]
},
{
- "entries": [4299, 4291],
+ "entries": [4291, 4540],
"positions": [
- {"x": 277.677, "y": -373.477, "z": 20.4933},
- {"x": 279.837, "y": -369.762, "z": 20.487}
+ {"x": 216.476, "y": -418.544, "z": 18.6147},
+ {"x": 215.304, "y": -421.982, "z": 18.6203}
]
},
{
- "entries": [4540, 4291],
+ "entries": [4291, 4291],
"positions": [
- {"x": 279.506, "y": -405.837, "z": 20.4875},
- {"x": 277.415, "y": -410.155, "z": 20.4948}
+ {"x": 231.985, "y": -425.421, "z": 18.6153},
+ {"x": 231.714, "y": -432.81, "z": 18.6163}
]
},
{
- "entries": [4540, 4299, 4540],
+ "entries": [4291],
"positions": [
- {"x": 256.803, "y": -409.171, "z": 20.4936},
- {"x": 253.815, "y": -405.001, "z": 20.4863},
- {"x": 251.522, "y": -408.955, "z": 20.4928}
+ {"x": 253.05, "y": -432.902, "z": 18.6185}
]
},
{
@@ -278,49 +277,45 @@
]
},
{
- "entries": [4291],
- "positions": [
- {"x": 253.05, "y": -432.902, "z": 18.6185}
- ]
- },
- {
- "entries": [4291, 4291],
+ "entries": [4291, 4540],
"positions": [
- {"x": 231.714, "y": -432.81, "z": 18.6163},
- {"x": 231.985, "y": -425.421, "z": 18.6153}
+ {"x": 277.415, "y": -410.155, "z": 20.4948},
+ {"x": 279.506, "y": -405.837, "z": 20.4875}
]
},
{
- "entries": [4540, 4291],
+ "entries": [4540, 4299, 4540],
"positions": [
- {"x": 215.304, "y": -421.982, "z": 18.6203},
- {"x": 216.476, "y": -418.544, "z": 18.6147}
+ {"x": 256.803, "y": -409.171, "z": 20.4936},
+ {"x": 253.815, "y": -405.001, "z": 20.4863},
+ {"x": 251.522, "y": -408.955, "z": 20.4928}
]
},
{
- "entries": [4540, 4540],
+ "entries": [4291, 4540],
"positions": [
- {"x": 210.902, "y": -407.296, "z": 23.8744},
- {"x": 208.394, "y": -404.917, "z": 23.8828}
+ {"x": 263.192, "y": -386.943, "z": 20.1605},
+ {"x": 265.949, "y": -386.511, "z": 20.1611}
]
},
{
- "entries": [61983],
+ "entries": [4299],
"positions": [
- {"x": 209.491, "y": -401.61, "z": 23.8661}
+ {"x": 265.031, "y": -379.047, "z": 20.078}
]
},
{
- "entries": [4540],
+ "entries": [4299, 4291],
"positions": [
- {"x": 220.82, "y": -386.671, "z": 23.8437}
+ {"x": 277.677, "y": -373.477, "z": 20.4933},
+ {"x": 279.837, "y": -369.762, "z": 20.487}
]
},
{
- "entries": [4540, 4540],
+ "entries": [4291, 4540],
"positions": [
- {"x": 234.038, "y": -380.116, "z": 23.8826},
- {"x": 230.638, "y": -375.109, "z": 23.8613}
+ {"x": 253.363, "y": -367.029, "z": 20.482},
+ {"x": 251.835, "y": -363.646, "z": 20.4781}
]
},
{
@@ -331,6 +326,22 @@
{"x": 182.002, "y": -416.543, "z": 18.9408}
]
},
+ {
+ "entries": [4540],
+ "positions": [
+ {"x": 197.231, "y": -432.247, "z": 18.5317}
+ ]
+ }
+ ],
+ "boss": {
+ "entry": 61983,
+ "position": {"x": 209.491, "y": -401.61, "z": 23.8661}
+ }
+ },
+ {
+ "name": "arcanist_doan",
+ "to": {"x": 173.965, "y": -427.5, "z": 18.6164},
+ "pull_groups": [
{
"entries": [4540, 4540],
"positions": [
@@ -345,12 +356,6 @@
{"x": 183.061, "y": -443.497, "z": 18.9388},
{"x": 180.243, "y": -445.043, "z": 18.9434}
]
- },
- {
- "entries": [4540],
- "positions": [
- {"x": 197.231, "y": -432.247, "z": 18.5317}
- ]
}
],
"boss": {
diff --git a/dungeons/uldaman.json b/dungeons/uldaman.json
index 2b305622..1f81b4dd 100644
--- a/dungeons/uldaman.json
+++ b/dungeons/uldaman.json
@@ -22,6 +22,10 @@
"warlock:dps"
],
"fleers": [4853, 4848, 4849, 7321],
+ "cc_priority": [4853, 4848, 7321, 7030, 7012, 7397],
+ "cc_always": [4848, 7321],
+ "no_cc": [4857, 4860],
+ "kill_first": [4852],
"notes": [
"Uldaman (map 70) from the main entrance (areatrigger 286) to Archaedas, the last boss. Legs 0-3 (Baelog, Revelosh, Keystone, Ironaya) are the west wing and were run remotely end to end (remote run 5); legs 4-13 go on from Ironaya's room and were written from the world database of tortoise-wow 5d79486 (map 70 creature and game object spawns, templates, EventAI, the C++ scripts of src/scripts/dungeons/uldaman) and the map 70 navmesh, then compared with the live map walk of 2026-10-14 (a GM walker, nothing fought or used). They have NOT been run as a party: Obsidian Sentinel, Ancient Stone Keeper, Galgann, Grimlok, the Altar of the Keepers with its four Stone Keepers, Archaedas. Order of legs 0-3 follows docs/guides/dungeon-tactics.md section 8 of TortoiseBots (PR 859).",
"Honest (non-GM) run: the baelog leg pulls its packs and loots Baelog's Chest; revelosh kills Revelosh, loots the Shaft of Tsol and makes the Staff of Prehistoria; keystone opens the Keystone with it. A Horde leader kills Baelog, Eric and Olaf (team: horde); for an Alliance leader the boss is skipped unless the lost dwarves show up hostile in the feed (remote run 2: they were hostile to an Alliance party and joined the pull of the last pack), then it is fought like a pull group. Pass criteria: docs/LIVE-CHECKS.md.",
@@ -472,6 +476,36 @@
"positions": [
{"x": -43.6, "y": 237.3, "z": -48.3}
]
+ },
+ {
+ "entries": [4860],
+ "positions": [
+ {"x": -39.6, "y": 272.8, "z": -49}
+ ],
+ "approach": [
+ {"x": -64, "y": 241, "z": -49.7}
+ ]
+ },
+ {
+ "entries": [7012, 7012, 7011],
+ "positions": [
+ {"x": -44.1, "y": 272.7, "z": -48.9},
+ {"x": -42.3, "y": 263.9, "z": -48.9},
+ {"x": -42.7, "y": 255.2, "z": -48.9}
+ ],
+ "approach": [
+ {"x": -58, "y": 241, "z": -48.3}
+ ]
+ },
+ {
+ "entries": [7011, 7011],
+ "positions": [
+ {"x": -49.6, "y": 202, "z": -48.3},
+ {"x": -43.2, "y": 203.8, "z": -48.3}
+ ],
+ "approach": [
+ {"x": -64, "y": 227, "z": -49.5}
+ ]
}
],
"boss": {
@@ -490,7 +524,8 @@
"notes": [
"From the Sentinel south-west through the big central hall (x -110 to -40, y 200 to 340): the Earthen guards of the hall (Earthen Rocksmashers 7011, Sculptors 7012, faction 415, hostile to both teams, stationary or wandering 2-5 yd) in five groups, two Stone Stewards (4860, waypoint patrols: their spawn point is where the plan looks first). Packs are the spawns within 10 yd of the navmesh route (the older legs use 8), grouped by tools/dungeongen (12 yd, creature groups).",
"Ancient Stone Keeper (7206, level 44, 8910 hp, faction 415, no script, one spell, 10132) stands alone at the south end of the central hall, a dead end off the way on: it is a mini boss, nothing opens behind it.",
- "The run leader stands the party within 50 yd of each pack (approach points): the south mouth of the tunnel for the first group, past the doorway at -126..-119 320..322 (a vmap lintel 5-6 yd up: no point there) for the Stone Steward, then the rooms south to the Keeper. Without them every pack of the leg was 75-195 yd from the leg's `to` and the run leader's own hops failed (live 2026-10-16, steps 77-84)."
+ "The run leader stands the party within 50 yd of each pack (approach points): the south mouth of the tunnel for the first group, past the doorway at -126..-119 320..322 (a vmap lintel 5-6 yd up: no point there) for the Stone Steward, then the rooms south to the Keeper. Without them every pack of the leg was 75-195 yd from the leg's `to` and the run leader's own hops failed (live 2026-10-16, steps 77-84).",
+ "The Stone Steward (4860 at -39.6 272.8, a patrol along x -42 from y 274 to 234), the Sculptor trio north of the Keeper (-44 .. -43 at y 255-273) and the two southern Rocksmashers (-49.6 202 and -43.2 204) are pulled before the Keeper: pull groups 7-9 here, the first two moved from the galgann leg, from stands in the south hall (-64 241, -58 241, -64 227) that stay 23 yd or more from the Keeper and from the groups pulled after them. In the runs r4 slot1 (wipe) and r6 slot5 (two deaths) the patrol and the trio were alive at the Keeper and 5-7 mobs fought together; with only the Steward or 2-4 mobs at a time nobody died (PLAN-ULDAMAN-GRIMLOK.txt, #1)."
]
},
{
@@ -503,20 +538,6 @@
{"x": -43.6, "y": 237.3, "z": -48.3}
]
},
- {
- "entries": [4860],
- "positions": [
- {"x": -39.6, "y": 272.8, "z": -49}
- ]
- },
- {
- "entries": [7012, 7012, 7011],
- "positions": [
- {"x": -44.1, "y": 272.7, "z": -48.9},
- {"x": -42.3, "y": 263.9, "z": -48.9},
- {"x": -42.7, "y": 255.2, "z": -48.9}
- ]
- },
{
"entries": [7012, 7011],
"positions": [
@@ -598,7 +619,8 @@
},
"notes": [
"North out of the central hall's east side: Rocksmashers/Sculptors/a Steward, then the nest of ten Deadly Cleft Scorpids (7405, level 42-43, wander 1) 6.7 yd off the route at -21 331, then the two Shadowforge Sharpshooters (7290) at the foot of the ramp, then the two pairs of Darkcaster (4848) and Archaeologist (4849) that stand 10-12 yd off the approach to the Galgann room. The casters of the Shadowforge groups run at 15 % health (EventAI 'Flee at 15% HP'), so a wounded one can bring the next group.",
- "Galgann Firehammer (7291, level 45, 13302 hp, faction 54, EventAI) stands with two Shadowforge Geologists (7030) in one creature group (leader guid 27491): the plan kills the Geologists first (boss.first). He yells on aggro and casts Amplify Flames (9482); his spell list is 3356, 8053 and 11969. Turtle-custom data: none found (the entries are untouched by the database updates read)."
+ "Galgann Firehammer (7291, level 45, 13302 hp, faction 54, EventAI) stands with two Shadowforge Geologists (7030) in one creature group (leader guid 27491): the plan kills the Geologists first (boss.first). He yells on aggro and casts Amplify Flames (9482); his spell list is 3356, 8053 and 11969. Turtle-custom data: none found (the entries are untouched by the database updates read).",
+ "Its first pull groups (the Stone Steward and the Sculptor trio at -44 .. -39 / 255-273) were moved to the ancient_stone_keeper leg, where they are pulled before the Keeper (PLAN-ULDAMAN-GRIMLOK.txt, #1); the leg starts at the Rocksmasher pack at -23.8 278.5."
]
},
{
@@ -606,27 +628,45 @@
"to": {"x": 4, "y": 393, "z": -45},
"pull_groups": [
{
- "entries": [4853, 4855],
+ "entries": [4853, 4855, 4855],
"positions": [
{"x": 30.5, "y": 375.8, "z": -40},
- {"x": 36.2, "y": 376, "z": -38.9}
+ {"x": 36.2, "y": 376, "z": -38.9},
+ {"x": 40.6, "y": 359.9, "z": -43.3}
+ ],
+ "radius": 16
+ },
+ {
+ "entries": [4855, 4853],
+ "positions": [
+ {"x": 49.3, "y": 346.2, "z": -40.1},
+ {"x": 48.9, "y": 340.7, "z": -40.9}
+ ],
+ "approach": [
+ {"x": 28, "y": 360, "z": -41.6}
]
},
{
- "entries": [4855],
+ "entries": [4855, 4855],
"positions": [
- {"x": 57.9, "y": 374, "z": -38.2}
+ {"x": 37, "y": 331.8, "z": -43.1},
+ {"x": 42.1, "y": 323.2, "z": -42.9}
],
"approach": [
- {"x": 19.1, "y": 366.4, "z": -40.6}
+ {"x": 33, "y": 354, "z": -42.5}
]
},
{
- "entries": [4855, 4853, 4855],
+ "entries": [4855, 4855, 4853, 4855],
"positions": [
+ {"x": 57.9, "y": 374, "z": -38.2},
{"x": 55.4, "y": 379, "z": -38.2},
{"x": 62.5, "y": 377.9, "z": -38.3},
{"x": 58.7, "y": 366.7, "z": -38.9}
+ ],
+ "radius": 18,
+ "approach": [
+ {"x": 19.1, "y": 366.4, "z": -40.6}
]
},
{
@@ -655,7 +695,7 @@
{"x": 40.1, "y": 445.2, "z": -41}
],
"approach": [
- {"x": 59.7, "y": 408.2, "z": -37.5}
+ {"x": 48, "y": 419, "z": -41}
]
}
],
@@ -678,39 +718,14 @@
},
"notes": [
"Back out of the Galgann room and east along the Stonevault corridor: Brawler (4855) + Geomancer (4853) pairs and trios (Geomancers flee at 15 % health), the patrolling Brawler near 57.9 374, then the group of three at 55 417. The trio at 79-85 397-405 (Brawler, Brawler, Geomancer: guids 27512-27514, wander 2) stands 25 yd east of the group of three at 55 417 and 18 yd from the lead bot's way to it: it is its own pull group, taken first (Uldaman A run 6: the tank walked up to the group of three, the trio aggroed on the way, six mobs at once, the party wiped at pull group 4).",
- "Grimlok (4854, level 45, 7390 hp, faction 59, EventAI) is in one creature group (leader guid 27528) with a Stonevault Brawler, a Stonevault Geomancer and a Jadespine Basilisk (4863; script mob_jadespine_basilisk: Crystalline Slumber every 28 s on its target, which it then drops from its threat): boss.first kills them in that order. Grimlok yells on aggro and on a player kill, casts Bloodlust (6742) at 30 % health, and has the spells 11892, 8292, 8143 and 9532. The run's `cc` option can mark the Basilisk."
+ "Grimlok (4854, level 45, 7390 hp, faction 59, EventAI) is in one creature group (leader guid 27528) with a Stonevault Brawler, a Stonevault Geomancer and a Jadespine Basilisk (4863; script mob_jadespine_basilisk: Crystalline Slumber every 28 s on its target, which it then drops from its threat): boss.first kills them in that order. Grimlok yells on aggro and on a player kill, casts Bloodlust (6742) at 30 % health, and has the spells 11892, 8292, 8143 and 9532. The run's `cc` option can mark the Basilisk.",
+ "Pull order of 2026-10-21 (PLAN-ULDAMAN-GRIMLOK.txt, #2; every wipe and near wipe of eight runs was a pull that grew from 2-4 to 5-11 mobs): group 0 lists the Brawler 30105 at 40.6 359.9 (16.7 yd from the Brawler at 36.2 376 it joined in 8 of 8 runs, radius 16 so that the Brawler at 55.4 379 stays out); the Brawler and Geomancer pair at 49 346/341 and the two Brawlers at 37-42 332/323 (they were the first groups of the keepers_door leg and woke while the fight of the east groups ran 14-20 yd from them) are pulled next, from stands at 28 360 and 33 354 west of the patrol and its trio; the patrol Brawler (57.9 374) and the trio at 55-62 367-379 are one group of four (they came together in 8 of 8 runs; group 2 of the old order was empty each time); the pair at 38-40 439-445 is pulled from 48 419 (a navmesh line to both, 29.8 yd or more from Grimlok's own group), and its fight must not move north of y 425."
]
},
{
"name": "keepers_door",
"to": {"x": 61, "y": 397, "z": -38},
"pull_groups": [
- {
- "entries": [4855, 4853],
- "positions": [
- {"x": 49.3, "y": 346.2, "z": -40.1},
- {"x": 48.9, "y": 340.7, "z": -40.9}
- ],
- "approach": [
- {"x": 58.5, "y": 382.2, "z": -38.2}
- ]
- },
- {
- "entries": [4855],
- "positions": [
- {"x": 40.6, "y": 359.9, "z": -43.3}
- ]
- },
- {
- "entries": [4855, 4855],
- "positions": [
- {"x": 37, "y": 331.8, "z": -43.1},
- {"x": 42.1, "y": 323.2, "z": -42.9}
- ],
- "approach": [
- {"x": 50.4, "y": 365.8, "z": -43}
- ]
- },
{
"entries": [4855, 4855, 4853],
"positions": [
@@ -783,7 +798,8 @@
"notes": [
"Back along the corridor Grimlok's room opens into, then south down the Stonevault passage and the ramp room of the Stonevault Maulers (7320) and Flameweavers (7321; Fire Shield IV, flee at 15 %; Maulers frenzy at 50 %) to the foot of the ramp up to the Hall of the Keepers (z -26). The route is 260 yd; the packs of the way in (the trio at 55 417 and the three near 58 374) were pulled by the grimlok leg.",
"The Temple Door (124368, 50.0 247.2 -26.5) closes the Hall of the Keepers: a plain door like the Echomok Door (type 0, lock 86, spawned closed, no script; same unverified lock). The step uses it from the west side (46.5 247.2).",
- "Pull group 3 (Brawler, Brawler, Geomancer at 39-50 305-311) is pulled from 47.6 357.3 -43.3 since 2026-10-17 (live run uldaman-events-2), 9.5 yd farther back than the old stand 44.5 348 -42.2. From there, 40 yd from the pack, the Geomancer cast from range and came only to 42 318; the mage walked after it to 37 315 and woke the two Stonevault Maulers of pull group 4 (7320 at 26 301 and 26 293, 16-18 yd from him), which had stood idle through the tank's own pull, and the party wiped. From 49 yd a caster has to come about 20 yd toward the party, and the fight stays farther from pull group 4 (64 yd from the stand). The approach point of pull group 2 (50.4 365.8, 57.8 yd) would be farther still but breaks the 50 yd reach rule (TestUldamanRunLeaderReach: the run leader judges a pack from 60 yd). Pulling groups 3 and 4 as one (five mobs) was not chosen: the Maulers joined only the chase, never the pull."
+ "Pull group 3 (Brawler, Brawler, Geomancer at 39-50 305-311) is pulled from 47.6 357.3 -43.3 since 2026-10-17 (live run uldaman-events-2), 9.5 yd farther back than the old stand 44.5 348 -42.2. From there, 40 yd from the pack, the Geomancer cast from range and came only to 42 318; the mage walked after it to 37 315 and woke the two Stonevault Maulers of pull group 4 (7320 at 26 301 and 26 293, 16-18 yd from him), which had stood idle through the tank's own pull, and the party wiped. From 49 yd a caster has to come about 20 yd toward the party, and the fight stays farther from pull group 4 (64 yd from the stand). The approach point of pull group 2 (50.4 365.8, 57.8 yd) would be farther still but breaks the 50 yd reach rule (TestUldamanRunLeaderReach: the run leader judges a pack from 60 yd). Pulling groups 3 and 4 as one (five mobs) was not chosen: the Maulers joined only the chase, never the pull.",
+ "Its first three pull groups (the Brawler and Geomancer pair at 49 346/341, the Brawler at 40.6 360 and the two Brawlers at 37-42 332/323) moved to the grimlok leg, which pulls them before the patrol and the trio (PLAN-ULDAMAN-GRIMLOK.txt, #2); the group numbers in the notes above are the old ones (old groups 3-7 are now 0-4)."
]
},
{
diff --git a/dungeons/zulfarrak.json b/dungeons/zulfarrak.json
index 47f54ccd..749130e0 100644
--- a/dungeons/zulfarrak.json
+++ b/dungeons/zulfarrak.json
@@ -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": [
@@ -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],
@@ -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."
]
},
{
@@ -508,7 +516,12 @@
{"x": 1586.1, "y": 1087.3, "z": 8.9},
{"x": 1590.3, "y": 1090.3, "z": 8.9}
],
- "radius": 30
+ "radius": 30,
+ "approach": [
+ {"x": 1594, "y": 1011, "z": 10.9},
+ {"x": 1598, "y": 1028, "z": 9.6},
+ {"x": 1606, "y": 1040, "z": 10.3}
+ ]
},
{
"entries": [7246, 5650, 7246, 5650],
@@ -518,14 +531,28 @@
{"x": 1608.3, "y": 1110.8, "z": 9},
{"x": 1610.4, "y": 1108.3, "z": 9}
],
- "radius": 15
+ "radius": 15,
+ "approach": [
+ {"x": 1588, "y": 1080, "z": 9.1},
+ {"x": 1584, "y": 1088, "z": 9.1}
+ ]
}
],
"skip": [7269, 10080, 10082],
+ "leave": [
+ {
+ "entry": 7246,
+ "spawn": 81610,
+ "at": {"x": 1591, "y": 1047.7, "z": 9},
+ "why": "lone Shadowhunter on a navmesh island (3 x 3 yd, no path leaves it): never pull, fight or walk up to it; a fight takes the tank onto the island (zf-r6-19z24)"
+ }
+ ],
"notes": [
+ "The leave list (2026-10-21, PLAN-ZF-LESSONS #3): spawn 81610 is the lone Shadowhunter of the first note below. It never moves, no path leaves its 3 x 3 yd patch of mesh (1588-1591.2 1044.3-1047), it fights back when a hunter or pet shoots it first (zf-r6-19z24: 97 % to 6 % in 46 s, then it fled) and is passive at 100 % otherwise (zf-r3-19z20: four pulls, no fight). The earlier text here said the run leader pulls it at the hop to 1584 1050 and that this is fine; it is not: the hop pull engaged it in melee and the tank landed on the island. No approach point or stand of the plan stays within 15 yd of a leave spawn (leave_clear_test.go); The walks after the approach rows are the module's and cannot be moved by plan data, measured on the 209 mesh as the shortest distance over every segment: the approach walk from the stand 1606 1040 to group 5's first point (1588 1080) passes 5.0 yd from the mob (the only way north is the west lane beside its patch of mesh); the pull walk of group 4 (the stand to the pack at 1583 1087, where the tank goes to its firing stand about 30 yd short of the pack, around y 1060, and the pack is fetched back along the same lane) also passes 5.0 yd; group 3's lone Blood Drinker at 1585.1 1035.5 is itself 13.6 yd from it. Group 4 holds Shadowhunters (7246) too, so a pull of it can wake 81610 by social link. The mob is passive at that range (zf-r6: four passes at 100 % out of combat), so the risk is the module's hop pull, melee chase and engage-on-arrival (PLAN-ZF-LESSONS #2), not these rows; TestZulfarrakApproachWalkKeepsClearOfLeave records the three figures as known entries. Deploy order: the loader that reads `leave` (turtle-agent dev, this change) has to be out before this plan file: an older binary refuses dungeons/zulfarrak.json with unknown field \"leave\".",
"zf1-run6 (2026-10-15): two lone Shadowhunter groups are dropped. 1591 1047.7 (7246): three pulls drew no fight (the tank stood 0.7 yd from it and targeted it, the mob stayed at 100 % out of combat, the hunter and the warlock reported out of range); the same Shadowhunter fought as part of the pack at 1576 1079. 1626.5 1136 (7246): nothing of it in view and the look walk to 21 yd of it was not reachable (lead 37 yd away). The run finished without either.",
"TestZulfarrakRouteHasNoUnplannedPack (2026-10-14) found three mobs within 20 yd of this leg's route in no pull group: the Shadowhunter 44177 (1662.3 929.7) with the pack of rows 81440/81443/81444 behind it (1657-1662 938-943; a group of its own after the first), the patrolling Shadowcaster 1246219 (1589.4 985.4, a group of its own) and the Blood Drinker 81575 (1632.5 1166.8, 19 yd from Velratha's spot, added to the group at 1648 1152).",
- "Route edit Z4 NOT applied (2026-10-16): a planned group for the Shadowhunter / Sandcrawler pair at 1565 1019 (the export has no spawn row there: they are roaming members of the patrol group at 1576-1590 1079-1092) puts the look point 21 yd off it within 20 yd of the static pack of two Witch Doctors and a Shadowhunter at 1545-1556 995-1007 (81618, 81619, 81624), which stands in a pit 4 yd below the mesh, off the navmesh and out of any pull (TestZulfarrakRouteHasNoUnplannedPack fails). The pair is still met on the way and the walk's pull takes it."
+ "Route edit Z4 NOT applied (2026-10-16): a planned group for the Shadowhunter / Sandcrawler pair at 1565 1019 (the export has no spawn row there: they are roaming members of the patrol group at 1576-1590 1079-1092) puts the look point 21 yd off it within 20 yd of the static pack of two Witch Doctors and a Shadowhunter at 1545-1556 995-1007 (81618, 81619, 81624), which stands in a pit 4 yd below the mesh, off the navmesh and out of any pull (TestZulfarrakRouteHasNoUnplannedPack fails). The pair is still met on the way and the walk's pull takes it.",
+ "The pool stairs (zf-full-19z16, 2026-10-20, STUCK #2): after the pack of four at 1601-1610 1104-1111 the run leader stood at 1629.2 1099.0 z 9.7 and three followers (and the healer at z 31.8) were on the UPPER level at 1629-1636 1070-1099, z 45.9-46.8, 46 yd from the lead, last_action \"reach spell\" at a mob 37 yd below; the gather summoned them one by one over 6 min (15:27:36-15:33:40) and the next pull started with the DPS 26-35 yd out. The stair base is a navmesh island: a path from 1595.5 1092.4 to 1629.2 1099.0 is Partial and ends at 1621.3 1102.9, 8.8 yd short (the 209 mmaps of 2026-10-20), so a follower walked to the lead's stand there has to go up and around. The approach rows of pull group 4 (1594 1011, 1598 1028, 1606 1040; moved on 2026-10-21 from 1584 1050 and 1603 1043, which stood 7.4 and 12.9 yd from the lone Shadowhunter 81610: see its leave entry) and group 5 (1588 1080, 1584 1088) keep the run leader's party on the lower floor, west of the stair mouth (not live-run). The LAST point of each (where the party stands to pull) is at least 20 yd from every member of its group, TestZulfarrakPoolStairsApproach checks it (cold review 2026-10-21: the first version, 1582 1070 and 1593.6 1089.9, stood 10.8 yd from the Witch Doctor of group 4 and 16.6 yd from a Shadowhunter of group 5, so the walk there would pull the pack by proximity, before the Polymorph: the STUCK #1 situation). Group 4 is the patrol of the Sandcrawler 81587 (creature_movement: 1586.1 1087.7, 1569.6 1052.1, 1563.3 1014.3, 1574.3 1065.2, 1598.0 1104.8, 1622.5 1131.7; Shadowhunter 81588 and Blood Drinker 81601 follow at 3.5 and 5.3 yd) with the static Witch Doctor 81609 and Shadowhunter 81608: 1606 1040 is 47.7 yd or more from every member's spawn (less the Blood Drinker's 5 yd wander), 33.0 yd or more from every patrol waypoint less the 5.3 yd follower offset, 36.3 yd from the patrol's path, 49 yd from the Witch Doctor and 52 yd from the group's centre (3 yd more than the run leader's 50 yd reach, so plan-check warns 'run leader hop' for it; the run leader sees 60 yd, kSeeYd), on the 209 navmesh at z 10.3. The lone Shadowhunter 81610 (1591 1047.7, in no group of the plan, on the plan's leave list) is 16.9 yd from it and 20.9 yd from 1598 1028; the walk 1594 1011 -\u003e 1598 1028 -\u003e 1606 1040 is complete (31 + 17 yd, from the stand south of the lone Blood Drinker at 1585.1 1035.5) and passes no closer than 15.2 yd to it. The party must take THAT way round: from the west lane at 1585 1035 the navmesh's short way to the east side (1584 1050 -\u003e 1603 1043, the old rows) goes through 1590.4 1048.8, 0.8 yd from 81610 at its closest, which is why the first point is south. Only the east side of the corridor can keep 15 yd from 81610 and 20 yd from the patrol's path and still see the pack: the west lane (x 1572-1580) is the patrol's own path and 11 yd from 81610, and the best point there (1584 1030) is 17 yd from the patrol path; the nearest point to the group's centre that holds both is 1605 1042 at 15.1 yd from 81610 with a walk 14.6 yd from it (not used). Group 5 (the static pack of four, 81604-81607) is 24.1 yd or more from 1584 1088 (z 9.1, a complete path from 1588 1080). The refused pullback of STUCK #1 (144 s at the same pack) is otherwise a run-leader retry matter."
]
},
{
@@ -678,6 +705,7 @@
"Event 2488 (event_scripts, world database of tortoise-wow-cbspin 4f133ab): delay 0 respawns the other gong object (guid 209001, 3600 s), delay 2 s summons Gahz'rilla (7273, level 46, faction 107, EventAI 20901, no spawn row) at 1664.65 1187.67 -2.89 (o 0.81) for 3600 s: the pit under the pool, 22 yd from the stand. His own AI casts spell 33007, which sends event 2489 (creature_ai_scripts 2090101): six MOVE_TO commands, one second apart, out of the pit and up the ramp: 1672.66 1195.93 (1 s), 1681.74 1204.13 (2 s), 1686.09 1207.3 (3 s), 1690.54 1211.53 (4 s), 1692.57 1213.66 (5 s) and 1702.58 1224.19 8.88 (6 s). The boss position is the last of them (1702.6 1224.2): he stands there, or comes at the party before. The brief of the leg said 1692.6 1213.7, which is only the fifth point of the walk. The step after the gong waits up to 30 s for a living 7273 in the bots' view; the boss fight then looks for him within 80 yd of the lead bot. Event 2489 in this database holds only those six moves (no summon): the six Sandfury trolls the first draft of the extra plan listed for it were these six rows misread, and nothing else appears at the pit.",
"THE PACKS. The pool ring still has about nine packs within 35 yd of his walk after the Velratha leg, which clears only the strip (and the five at 1712-1720 1181-1190, 32 yd from the walk). The eight groups here are the ones on or near the walk-out (every member checked against the world export, creatures.tsv, to the decimal): the Witch Doctor at 1643.6 1200.1 (24 yd from the walk), the two Shadowhunters with the patrolling Sandcrawler of 1649-1654 1210-1215 (spawn rows 81583, 81582, 81581: 24-30 yd; the Sandcrawler walks a patrol, so the group has the 30 yd radius), the Blood Drinkers at 1652.1 1219.9 (31 yd), 1667 1233.6 (32 yd), 1699.8 1233.8 (10 yd from where he ends) and 1714.4 1218.6 (13 yd from it), and the Witch Doctor and the Shadowhunter at 1700.3 1248.3 and 1713.2 1247.8 (24-26 yd, two groups of one in the export, pulled as one). Left out on purpose: the four at 1662-1667 1254-1260 (47 yd from the walk, 41 yd from where he ends; the design list said two Shadowhunters and a Witch Doctor, the export has THREE Shadowhunters, rows 81594, 81592, 81595, with the Witch Doctor 81590). The pack of three at 1621-1625 1192-1197 (40 yd west of the walk) IS pulled, first: the walk from the stand to the first Witch Doctor passes 20 yd from it, and TestZulfarrakRouteHasNoUnplannedPack flags a mob that near a route. The Scarabs (7269) and Elder Wildmane (15578, friendly, absent live) are in skip. The Blood Drinker at 1714.4 1218.6 is the one the Velratha leg's notes say the tank found no firing position for in two batch-2 runs: it is the last pull here, and a refusal on it is the first thing to look at in a live run.",
"The first run with the Mallet: not yet done. Gahz'rilla's abilities (spells 11836 Freeze Solid, 11902 and 11131) are not in the plan's tactics; his first fight sets them.",
+ "WHERE THE MALLET COMES FROM (world DB tw_world, 2026-10-20, SELECT only): Lock.dbc 459 is type 1 (item required) with index 9240, so the gong's lock needs item 9240 and nothing else. Item 9240 Mallet of Zul'Farrak (class 12 quest item, bind on pickup) is not a quest reward, a vendor good, a chest or a loot row anywhere; it is created by USING item 9241 Sacred Mallet (also a bind-on-pickup quest item), whose on-use spell 11435 \"Create Mallet of Zul'Farrak\" has Effect 24 and creates 9240. Item 9241's only source is creature_loot_template entry 7996 at 100 %: Qiaga the Keeper, level 50 elite, faction 37, one spawn on map 0 at -284.792 -3447.45 188.426 (The Hinterlands). No other creature, object, quest, vendor or reference loot row gives 9239/9240/9241. A ZF run starts inside map 209 with hired level 46 bots and the plan has no step kind for leaving the instance, travelling to another map, killing a level-50 elite and coming back, so the Mallet is NOT obtainable in a bot run: the leg stays optional and is skipped honestly, and only a test's `.additem 9240` reaches it. The real gong is 141832 (type 10 goober, guid 27140, data0 459, data2 event 2488); 20901 is the Turtle decoration copy (type 5, guid 209001, no lock, no event) at the same 1650.91 1171.88 10.901 and is never targeted.",
"THE FIGHT (Wowhead strategy guide, round 4 briefing; the server script wins where they disagree, and no spell of his was read from the database here): Gahz'rilla's Slam throws players high into the air, so the tank should hold him with its back to a wall and the party stacked on the tank, not spread out; he casts Icicle and Freeze Solid at anyone out of his melee range, so ranged members belong close to him rather than behind; the room is cleared of packs first (this leg's pull groups). The plan cannot express a tank stand or a gather-on-the-tank order for a boss fight (a leg has `to`, pull groups, steps and the boss position, and the bots stack and spread as the module's own boss tactics do), so none is given: the boss position is where event 2489 leaves him (1702.58 1224.19 8.88), and a wall-backed tank spot has to be found live (the pool room's edge near 1700 1224, which this plan has not measured). `boss.tactics` is left empty for the same reason: his spells (11836 Freeze Solid, 11902, 11131 in his template) are not named in the spell tables the tactics check reads. First live run: record where the tank stood, how often Slam threw someone, and who was frozen out of melee range."
]
},
@@ -1091,8 +1119,8 @@
"companions_loot": false,
"leftover_entries": [7286, 7276],
"notes": [
- "PR B (TortoiseBots #984). Witch Doctor Zum'rah (7271, spawn row 81524 at 1912.17 1016.11 11.6) is friendly (faction 35, flag IMMUNE_TO_PLAYER 0x100) until area trigger 962 (at_zumrah, centre 1909.27 1015.11 11.5, radius 10) fires, and a trigger is taken only from a client packet. The step is by auto: the lead bot walks to the stand (1903 1014 11.8, 6.4 yd from the centre, 9 yd from him and 6 yd from the Campfire 2061 at 1909.5 1016.1 that a stand on the centre would be in) and the module takes the trigger for it when AiPlayerbot.BotAreaTriggers lists 962 (TortoiseBots #1002); when `unit 7271 hostile` does not hold 5 s later the agent's own character does it (.appear to the lead bot, a walk into the zone, the packet, a walk back). Live 2026-10-15 (tests/zf-probe-events, zf-probe-zumrah): trigger 962 sent from 1909.3 1015.1 turns him from faction 35 to 37 and he yells; he keeps the flag until combat, but aggroes a bot at 27 yd within about 2 s on his own and the flag clears in combat, so the bots can fight him. In combat he summons Zombies (7286) and Dead Heroes (7276) and a Ward (7785): the plan's kill_first and summoned_kill_first name the ward 7785, which the remote driver takes as part of the fight only with encounter evidence (within 10 yd of a hostile that fights the party) and then sends the party at first; boss.tactics.kill_first [7785] is the same for a leader-mode brain. THE GRAVES (owner order 2026-10-15: open the graves and kill what comes out before the boss; core research, src/scripts/dungeons/zulfarrak/zulfarrak.cpp and sql/base): the graveyard (x 1832-1898, y 967-1054) holds 40 Shallow Graves, all chests (type 3, loot 8367, gold 18-129). 31 are entry 128403, each with a linked trap 128972 (a TRAP object of radius 0: only the chest's use fires it, walking past does nothing; its spell 10247 'Summon Zul'Farrak Zombies' summons one Zombie 7286 and one Dead Hero 7276 at the grave); 9 are entry 128308 (script go_shallow_grave: the first use summons a Zombie with 65 %, a Dead Hero with 10 %, nothing with 25 %, for 30 s). Zum'rah's in-fight 'Awaken Zul'Farrak Zombie' (spell 10731, EventAI 727103, every 18 s from 1-5 s into the fight) has effect ACTIVATE_OBJECT on a Shallow Grave of entry 128403 within 100 yd of him that is spawned and still unopened (conditions 122 AND 123: loot state ready); so each opened 128403 grave is one cast fewer, and the unopened ones are his adds (a Zombie and a Dead Hero each). Fixed spots: the 31 graves. The 28 of them more than 30 yd from him (1912.2 1016.1; the three nearer ones, 1887.9 1029.1, 1892.6 1006.4 and 1891.6 1023.1, are 22-28 yd away and left to him) are steps of this leg, in walking order from 1859 1058 (nearest neighbour; every stand is a navmesh point within 4 yd of its grave, reached by a complete path, TestZulfarrakGraveSteps), before the trigger: the carrier bot opens one (loot_object on that exact spawn: `spawn` is the export's gameobject.guid, the open goes by its GUID, range 2 yd), the party fights out what comes out (the driver fights the engaged hostiles until none has fought the party for the quiet time, then the loot wait; the rest gate runs only when a rest line is crossed, no fixed wait per grave) BEFORE the next grave's walk and open, then the next. A resumed run asks `cmd state \u003cguid\u003e` first and skips a grave that is opened already or gone, with a note, so it goes on at the first unopened one. The 9 entry 128308 graves are not opened: Zum'rah's spell does not use them and they only spawn extra mobs. Looting stays off for every other bot (companions_loot false); only the carrier opens graves and only these. The earlier detour via 1854 1055 and 1860 1037 is gone: the stands lead along the graves, and the traps have radius 0. Zombie and Dead Hero packs seen near 1879 1038, 1870 1008 and 1891 1008 (zf-zum2-s2, resumed after a wipe) are NOT spawn rows: the world export has no row of 7286 or 7276 and none of the core's creature spawns in the graveyard, so they are what Zum'rah's spell 10731 and the graves summoned in the attempt before, which stay (the summon spell's duration is long) and are met on the walk to the stand; no pull group is added, the grave steps (before the trigger) are what takes them out. Unverified live: that the Opening spell on a grave fires its linked trap, and how many mobs one opening brings. The route from the courtyard goes by the pyramid's south-west side (1877 1131, 1858 1061), never within 12 yd of the End Door. The leg's first via is 1892.3 1198.4 in the courtyard's east: from the courtyard's west half (1822 1177, 1849 1185, 1832 1180) the shortest navmesh path to the graveyard goes over the pyramid's End Door ramp (1852 1159 z 15, 1863.5 1142.9 z 14, 9 yd from the shut door) and the live hop at 1847.1 1164 z 13.6 (an interpolated point 3.4 yd under the ramp floor) was unreachable six times; every start reaches 1892.3 1198.4 on a path at least 39 yd from the door, and from there the path to 1859 1058 keeps 25 yd from it.",
- "Graves in batches (owner 2026-10-15: make them open 3-6 graveyards, then use aoe to kill them): the 28 graves are in seven plan batches (batch g1 to g7, 3-6 graves, all within 15 yd of the batch centre). The party gathers at the centre, the carrier opens every grave of the batch back to back with no fight between the opens, then the party fights out everything that came out with the bots' area spells on (.bot behavior \u003cbot\u003e aoe on, the module's default). The run's graves_batch (a fixed size) or graves_ramp (start 2, one more after each batch without a wipe, no growth after the first wipe) regroup the same steps; each batch is one graves-batch: note and a remote.grave_batches record.",
+ "PR B (TortoiseBots #984). Witch Doctor Zum'rah (7271, spawn row 81524 at 1912.17 1016.11 11.6) is friendly (faction 35, flag IMMUNE_TO_PLAYER 0x100) until area trigger 962 (at_zumrah, centre 1909.27 1015.11 11.5, radius 10) fires, and a trigger is taken only from a client packet. The step is by auto: the lead bot walks to the stand (1903 1014 11.8, 6.4 yd from the centre, 9 yd from him and 6 yd from the Campfire 2061 at 1909.5 1016.1 that a stand on the centre would be in) and the module takes the trigger for it when AiPlayerbot.BotAreaTriggers lists 962 (TortoiseBots #1002); when `unit 7271 hostile` does not hold 5 s later the agent's own character does it (.appear to the lead bot, a walk into the zone, the packet, a walk back). Live 2026-10-15 (tests/zf-probe-events, zf-probe-zumrah): trigger 962 sent from 1909.3 1015.1 turns him from faction 35 to 37 and he yells; he keeps the flag until combat, but aggroes a bot at 27 yd within about 2 s on his own and the flag clears in combat, so the bots can fight him. In combat he summons Zombies (7286) and Dead Heroes (7276) and a Ward (7785): the plan's kill_first and summoned_kill_first name the ward 7785, which the remote driver takes as part of the fight only with encounter evidence (within 10 yd of a hostile that fights the party) and then sends the party at first; boss.tactics.kill_first [7785] is the same for a leader-mode brain. THE GRAVES (owner order 2026-10-15: open the graves and kill what comes out before the boss; core research, src/scripts/dungeons/zulfarrak/zulfarrak.cpp and sql/base): the graveyard (x 1832-1898, y 967-1054) holds 40 Shallow Graves, all chests (type 3, loot 8367, gold 18-129). 31 are entry 128403, each with a linked trap 128972 (a TRAP object of radius 0: only the chest's use fires it, walking past does nothing; its spell 10247 'Summon Zul'Farrak Zombies' summons one Zombie 7286 and one Dead Hero 7276 at the grave); 9 are entry 128308 (script go_shallow_grave: the first use summons a Zombie with 65 %, a Dead Hero with 10 %, nothing with 25 %, for 30 s). Zum'rah's in-fight 'Awaken Zul'Farrak Zombie' (spell 10731, EventAI 727103, every 18 s from 1-5 s into the fight) has effect ACTIVATE_OBJECT on a Shallow Grave of entry 128403 within 100 yd of him that is spawned and still unopened (conditions 122 AND 123: loot state ready); so each opened 128403 grave is one cast fewer, and the unopened ones are his adds (a Zombie and a Dead Hero each). Fixed spots: the 31 graves. all 31 are steps of this leg (none is left to his Awaken spell 10731: it activates the nearest unopened grave while any is unopened; the three nearest, 1887.94 1029.08, 1892.57 1006.37 and 1891.60 1023.06, are 22-28 yd from him and are the last batch, g8), in walking order from 1859 1058 (nearest neighbour; every stand is a navmesh point within 4 yd of its grave, reached by a complete path, TestZulfarrakGraveSteps), before the trigger: the carrier bot opens one (loot_object on that exact spawn: `spawn` is the export's gameobject.guid, the open goes by its GUID, range 2 yd), the party fights out what comes out (the driver fights the engaged hostiles until none has fought the party for the quiet time, then the loot wait; the rest gate runs only when a rest line is crossed, no fixed wait per grave) BEFORE the next grave's walk and open, then the next. A resumed run asks `cmd state \u003cguid\u003e` first and skips a grave that is opened already or gone, with a note, so it goes on at the first unopened one. The 9 entry 128308 graves are not opened: Zum'rah's spell does not use them and they only spawn extra mobs. Looting stays off for every other bot (companions_loot false); only the carrier opens graves and only these. The earlier detour via 1854 1055 and 1860 1037 is gone: the stands lead along the graves, and the traps have radius 0. Zombie and Dead Hero packs seen near 1879 1038, 1870 1008 and 1891 1008 (zf-zum2-s2, resumed after a wipe) are NOT spawn rows: the world export has no row of 7286 or 7276 and none of the core's creature spawns in the graveyard, so they are what Zum'rah's spell 10731 and the graves summoned in the attempt before, which stay (the summon spell's duration is long) and are met on the walk to the stand; no pull group is added, the grave steps (before the trigger) are what takes them out. Unverified live: that the Opening spell on a grave fires its linked trap, and how many mobs one opening brings. The route from the courtyard goes by the pyramid's south-west side (1877 1131, 1858 1061), never within 12 yd of the End Door. The leg's first via is 1892.3 1198.4 in the courtyard's east: from the courtyard's west half (1822 1177, 1849 1185, 1832 1180) the shortest navmesh path to the graveyard goes over the pyramid's End Door ramp (1852 1159 z 15, 1863.5 1142.9 z 14, 9 yd from the shut door) and the live hop at 1847.1 1164 z 13.6 (an interpolated point 3.4 yd under the ramp floor) was unreachable six times; every start reaches 1892.3 1198.4 on a path at least 39 yd from the door, and from there the path to 1859 1058 keeps 25 yd from it.",
+ "Graves in batches (owner 2026-10-15: make them open 3-6 graveyards, then use aoe to kill them): all 31 graves of 128403 are in eight plan batches (batch g1 to g8, 3-5 graves, all within 15 yd of the batch centre; round 4 opened all 31 in every group): none may stay closed, because Zum'rah's Awaken spell 10731 (EventAI 727103) activates the nearest unopened 128403 within 100 yd of him every 18 s of the fight, so every unfound grave's Zombie and Dead Hero rise in the boss fight itself. The party gathers at the centre, the carrier opens every grave of the batch back to back with no fight between the opens, then the party fights out everything that came out with the bots' area spells on (.bot behavior \u003cbot\u003e aoe on, the module's default). The run's graves_batch (a fixed size) or graves_ramp (start 2, one more after each batch without a wipe, no growth after the first wipe) regroup the same steps; each batch is one graves-batch: note and a remote.grave_batches record.",
"Leftover summons (round 3, slot 4 attempt b, trace zf-r3-s4-b): after step 16 eleven Zombies (7286) and Dead Heroes (7276) from graves 13-16 stood idle at 100% hp 12-30 yd from the lead; the carrier's walk to the grave at 1861 1019 woke all of them within 2 s and the party went from 5 alive to 2. leftover_entries makes the driver pull and fight the idle ones within 35 yd of the next batch (before it), of the batch just done (after it) and of the trigger's stand (before the trigger).",
"Owner 2026-10-15: \"the boss opens all the graveyards, and if not all are opened before, they get pulled while they try to kill the boss. There can be 1 or 2 graveyards left but not more.\" Zum'rah's Awaken Zul'Farrak Zombie (spell 10731, spell_script_target: game object entry 128403 only; his AI script 727103 casts only that) activates an unopened 128403 grave within 100 yd of him; the 128308 graves are not used by it and are not opened. He is friendly (faction 35) until the trigger fires, so the three graves 22-28 yd from him (spawns 27128, 27129, 27132) are opened too, as batch g8 after the far graves: all 31 graves of entry 128403 are opened before the trigger.",
"Wowhead guide (data; the server script wins): kill each Ward (7785) as it appears (kill_first); his Healing Wave and Shadow Bolt Volley want interrupts, which the bots cannot be ordered to do (no cmd verb); fight him in his room with the whole party in it, not pulled out; the Ward summons the skeletons every 5 s (spell 11088), so once all the graves are open the Ward is the add source. Players confirm that opening and clearing all graves first removes the zombie adds.",
@@ -1131,12 +1159,21 @@
"state": "seen"
},
"timeout_s": 60,
- "fail_text": "The cage was used but Weegli Blastfuse did not walk to the top of the stairs within 60 s: the stairs event (instance_zulfarrak.cpp) starts when he arrives there, so it has not started (the cage did not open, or Weegli is dead)."
+ "fail_text": "The cage was used but Weegli Blastfuse did not walk to the top of the stairs within 60 s: the stairs event (instance_zulfarrak.cpp) starts when he arrives there, so it has not started (the cage did not open, or Weegli is dead).",
+ "hold": {
+ "instance_data": 1,
+ "value": 3,
+ "spot": {"x": 1887, "y": 1274, "z": 42},
+ "radius": 30,
+ "timeout_s": 90,
+ "entries": [7787, 7788, 7789, 8876, 8877]
+ }
}
}
],
"notes": [
- "NEEDS A LIVE RUN. Leg 1 of 3 of the stairs event (instance_zulfarrak.cpp, zulfarrak.cpp go_troll_cage): the Sandfury Executioner (7274, spawn row 81552 at 1886.79 1286.76 44.19) is the one pack at the cages; then any of the five Troll Cages (141070-141074; the plan uses 141073 at 1886.88 1298.94 48.2) is used: its script frees Sergeant Bly (7604), Raven (7605), Oro Eyegouge (7606), Weegli Blastfuse (7607) and Murta Grimgut (7608) (faction 250, hostile to trolls, friendly to the party) and sends them to the top of the stairs (1883-1891 1263-1273 z 41-42). When Weegli reaches 1883.9 1263.5 the event is PYRAMID_ARRIVED_AT_STAIR and the next instance update spawns wave 1: the wait ends when he is in view near there. The stand is 3.6 yd from the cage on the platform at z 48.3 (a use is refused beyond about 5 yd). The leg also has the bots' looting off from its start to its end (companions_loot false, the zumrah leg's rule): the shortest walk from Zum'rah's spot (1912.2 1016.1) to the courtyard crosses the graveyard (TestZulfarrakOnNavmesh: 1904 1018, 1899 1019, 1859 1058), where the three graves left to him (1887.9 1029.1, 1892.6 1006.4, 1891.6 1023.1) may still be shut, and a bot that loots opens a Shallow Grave chest and wakes what lies in it. The Executioner is the leg's boss (zf-r5, round 5: the cage open was refused \"no-item\" because nothing looted him): the carrier takes his Executioner's Key (8444, 100% drop, creature_loot_template) with cmd loot after the kill, and the cage (lock 420, which needs that key) is used in after_boss. The leg's mute stays on for the walk; the loader accepts it together with the boss's loot_item."
+ "NEEDS A LIVE RUN. Leg 1 of 3 of the stairs event (instance_zulfarrak.cpp, zulfarrak.cpp go_troll_cage): the Sandfury Executioner (7274, spawn row 81552 at 1886.79 1286.76 44.19) is the one pack at the cages; then any of the five Troll Cages (141070-141074; the plan uses 141073 at 1886.88 1298.94 48.2) is used: its script frees Sergeant Bly (7604), Raven (7605), Oro Eyegouge (7606), Weegli Blastfuse (7607) and Murta Grimgut (7608) (faction 250, hostile to trolls, friendly to the party) and sends them to the top of the stairs (1883-1891 1263-1273 z 41-42). When Weegli reaches 1883.9 1263.5 the event is PYRAMID_ARRIVED_AT_STAIR and the next instance update spawns wave 1: the wait ends when he is in view near there. The stand is 3.6 yd from the cage on the platform at z 48.3 (a use is refused beyond about 5 yd). The leg also has the bots' looting off from its start to its end (companions_loot false, the zumrah leg's rule): the shortest walk from Zum'rah's spot (1912.2 1016.1) to the courtyard crosses the graveyard (TestZulfarrakOnNavmesh: 1904 1018, 1899 1019, 1859 1058), where the three graves left to him (1887.9 1029.1, 1892.6 1006.4, 1891.6 1023.1) may still be shut, and a bot that loots opens a Shallow Grave chest and wakes what lies in it. The Executioner is the leg's boss (zf-r5, round 5: the cage open was refused \"no-item\" because nothing looted him): the carrier takes his Executioner's Key (8444, 100% drop, creature_loot_template) with cmd loot after the kill, and the cage (lock 420, which needs that key) is used in after_boss. The leg's mute stays on for the walk; the loader accepts it together with the boss's loot_item.",
+ "MODULE RUN (TortoiseBots run leader, the rows plan-export writes; 2026-10-21). The rows are: the walk up, the Executioner's boss row, his loot row (item 8444), a move to the stand, the cage's open (item 8444; any party bot that carries the key is its carrier), then the Weegli wait's hold (instance data 1 to value 3) on the stairs top. The open is the event's start step for the run leader; the holds read a script value (flag 8 without 32), so the run leader reads no FAIL or reset into them and a wipe in them regroups and holds the spot again (RearmEventOnWipe goes back to a start step only for an encounter hold): the stairs event is not restartable anyway. Companions loot freely in the module run (--drop-companions-loot): the run leader's loot step counts the key done when ANY party bot carries it. Setup for a module run: give the Executioner's Key 8444 to the lead bot before `.bot run start` (GM additem, the owner's test-run shortcut of 2026-10-21) so a key looted by someone else, or a corpse looted empty, cannot stop the cage; the loot row then reads 'already carries' and goes on. No Mallet is needed: the optional gahzrilla leg is not in the rows."
]
},
{
@@ -1153,7 +1190,15 @@
"state": "seen"
},
"timeout_s": 600,
- "fail_text": "Waves 1 and 2 of the stairs event were not over in 600 s (Nekrum Gutchewer, the first unit of wave 3, was not seen at the foot of the stairs): the party may have lost the top of the stairs, or a wave is still running. The event is not restartable."
+ "fail_text": "Waves 1 and 2 of the stairs event were not over in 600 s (Nekrum Gutchewer, the first unit of wave 3, was not seen at the foot of the stairs): the party may have lost the top of the stairs, or a wave is still running. The event is not restartable.",
+ "hold": {
+ "instance_data": 1,
+ "value": 7,
+ "spot": {"x": 1887, "y": 1274, "z": 42},
+ "radius": 30,
+ "timeout_s": 600,
+ "entries": [7787, 7788, 7789, 8876, 8877]
+ }
}
}
],
@@ -1164,7 +1209,8 @@
"max_s": 10
},
"notes": [
- "NEEDS A LIVE RUN. Leg 2 of 3 of the stairs event: the party holds 1887 1274 42, the spot the climbing trolls are sent to (instance_zulfarrak.cpp SendAddsUpStairs: MovePoint 1880+0..10, 1274, 42), with Bly's crew 5-10 yd below it at 1883-1891 1263-1273. Wave 1 (23 trolls: Slaves 7787, Drudges 7788, Cretins 7789, Acolytes 8876, Zealots 8877) spawns at the foot of the stairs (1874-1899, 1202-1226, z 8.87) and groups of 2, 3, 4, ... run up every 10 s (the last of the 23 after about 50 s); 10 s after the last one dies wave 2 (24 trolls, the same entries) spawns and comes the same way. A wait does not fight: the bots defend themselves at the top and the tank takes the trolls where they end (no formation can be asked of them). Wave 3 spawns the instant wave 2 is dead and is the only one with Nekrum Gutchewer (7796, no spawn row): the wait ends when he is in view. The agent's own character sees creatures out to the server's visibility distance, so he is seen from the top; a driver that cannot see units fails the step at once. Slaves (7787), Cretins (7789) and Acolytes (8876) flee at 15 % health (EventAI) and every unit of a wave must die for the next to start: a runner that has gone off holds the event until it comes back. The leg's rest gate is short on purpose (10 s): the first group of wave 1 is on its way as the party arrives. Wowhead (the Classic guide and the NPC comments, read 2026-10-15; data only) agrees with the script on where to stand: its comments say to STAY ON TOP OF THE TEMPLE while the waves climb, and that after a number of waves the crew goes down the steps while more come. Wowhead says nothing of a formation either."
+ "NEEDS A LIVE RUN. Leg 2 of 3 of the stairs event: the party holds 1887 1274 42, the spot the climbing trolls are sent to (instance_zulfarrak.cpp SendAddsUpStairs: MovePoint 1880+0..10, 1274, 42), with Bly's crew 5-10 yd below it at 1883-1891 1263-1273. Wave 1 (23 trolls: Slaves 7787, Drudges 7788, Cretins 7789, Acolytes 8876, Zealots 8877) spawns at the foot of the stairs (1874-1899, 1202-1226, z 8.87) and groups of 2, 3, 4, ... run up every 10 s (the last of the 23 after about 50 s); 10 s after the last one dies wave 2 (24 trolls, the same entries) spawns and comes the same way. A wait does not fight: the bots defend themselves at the top and the tank takes the trolls where they end (no formation can be asked of them). Wave 3 spawns the instant wave 2 is dead and is the only one with Nekrum Gutchewer (7796, no spawn row): the wait ends when he is in view. The agent's own character sees creatures out to the server's visibility distance, so he is seen from the top; a driver that cannot see units fails the step at once. Slaves (7787), Cretins (7789) and Acolytes (8876) flee at 15 % health (EventAI) and every unit of a wave must die for the next to start: a runner that has gone off holds the event until it comes back. The leg's rest gate is short on purpose (10 s): the first group of wave 1 is on its way as the party arrives. Wowhead (the Classic guide and the NPC comments, read 2026-10-15; data only) agrees with the script on where to stand: its comments say to STAY ON TOP OF THE TEMPLE while the waves climb, and that after a number of waves the crew goes down the steps while more come. Wowhead says nothing of a formation either.",
+ "THE PHASE CHECK (missing vocabulary, 2026-10-20). The pyramid phase is instance data 1, not 0: Tortoise's `EVENT_PYRAMID = 1` (zulfarrak.h) and `GetData(1)` returns `PyramidPhase`, while `GetData(0)` returns 0 (instance_zulfarrak.cpp). The phase values are NOT_STARTED 0, CAGES_OPEN 1, ARRIVED_AT_STAIR 2, WAVE_1 3, PRE_WAVE_2 4, WAVE_2 5, PRE_WAVE_3 6 (wave 3 is spawned in this case), WAVE_3 7 (5 s later, the crew moves to the foot) and KILLED_ALL_TROLLS 8, so 7 is the value this leg ends on. The agent's driver cannot read it: its `wait_for` holds on units, objects and hostiles only, so for the agent this leg waits on the observable proxy (Nekrum 7796, summoned with wave 3 at phase 6, seen at the foot); that is correct while wave 3 is alive or about to spawn, but a resumed run that starts the leg after wave 3 is dead sees no Nekrum and fails the wait at 600 s. The TortoiseBots run leader CAN read it (2026-10-21, feat/zf-full-module-plan): its loader takes a `hold` done by instance data with flag 8 alone (no encounter flag 32) and any param, and ends the hold when the data reads exactly that value (DungeonRunPolicy.h HoldDoneNote). The wait therefore carries a `hold` with `value` 7 (instance data 1, the stairs top 1887 1274 42, radius 30, 600 s, the wave entries 7787 7788 7789 8876 8877), which plan-export writes in place of the wait: the run leader's party holds the top and fights the waves until phase 7 (wave 2 dead, wave 3 spawned, the crew moving down). 7 lasts until every unit of wave 3 is dead, so the 1 s tick cannot miss it; a run resumed after wave 3 is dead reads 8 and the hold runs out at 600 s (the run leader has no 'at least' compare: that is the module's planned flag 128). The cage leg's Weegli wait carries a hold to value 3 (WAVE_1: Weegli arrived, wave 1 spawned; phase 2 lasts one instance update and is not a value to wait for)."
]
},
{
@@ -1212,30 +1258,28 @@
"state": "seen"
},
"timeout_s": 60,
- "fail_text": "Weegli Blastfuse is not at 1877.5 1199.6 (his place when the event is over): the crew walks there when the last troll of wave 3 is dead."
+ "fail_text": "Weegli Blastfuse is not at 1877.5 1199.6 (his place when the event is over): the crew walks there when the last troll of wave 3 is dead.",
+ "hold": {
+ "instance_data": 1,
+ "value": 8,
+ "spot": {"x": 1877.5, "y": 1199.6, "z": 8.9},
+ "radius": 30,
+ "timeout_s": 600,
+ "entries": [7787, 7788, 7789, 8876, 8877]
+ }
}
},
{
"gossip": {
"npc": 7607,
"near": {"x": 1877.52, "y": 1199.63, "z": 8.87},
- "text": "blow",
+ "option": 0,
"by": "tank",
"stand": {"x": 1877.5, "y": 1203.3, "z": 8.9},
"retry_s": 30,
"fail_text": "Weegli's menu has no option to blow up the door: the event is not over (PYRAMID_KILLED_ALL_TROLLS), or he is dead."
}
},
- {
- "wait_for": {
- "unit": {
- "entries": [7607],
- "state": "gone"
- },
- "timeout_s": 120,
- "fail_text": "Weegli Blastfuse was still in view 120 s after the gossip: he walks to the End Door, blows it up and runs off to 1827 1184, where he despawns."
- }
- },
{
"wait_for": {
"gameobject": {
@@ -1244,12 +1288,12 @@
"state": "active"
},
"timeout_s": 60,
- "fail_text": "The End Door (146084) is not open although Weegli Blastfuse (7607) has gone: he opens it only if he reaches it alive and unhurt."
+ "fail_text": "The End Door (146084) is not open 60 s after Weegli Blastfuse's gossip: he opens it only if he reaches it alive and unhurt."
}
}
],
"notes": [
- "NEEDS A LIVE RUN. The End Door (146084 at 1854.53 1142.89 16.08, shut, state ready) opens only when Weegli Blastfuse (7607, level 43, 16.6k health) has blown it up (zulfarrak.cpp npc_weegli_blastfuseAI::DestroyDoor): he walks to 1858.57 1146.35 14.7 as a friendly (faction 35), plants an explosive (game object 144065 at 1856.3 1145 15.5), runs to 1863.77 1176.99, the explosive goes off, the door is used (EVENT_END_DOOR done, saved with the instance: a door blown once stays open for the run's later entries) and he runs on to 1827.1 1184 and despawns. Two things start it: Weegli's own option 'Will you blow up that door now?' (index 0, offered only while PYRAMID_KILLED_ALL_TROLLS) and Bly's option (the bly leg: 10 s after it, Bly's script calls Weegli's DoAction). This leg does Weegli's FIRST, as the Wowhead Classic comments on Weegli, Bly and Ukorz advise ('talk to the goblin first', 'try talking to him before Bly'): he is gone and the door open before the crew turns hostile, so the fight cannot kill him or keep him from the door (a comment: he cannot open the door if he is dead or attacking someone; one that hits him by mistake should not kill him but go back up and reset him). The script has no reason for the order (both orders run the same DestroyDoor); a second DestroyDoor on a Weegli that is still walking would plant a second barrel, which is why the leg waits for him to be gone before the bly leg talks to Bly. The gossip is by the tank, 3.8 yd from his place (1877.5 1199.6). If Weegli died in the waves nothing opens the door: the first wait fails after 60 s. The party waits where it is, 60 yd from the blast."
+ "NEEDS A LIVE RUN. The End Door (146084 at 1854.53 1142.89 16.08, shut, state ready) opens only when Weegli Blastfuse (7607, level 43, 16.6k health) has blown it up (zulfarrak.cpp npc_weegli_blastfuseAI::DestroyDoor): he walks to 1858.57 1146.35 14.7 as a friendly (faction 35), plants an explosive (game object 144065 at 1856.3 1145 15.5), runs to 1863.77 1176.99, the explosive goes off, the door is used (EVENT_END_DOOR done, saved with the instance: a door blown once stays open for the run's later entries) and he runs on to 1827.1 1184 and despawns. Two things start it: Weegli's own option 'Will you blow up that door now?' (index 0, offered only while PYRAMID_KILLED_ALL_TROLLS) and Bly's option (the bly leg: 10 s after it, Bly's script calls Weegli's DoAction). This leg does Weegli's FIRST, as the Wowhead Classic comments on Weegli, Bly and Ukorz advise ('talk to the goblin first', 'try talking to him before Bly'): he is gone and the door open before the crew turns hostile, so the fight cannot kill him or keep him from the door (a comment: he cannot open the door if he is dead or attacking someone; one that hits him by mistake should not kill him but go back up and reset him). The script has no reason for the order (both orders run the same DestroyDoor); a second DestroyDoor on a Weegli that is still walking would plant a second barrel, which is why the bly leg begins by waiting for him to be gone (that wait stood in this leg until 2026-10-21; the run leader has no unit wait, and the door's active state, the wait after the gossip, is what it can read). The gossip is by the tank, 3.8 yd from his place (1877.5 1199.6). If Weegli died in the waves nothing opens the door: the first wait fails after 60 s. The party waits where it is, 60 yd from the blast. THE RUN LEADER'S FORM (2026-10-21, TortoiseBots feat/run-gossip-step): the first wait carries a hold on instance data 1 = 8 (PYRAMID_KILLED_ALL_TROLLS, the last value of the pyramid phase, zulfarrak.h:40) on Weegli's place, 600 s, the wave entries; the gossip is the option number 0 (the menu's index: Weegli's menu has exactly one line, ADD_GOSSIP_ITEM at zulfarrak.cpp:497 inside OnGossipHello_npc_weegli_blastfuse, :490-509, and OnGossipSelect :479-488 acts on that line, GOSSIP_ACTION_INFO_DEF + 1), which plan-export writes as a gossip row (param 1 = the number `cmd gossip` takes, counted from 1: .bot cmd gossip 7607 BOT 1); the agent's own driver still runs the wait for Weegli and the gossip as written. The NPC must be in AiPlayerbot.BotGossipNpcs (7607 listed; Bly's 7604 too for the agent's bly leg)."
]
},
{
@@ -1257,6 +1301,16 @@
"to": {"x": 1884, "y": 1212, "z": 9},
"skip": [7607, 7269],
"steps": [
+ {
+ "wait_for": {
+ "unit": {
+ "entries": [7607],
+ "state": "gone"
+ },
+ "timeout_s": 120,
+ "fail_text": "Weegli Blastfuse was still in view 120 s after the gossip: he walks to the End Door, blows it up and runs off to 1827 1184, where he despawns."
+ }
+ },
{
"wait_for": {
"unit": {
@@ -1299,7 +1353,7 @@
"first": [7608, 7606, 7605]
},
"notes": [
- "NEEDS A LIVE RUN. Sergeant Bly (7604, level 45, 18.5k health: Shield Bash 11972, Revenge 12170) after the event: his only gossip option is 'That's it! I'm tired of helping you out. It's time we settled things on the battlefield!' (menu 1517 while PYRAMID_KILLED_ALL_TROLLS; 1515 before the cages, 1516 during). zulfarrak.cpp npc_sergeant_blyAI: 10 s after the option he turns hostile (faction 14) and attacks the one who talked, Raven (7605, level 44, 6.2k, Gouge 12540), Oro Eyegouge (7606, level 44, 4.4k, mana, spell list 76060: Immolate 11962, Curse of Weakness 12741, Shadow Bolt 9613) and Murta Grimgut (7608, level 44, 5k, mana, a healer: Renew 11640, Power Word: Shield 11974, 10577) turn hostile too, and WEEGLI IS NOT TURNED HOSTILE: Bly's script calls Weegli's DoAction, which makes him friendly (faction 35) and has him walk to the End Door and blow it up (see weegli_door). Weegli is already gone (the weegli_door leg ran first), and if he were not, Bly's script would call his DoAction again. The kill order is the healer, the caster, the rogue, then Bly (boss.first). The gossip is by the tank (the one Bly attacks); a menu that lacks the option is asked again for 30 s. Weegli's script heals the whole crew to full the moment the event ends. Wowhead (guide, comments): Oro's area damage (Rain of Fire) and Murta's healing make this fight hard, so Murta and Oro die before Bly (boss.first: Murta, Oro, Raven); Murta is the priest with Holy Smite, Renew, Heal and Power Word: Shield, Oro the warlock with Shadow Bolt, Immolate, Rain of Fire and Curse of Weakness, Raven the rogue with Poison, Backstab and Gouge; Bly is 18k health with Revenge and Shield Bash and has no loot table (the guide) but the Divino-matic Rod of the quest 'Divino-matic Rod' (item 8548). Two Wowhead differences from this server: a comment says the crew uses Hearthstones and leaves after the event if the party rests (the script has no such thing: they stand at their places), and that talking to Weegli, waiting for them to leave, then Bly leaves Bly alone to fight; neither applies here."
+ "NEEDS A LIVE RUN. Sergeant Bly (7604, level 45, 18.5k health: Shield Bash 11972, Revenge 12170) after the event: his only gossip option is 'That's it! I'm tired of helping you out. It's time we settled things on the battlefield!' (menu 1517 while PYRAMID_KILLED_ALL_TROLLS; 1515 before the cages, 1516 during). zulfarrak.cpp npc_sergeant_blyAI: 10 s after the option he turns hostile (faction 14) and attacks the one who talked, Raven (7605, level 44, 6.2k, Gouge 12540), Oro Eyegouge (7606, level 44, 4.4k, mana, spell list 76060: Immolate 11962, Curse of Weakness 12741, Shadow Bolt 9613) and Murta Grimgut (7608, level 44, 5k, mana, a healer: Renew 11640, Power Word: Shield 11974, 10577) turn hostile too, and WEEGLI IS NOT TURNED HOSTILE: Bly's script calls Weegli's DoAction, which makes him friendly (faction 35) and has him walk to the End Door and blow it up (see weegli_door). Weegli is already gone (the weegli_door leg ran first), and if he were not, Bly's script would call his DoAction again. The kill order is the healer, the caster, the rogue, then Bly (boss.first). The gossip is by the tank (the one Bly attacks); a menu that lacks the option is asked again for 30 s. Weegli's script heals the whole crew to full the moment the event ends. Wowhead (guide, comments): Oro's area damage (Rain of Fire) and Murta's healing make this fight hard, so Murta and Oro die before Bly (boss.first: Murta, Oro, Raven); Murta is the priest with Holy Smite, Renew, Heal and Power Word: Shield, Oro the warlock with Shadow Bolt, Immolate, Rain of Fire and Curse of Weakness, Raven the rogue with Poison, Backstab and Gouge; Bly is 18k health with Revenge and Shield Bash and has no loot table (the guide) but the Divino-matic Rod of the quest 'Divino-matic Rod' (item 8548). Two Wowhead differences from this server: a comment says the crew uses Hearthstones and leaves after the event if the party rests (the script has no such thing: they stand at their places), and that talking to Weegli, waiting for them to leave, then Bly leaves Bly alone to fight; neither applies here. The leg begins with the wait for Weegli to be gone (it stood in weegli_door until 2026-10-21). THE RUN LEADER LEAVES THIS LEG OUT (2026-10-21): it has no boss that turns hostile later, so the export is made with `plan-export --skip-legs bly`; Bly's menu is one line (index 0, zulfarrak.cpp:184 inside OnGossipHello_npc_sergeant_bly, :178-194), kept as text here until the leg is exported."
]
},
{
diff --git a/internal/dashboard/card.go b/internal/dashboard/card.go
index 49896ac8..cd2e87f9 100644
--- a/internal/dashboard/card.go
+++ b/internal/dashboard/card.go
@@ -136,6 +136,7 @@ type LeaderInfo struct {
Kind string `json:"kind,omitempty"`
Phase string `json:"phase,omitempty"`
Note string `json:"note,omitempty"`
+ Left int `json:"left,omitempty"` // packs the run leader left alive
Step int `json:"step"`
Steps int `json:"steps"`
// Text is the status in plain words: "Run leader: Sentinel · pull 7/41 · pulling".
@@ -322,6 +323,7 @@ type rawReport struct {
Kind string `json:"kind"`
Phase string `json:"phase"`
Note string `json:"note"`
+ Left int `json:"left,omitempty"`
Lines int `json:"lines"`
} `json:"observe"`
PlanReloads []struct {
@@ -510,7 +512,7 @@ func fillRun(c *Card, rep *rawReport, ds rawDungeon, in Inputs) {
c.Counters.OrdersSent, c.Counters.OrdersRefused, c.Counters.OrdersUnanswered = r.OrdersSent, r.OrdersRefused, r.OrdersUnanswered
c.Counters.Unreachable = r.Unreachable
if o := r.Observe; o != nil && o.Lines > 0 {
- c.Run.Leader = leaderInfo(tbm.RunStatus{State: o.State, Step: o.Step, Steps: o.Steps, Leg: o.Leg, Kind: o.Kind, Phase: o.Phase, Note: o.Note})
+ c.Run.Leader = leaderInfo(tbm.RunStatus{State: o.State, Step: o.Step, Steps: o.Steps, Leg: o.Leg, Kind: o.Kind, Phase: o.Phase, Note: o.Note, Left: o.Left})
if c.Run.State == "running" {
c.Run.Phase = "leader"
}
@@ -541,7 +543,7 @@ func fillRun(c *Card, rep *rawReport, ds rawDungeon, in Inputs) {
// leaderInfo is the card's view of a run leader status, with its plain-words line.
func leaderInfo(st tbm.RunStatus) *LeaderInfo {
- li := &LeaderInfo{State: st.State, Leg: st.Leg, Kind: st.Kind, Phase: st.Phase, Note: st.Note, Step: st.Step, Steps: st.Steps}
+ li := &LeaderInfo{State: st.State, Leg: st.Leg, Kind: st.Kind, Phase: st.Phase, Note: st.Note, Left: st.Left, Step: st.Step, Steps: st.Steps}
text := st.Text()
if text != "" {
text = strings.ToUpper(text[:1]) + text[1:]
@@ -563,6 +565,11 @@ func leaderInfo(st tbm.RunStatus) *LeaderInfo {
default:
li.Text = "Run leader: " + text
}
+ if st.Left == 1 {
+ li.Text += ", 1 pack left"
+ } else if st.Left > 1 {
+ li.Text += fmt.Sprintf(", %d packs left", st.Left)
+ }
return li
}
diff --git a/internal/dashboard/digest.go b/internal/dashboard/digest.go
index 9d5f8803..c8604027 100644
--- a/internal/dashboard/digest.go
+++ b/internal/dashboard/digest.go
@@ -109,6 +109,7 @@ type RunDigest struct {
Ended bool `json:"ended"`
Reason string `json:"reason,omitempty"`
LegsDone int `json:"legs_done"`
+ Left int `json:"left,omitempty"` // packs the run leader left alive
Legs []DLeg `json:"legs"`
Paths []DPath `json:"paths"`
Stalls []DStall `json:"stalls"`
@@ -140,6 +141,7 @@ type digestBuilder struct {
ended bool
reason string
endSeconds float64
+ left int // packs the run leader left alive, from the newest run_leader trace event
legs []DLeg
stalls []DStall
refusals, deaths []DMark
@@ -391,6 +393,14 @@ var refusalCodes = map[string]bool{"unreachable": true, "off-mesh": true, "no-lo
func (b *digestBuilder) event(ev string, t int64, line []byte) {
ts := b.sec(t)
switch ev {
+ case "run_leader":
+ var e struct {
+ Left float64 `json:"left"`
+ }
+ if json.Unmarshal(line, &e) == nil {
+ b.left = int(e.Left)
+ }
+ return
case "leg":
var e struct {
Leg int `json:"leg"`
@@ -571,7 +581,7 @@ func (b *digestBuilder) event(ev string, t int64, line []byte) {
// Snapshot is the digest so far. minutes > 0 keeps only the last minutes of every walk (the stalls, marks and the rest are not cut).
func (b *digestBuilder) Snapshot(minutes int) *RunDigest {
- d := &RunDigest{Run: b.run, Plan: b.plan, Map: b.mapID, Mode: b.mode, Lead: b.lead, StartUnix: b.startUnix, Ended: b.ended, Reason: b.reason,
+ d := &RunDigest{Run: b.run, Plan: b.plan, Map: b.mapID, Mode: b.mode, Lead: b.lead, StartUnix: b.startUnix, Ended: b.ended, Reason: b.reason, Left: b.left,
Seconds: round1(float64(b.lastT) / 1000), Legs: append([]DLeg{}, b.legs...), Stalls: append([]DStall{}, b.stalls...),
Refusals: append([]DMark{}, b.refusals...), Deaths: append([]DMark{}, b.deaths...), Rests: append([]DMark{}, b.rests...), Gathers: append([]DMark{}, b.gathers...),
Pulls: append([]DPull{}, b.pulls...), Objects: append([]DObject{}, b.objects...), Paths: []DPath{}}
diff --git a/internal/dashboard/history.go b/internal/dashboard/history.go
index f772d8ad..4877eb0d 100644
--- a/internal/dashboard/history.go
+++ b/internal/dashboard/history.go
@@ -253,7 +253,8 @@ type GhostAnswer struct {
Run string `json:"run,omitempty"`
Folder string `json:"folder,omitempty"`
Seconds float64 `json:"seconds,omitempty"`
- Complete bool `json:"complete,omitempty"` // the run cleared the whole dungeon
+ Complete bool `json:"complete,omitempty"` // the run cleared the whole dungeon (done with no packs left)
+ Left int `json:"left,omitempty"` // packs the run leader left alive
LegsDone int `json:"legs_done,omitempty"`
PlanLegs int `json:"plan_legs,omitempty"` // the plan's sections, when the page said
Legs []DLeg `json:"legs,omitempty"`
@@ -261,9 +262,10 @@ type GhostAnswer struct {
Runs int `json:"runs"` // runs of the plan looked at
}
-// Ghost picks the best earlier run of plan, not counting the run called exclude: the one that got through the most sections, the quicker
-// of equals. A run that ended "done" and started at the first section counts as a clear of the whole dungeon, or, when planLegs says how
-// many sections the plan has, a run with that many done. The answer is cached until the folders are listed again.
+// Ghost picks the best earlier run of plan, not counting the run called exclude: the one that got through the most sections, and of
+// equals a confirmed clean clear before any other run, then the quicker. A run that ended "done", started at the first section and left no packs alive counts as a clear of the whole dungeon,
+// or, when planLegs says how many sections the plan has, a run with that many done and none left. The answer is cached until the
+// folders are listed again.
func (h *History) Ghost(plan, exclude string, planLegs int) []byte {
h.mu.Lock()
defer h.mu.Unlock()
@@ -295,19 +297,20 @@ func (h *History) Ghost(plan, exclude string, planLegs int) []byte {
if d == nil {
continue
}
- if best == nil || d.LegsDone > best.LegsDone || (d.LegsDone == best.LegsDone && d.Seconds < best.Seconds) {
+ if best == nil || d.LegsDone > best.LegsDone || (d.LegsDone == best.LegsDone && ghostBetter(f, d, bestF, best)) {
best, bestF = d, f
}
}
+
if best == nil {
ans.Why = "no earlier run of " + plan + " in the tests folder"
return h.keepGhost(key, ans)
}
- ans.Found, ans.Run, ans.Folder, ans.Seconds, ans.LegsDone, ans.Legs = true, best.Run, bestF.folder, best.Seconds, best.LegsDone, best.Legs
+ ans.Found, ans.Run, ans.Folder, ans.Seconds, ans.LegsDone, ans.Left, ans.Legs = true, best.Run, bestF.folder, best.Seconds, best.LegsDone, best.Left, best.Legs
if planLegs > 0 {
- ans.Complete = best.LegsDone >= planLegs
+ ans.Complete = best.LegsDone >= planLegs && best.Left == 0
} else {
- ans.Complete = bestF.reason == "done" && bestF.fromLeg == 0
+ ans.Complete = confirmedClear(bestF, best)
}
for _, p := range best.Paths {
if p.Lead {
@@ -321,6 +324,23 @@ func (h *History) Ghost(plan, exclude string, planLegs int) []byte {
return h.keepGhost(key, ans)
}
+// confirmedClear is a run that cleared the whole dungeon for sure: it ended
+// "done", started at the first section and left no packs alive. An
+// interrupted run never reported a final count, so its Left of 0 proves
+// nothing. Complete and the ghost tie-break share this predicate.
+func confirmedClear(f runFile, d *RunDigest) bool {
+ return f.reason == "done" && f.fromLeg == 0 && d.Left == 0
+}
+
+// ghostBetter says whether d is the better ghost of two runs that finished the same sections: a confirmed clean clear beats any other run, and of two alike runs the quicker wins.
+func ghostBetter(df runFile, d *RunDigest, bestF runFile, best *RunDigest) bool {
+ dClean, bestClean := confirmedClear(df, d), confirmedClear(bestF, best)
+ if dClean != bestClean {
+ return dClean
+ }
+ return d.Seconds < best.Seconds
+}
+
func (h *History) keepGhost(key string, ans GhostAnswer) []byte {
b, _ := json.Marshal(ans)
h.ghosts[key] = cachedJSON{scan: h.scanned, body: b}
diff --git a/internal/dashboard/leader_test.go b/internal/dashboard/leader_test.go
index 391f4e84..96cfcefb 100644
--- a/internal/dashboard/leader_test.go
+++ b/internal/dashboard/leader_test.go
@@ -44,6 +44,22 @@ func TestCardShowsTheRunLeader(t *testing.T) {
}
}
+// A run leader that left packs alive says so on the card.
+func TestCardShowsPacksLeftByTheRunLeader(t *testing.T) {
+ d := observedDungeon("done", 41, "archaedas", "boss", "pull", "plan done", "done")
+ d = strings.Replace(d, `"lines":9}`, `"left":2,"lines":9}`, 1)
+ c := BuildCard(Inputs{Agent: Agent{ID: "1"}, Now: t0, Status: json.RawMessage(statusJSON), Dungeon: json.RawMessage(d)})
+ l := c.Run.Leader
+ if l == nil || l.Left != 2 || l.Text != "Run leader: finished all 41 steps, 2 packs left" {
+ t.Fatalf("%+v", l)
+ }
+ d = strings.Replace(observedDungeon("running", 7, "sentinel", "pull", "pull", "pulling 4851", "running"), `"lines":9}`, `"left":1,"lines":9}`, 1)
+ c = BuildCard(Inputs{Agent: Agent{ID: "1"}, Now: t0, Status: json.RawMessage(statusJSON), Dungeon: json.RawMessage(d)})
+ if l = c.Run.Leader; l == nil || l.Text != "Run leader: Sentinel · pull 7/41 · pulling, 1 pack left" {
+ t.Fatalf("%+v", l)
+ }
+}
+
// A driven run's card has no run leader.
func TestCardWithoutRunLeader(t *testing.T) {
c := BuildCard(Inputs{Agent: Agent{ID: "1"}, Now: t0, Status: json.RawMessage(statusJSON), Dungeon: json.RawMessage(dungeonJSON("leg 0 (one): walk to 1 2 3", 0))})
diff --git a/internal/dashboard/minimap.go b/internal/dashboard/minimap.go
index e2f38a6f..6b7cffb8 100644
--- a/internal/dashboard/minimap.go
+++ b/internal/dashboard/minimap.go
@@ -9,20 +9,50 @@ import (
"sort"
"strconv"
"strings"
+ "unicode/utf8"
"github.com/pjerra/turtle-agent/internal/minimap"
)
-// MinimapLayer is one background image of a map, ready for the page: the rectangle it covers in the drawing's plane (the
-// plane Project makes) and the URL it is served at. The images come from `turtle-agent minimap-extract` and stay local.
+// The page always gets the three picture slots of a map in the order Zul'Farrak has them: the world map (the main image, then
+// a slot per floor), the minimap and the navmesh. A kind the folder has no image for is still listed, marked unavailable with
+// one line of reason, so the picker shows "Minimap (none)" and why instead of silently dropping the entry.
+const (
+ maxStatus = 1 << 16 // .status.json is at most this big; real ones are a few hundred bytes
+ maxReasonRunes = 160 // longer reason strings are cut, so a hand-written status file cannot bloat every page
+)
+
+var (
+ kindOrder = []string{"worldmap", "minimap", "navmesh"}
+ kindTitle = map[string]string{"worldmap": "World map", "minimap": "Minimap", "navmesh": "Navmesh"}
+ kindWhy = map[string]string{
+ "worldmap": "No world map picture: the client has no WorldMapArea row for this map",
+ "minimap": "No minimap picture: indoor dungeon (the client draws it from WMO tiles, which minimap-extract does not compose yet)",
+ "navmesh": "No navmesh picture: minimap-extract ran without --mmaps, or the server has no mmaps for this map",
+ }
+)
+
+// MinimapPicture is the rectangle a picture covers in the drawing's plane and the URL it is served at.
+type MinimapPicture struct {
+ URL string `json:"url"`
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ W float64 `json:"w"`
+ H float64 `json:"h"`
+}
+
+// MinimapLayer is one picture slot of a map, ready for the page: the rectangle it covers in the drawing's plane (the plane
+// Project makes) and the URL it is served at. An unavailable slot has no picture fields (the embedded pointer is nil) and a
+// reason instead. The images come from `turtle-agent minimap-extract` and stay local.
type MinimapLayer struct {
- Name string `json:"name"` // what the page's picker shows: "worldmap", "minimap", "worldmap 2f", ...
- Source string `json:"source"` // minimap, worldmap or navmesh
- URL string `json:"url"`
- X float64 `json:"x"`
- Y float64 `json:"y"`
- W float64 `json:"w"`
- H float64 `json:"h"`
+ Name string `json:"name"` // what the page's picker shows, the same as Label: "World map", "World map 2f", "Minimap", ...
+ Source string `json:"source"` // minimap, worldmap or navmesh
+ Kind string `json:"kind"` // the slot: worldmap, minimap or navmesh
+ Floor string `json:"floor"` // "" or the floor suffix of a world map slot: "2f", "3f", ...
+ Label string `json:"label"` // the panel's text for the slot
+ Available bool `json:"available"`
+ Reason string `json:"reason,omitempty"` // one line, for a slot without a picture
+ *MinimapPicture
}
// OverlayRect is where an image with the world edges of m lies in the drawing's plane. World x is north and world y is west;
@@ -35,77 +65,194 @@ func OverlayRect(m minimap.Meta) (x, y, w, h float64) {
}
var (
+ // minimapFile is what /minimap/ serves: only images. The status file is a folder file the server reads itself, never one it hands out.
minimapFile = regexp.MustCompile(`^(\d{1,9})(?:\.(minimap|worldmap|navmesh)(-\d+f)?)?\.(json|png)$`)
- layerName = regexp.MustCompile(`^\d{1,9}\.(minimap|worldmap|navmesh)(-\d+f)?\.json$`)
+ layerName = regexp.MustCompile(`^\d{1,9}\.(minimap|worldmap|navmesh|status)(-\d+f)?\.json$`)
)
-// MinimapLayers lists the images the folder has for a map: the main one (.png) first, then the others by name.
-func MinimapLayers(dir string, id uint32) []MinimapLayer {
- if dir == "" {
+// readMeta reads the JSON beside a picture of the folder; the picture itself must be there too.
+func readMeta(dir, file string) (minimap.Meta, bool) {
+ full, err := SafeJoin(dir, file)
+ if err != nil {
+ return minimap.Meta{}, false
+ }
+ st, err := os.Stat(full)
+ if err != nil || st.Size() > 1<<16 {
+ return minimap.Meta{}, false
+ }
+ b, err := os.ReadFile(full)
+ if err != nil {
+ return minimap.Meta{}, false
+ }
+ var m minimap.Meta
+ if json.Unmarshal(b, &m) != nil || m.Width <= 0 || m.Height <= 0 || m.West <= m.East || m.North <= m.South {
+ return minimap.Meta{}, false
+ }
+ if _, err := SafeJoin(dir, strings.TrimSuffix(file, ".json")+".png"); err != nil {
+ return minimap.Meta{}, false
+ }
+ return m, true
+}
+
+// statusReasons reads .status.json, written by minimap-extract and the operator: {"minimap": {"reason": "..."}, ...}. An
+// unreadable, oversized or malformed file gives no reasons at all, so the built-in line of each kind stands. Names are held to
+// the same allow-list as every other file of the folder.
+func statusReasons(dir, base string) map[string]string {
+ name := base + ".status.json"
+ if !layerName.MatchString(name) {
return nil
}
- read := func(file string) (minimap.Meta, bool) {
- full, err := SafeJoin(dir, file)
- if err != nil {
- return minimap.Meta{}, false
- }
- st, err := os.Stat(full)
- if err != nil || st.Size() > 1<<16 {
- return minimap.Meta{}, false
+ full, err := SafeJoin(dir, name)
+ if err != nil {
+ return nil
+ }
+ st, err := os.Stat(full)
+ if err != nil || st.Size() > maxStatus {
+ return nil
+ }
+ b, err := os.ReadFile(full)
+ if err != nil {
+ return nil
+ }
+ var raw map[string]struct {
+ Reason string `json:"reason"`
+ }
+ if json.Unmarshal(b, &raw) != nil {
+ return nil
+ }
+ out := map[string]string{}
+ for kind, v := range raw {
+ if _, ok := kindTitle[kind]; !ok {
+ continue
}
- b, err := os.ReadFile(full)
- if err != nil {
- return minimap.Meta{}, false
+ if r := strings.TrimSpace(v.Reason); r != "" {
+ out[kind] = shortReason(r)
}
- var m minimap.Meta
- if json.Unmarshal(b, &m) != nil || m.Width <= 0 || m.Height <= 0 || m.West <= m.East || m.North <= m.South {
- return minimap.Meta{}, false
+ }
+ return out
+}
+
+// shortReason makes a hand-written reason safe for one line of the panel: a line break, a tab and any other control character
+// becomes a space, and the text is cut to 160 characters.
+func shortReason(r string) string {
+ r = strings.Map(func(c rune) rune {
+ if c < 0x20 || (c >= 0x7f && c <= 0x9f) {
+ return ' '
}
- return m, true
+ return c
+ }, r)
+ if utf8.RuneCountInString(r) <= maxReasonRunes {
+ return r
}
- base := strconv.FormatUint(uint64(id), 10)
- layer := func(file, name string, m minimap.Meta) MinimapLayer {
- x, y, w, h := OverlayRect(m)
- return MinimapLayer{Name: name, Source: m.Source, URL: "/minimap/" + strings.TrimSuffix(file, ".json") + ".png", X: x, Y: y, W: w, H: h}
+ rs := []rune(r)
+ return string(rs[:maxReasonRunes])
+}
+
+// MinimapSlots lists the three picture slots of a map in Zul'Farrak's order. The folder's own .[-f].json is the
+// slot of that source; the main .json is used only for a source the folder has no file of (an old folder), so a picture is
+// never listed twice. A slot with no picture is unavailable, with the reason from .status.json or the built-in line. An
+// empty folder gives three unavailable slots; no folder at all gives nil (the route then answers 404, as before).
+func MinimapSlots(dir string, id uint32) []MinimapLayer {
+ if dir == "" {
+ return nil
}
- var out []MinimapLayer
- mainSrc := ""
- if m, ok := read(base + ".json"); ok {
- out = append(out, layer(base+".json", m.Source, m))
- mainSrc = m.Source
+ base := strconv.FormatUint(uint64(id), 10)
+ type found struct {
+ m minimap.Meta
+ stem string // the file without .json: ".worldmap-2f" or, for the stand-in main picture, ""
+ floor string
}
+ byKind := map[string][]found{}
ents, _ := os.ReadDir(dir)
- var names []string
for _, e := range ents {
n := e.Name()
- if layerName.MatchString(n) && strings.HasPrefix(n, base+".") && n != base+"."+mainSrc+".json" {
- names = append(names, n)
+ if !strings.HasPrefix(n, base+".") || !layerName.MatchString(n) {
+ continue
+ }
+ // ".worldmap-2f.json" -> source worldmap, floor 2f
+ rest := strings.TrimSuffix(strings.TrimPrefix(n, base+"."), ".json")
+ kind, floor := rest, ""
+ if i := strings.IndexByte(rest, '-'); i >= 0 {
+ kind, floor = rest[:i], rest[i+1:]
+ }
+ if kind == "status" {
+ continue
+ }
+ if m, ok := readMeta(dir, n); ok {
+ byKind[kind] = append(byKind[kind], found{m, strings.TrimSuffix(n, ".json"), floor})
}
}
- sort.Strings(names)
- for _, n := range names {
- if m, ok := read(n); ok {
- // ".worldmap-2f.json" -> "worldmap 2f"
- name := strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(n, base+"."), ".json"), "-", " ")
- out = append(out, layer(n, name, m))
+ main, mainOK := readMeta(dir, base+".json")
+ reasons := statusReasons(dir, base)
+
+ slot := func(kind, floor, stem string, m *minimap.Meta) MinimapLayer {
+ l := MinimapLayer{Kind: kind, Floor: floor, Source: kind, Label: kindTitle[kind]}
+ if floor != "" {
+ l.Label += " " + floor
+ }
+ l.Name = l.Label
+ if m == nil {
+ l.Reason = reasons[kind]
+ if l.Reason == "" {
+ l.Reason = kindWhy[kind]
+ }
+ return l
+ }
+ x, y, w, h := OverlayRect(*m)
+ l.Source, l.Available = m.Source, true
+ l.MinimapPicture = &MinimapPicture{URL: "/minimap/" + stem + ".png", X: x, Y: y, W: w, H: h}
+ return l
+ }
+ pic := func(kind, floor string) (found, bool) {
+ for _, f := range byKind[kind] {
+ if f.floor == floor {
+ return f, true
+ }
+ }
+ // no file of its own: an old folder's main picture stands in for its one source
+ if floor == "" && mainOK && main.Source == kind {
+ return found{main, base, ""}, true
+ }
+ return found{}, false
+ }
+
+ out := make([]MinimapLayer, 0, len(kindOrder)+2)
+ var floors []string
+ for _, f := range byKind["worldmap"] {
+ if f.floor != "" {
+ floors = append(floors, f.floor)
+ }
+ }
+ sort.Slice(floors, func(i, j int) bool {
+ a, _ := strconv.Atoi(strings.TrimSuffix(floors[i], "f"))
+ b, _ := strconv.Atoi(strings.TrimSuffix(floors[j], "f"))
+ return a < b
+ })
+ for _, kind := range kindOrder {
+ if f, ok := pic(kind, ""); ok {
+ out = append(out, slot(kind, "", f.stem, &f.m))
+ } else {
+ out = append(out, slot(kind, "", "", nil))
+ }
+ if kind == "worldmap" {
+ for _, fl := range floors {
+ if f, ok := pic("worldmap", fl); ok {
+ out = append(out, slot("worldmap", fl, f.stem, &f.m))
+ }
+ }
}
}
return out
}
-// serveMinimapLayers answers /api/minimap/ with the layers, 404 for a map without images.
+// serveMinimapLayers answers /api/minimap/ with the three slots, 404 only when no folder is configured.
func serveMinimapLayers(w http.ResponseWriter, r *http.Request, dir string) {
id, err := strconv.ParseUint(strings.TrimPrefix(r.URL.Path, "/api/minimap/"), 10, 32)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- ls := MinimapLayers(dir, uint32(id))
- if len(ls) == 0 {
+ if err != nil || dir == "" {
http.NotFound(w, r)
return
}
- b, _ := json.Marshal(ls)
+ b, _ := json.Marshal(MinimapSlots(dir, uint32(id)))
writeJSON(w, b)
}
diff --git a/internal/dashboard/minimap_test.go b/internal/dashboard/minimap_test.go
index c1ef1e0a..c5a833c1 100644
--- a/internal/dashboard/minimap_test.go
+++ b/internal/dashboard/minimap_test.go
@@ -98,37 +98,231 @@ func writeLayerFiles(t *testing.T, dir, stem string, m minimap.Meta) {
}
}
-func TestMinimapLayersAndRoutes(t *testing.T) {
+func slotLabels(ls []MinimapLayer) string {
+ var names []string
+ for _, l := range ls {
+ names = append(names, l.Label)
+ }
+ return strings.Join(names, ", ")
+}
+
+func landOf(t *testing.T, dir string, id uint32) [3]MinimapLayer {
+ t.Helper()
+ ls := MinimapSlots(dir, id)
+ if len(ls) != 3 {
+ t.Fatalf("slots = %d (%s), want the three kinds", len(ls), slotLabels(ls))
+ }
+ return [3]MinimapLayer{ls[0], ls[1], ls[2]}
+}
+
+// The folder of Zul'Farrak: the main picture is the world map's copy, and every source has its own file. The main copy is not
+// listed a second time.
+func TestMinimapSlotsZulFarrak(t *testing.T) {
dir := t.TempDir()
- writeLayerFiles(t, dir, "70", uldamanWorldMap)
- writeLayerFiles(t, dir, "70.worldmap", uldamanWorldMap) // the main image's own copy: not listed twice
+ writeLayerFiles(t, dir, "209", uldamanWorldMap)
+ writeLayerFiles(t, dir, "209.worldmap", uldamanWorldMap)
+ mini := uldamanWorldMap
+ mini.Source = "minimap"
+ writeLayerFiles(t, dir, "209.minimap", mini)
nav := uldamanWorldMap
nav.Source = "navmesh"
- writeLayerFiles(t, dir, "70.navmesh", nav)
- floor := uldamanWorldMap
- writeLayerFiles(t, dir, "70.worldmap-2f", floor)
- os.WriteFile(filepath.Join(dir, "209.json"), []byte("{broken"), 0o644)
- os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("no"), 0o644)
+ writeLayerFiles(t, dir, "209.navmesh", nav)
- ls := MinimapLayers(dir, 70)
- var names []string
- for _, l := range ls {
- names = append(names, l.Name+"="+l.URL)
+ ls := MinimapSlots(dir, 209)
+ if got, want := slotLabels(ls), "World map, Minimap, Navmesh"; got != want {
+ t.Fatalf("labels = %s, want %s", got, want)
+ }
+ for i, kind := range []string{"worldmap", "minimap", "navmesh"} {
+ l := ls[i]
+ if l.Kind != kind || l.Floor != "" || l.Name != l.Label || !l.Available || l.Reason != "" {
+ t.Errorf("%d = %+v", i, l)
+ }
}
- if got, want := strings.Join(names, " "), "worldmap=/minimap/70.png navmesh=/minimap/70.navmesh.png worldmap 2f=/minimap/70.worldmap-2f.png"; got != want {
- t.Errorf("layers = %s\nwant %s", got, want)
+ if ls[0].URL != "/minimap/209.worldmap.png" || ls[1].URL != "/minimap/209.minimap.png" || ls[2].URL != "/minimap/209.navmesh.png" {
+ t.Errorf("urls = %s, %s, %s", ls[0].URL, ls[1].URL, ls[2].URL)
}
x, y, w, h := OverlayRect(uldamanWorldMap)
if l := ls[0]; l.X != x || l.Y != y || l.W != w || l.H != h {
- t.Errorf("layer rect = %v %v %v %v", l.X, l.Y, l.W, l.H)
+ t.Errorf("rectangle = %v %v %v %v", l.X, l.Y, l.W, l.H)
+ }
+ if got := MinimapSlots("", 209); got != nil {
+ t.Errorf("no folder gives slots: %v", got)
+ }
+ if got := MinimapSlots(dir, 70); len(got) != 3 {
+ t.Errorf("another map = %v", got)
+ }
+}
+
+// Uldaman has no minimap: the slot is still there, unavailable, with the built-in reason.
+func TestMinimapSlotMissingKind(t *testing.T) {
+ dir := t.TempDir()
+ writeLayerFiles(t, dir, "70", uldamanWorldMap)
+ writeLayerFiles(t, dir, "70.worldmap", uldamanWorldMap)
+ nav := uldamanWorldMap
+ nav.Source = "navmesh"
+ writeLayerFiles(t, dir, "70.navmesh", nav)
+
+ l := landOf(t, dir, 70)
+ if !l[0].Available || l[0].Label != "World map" || !l[2].Available || l[2].Label != "Navmesh" {
+ t.Fatalf("world map or navmesh = %+v / %+v", l[0], l[2])
+ }
+ if l[1].Available || l[1].MinimapPicture != nil || l[1].Source != "minimap" || l[1].Kind != "minimap" {
+ t.Errorf("minimap = %+v, want unavailable", l[1])
+ }
+ if l[1].Reason != kindWhy["minimap"] || !strings.Contains(l[1].Reason, "WMO tiles") {
+ t.Errorf("minimap reason = %q", l[1].Reason)
+ }
+}
+
+// Scarlet Monastery: the main world map plus floors 2f-4f, then the missing minimap, then the navmesh. The floors are sorted by
+// number, whatever order the folder lists them in.
+func TestMinimapSlotsFloors(t *testing.T) {
+ dir := t.TempDir()
+ writeLayerFiles(t, dir, "189", uldamanWorldMap)
+ writeLayerFiles(t, dir, "189.worldmap", uldamanWorldMap)
+ for _, fl := range []string{"10f", "3f", "2f"} {
+ m := uldamanWorldMap
+ m.Name = "Scarlet Monastery " + fl
+ writeLayerFiles(t, dir, "189.worldmap-"+fl, m)
+ }
+ nav := uldamanWorldMap
+ nav.Source = "navmesh"
+ writeLayerFiles(t, dir, "189.navmesh", nav)
+
+ ls := MinimapSlots(dir, 189)
+ if got, want := slotLabels(ls), "World map, World map 2f, World map 3f, World map 10f, Minimap, Navmesh"; got != want {
+ t.Fatalf("labels = %s, want %s", got, want)
+ }
+ for i, fl := range []string{"", "2f", "3f", "10f"} {
+ if ls[i].Kind != "worldmap" || ls[i].Floor != fl || !ls[i].Available {
+ t.Errorf("%d = %+v", i, ls[i])
+ }
+ }
+ if ls[2].URL != "/minimap/189.worldmap-3f.png" || ls[2].Label != "World map 3f" {
+ t.Errorf("3f = %+v", ls[2])
+ }
+ if ls[3].URL != "/minimap/189.worldmap-10f.png" || ls[3].Label != "World map 10f" {
+ t.Errorf("10f = %+v", ls[3])
+ }
+ if ls[4].Available || ls[4].Kind != "minimap" || ls[5].Kind != "navmesh" {
+ t.Errorf("after the floors: %+v / %+v", ls[4], ls[5])
+ }
+}
+
+// An old folder has only the main picture. It stands in for the one source it names and is never the minimap or navmesh too.
+func TestMinimapSlotsOldFolder(t *testing.T) {
+ dir := t.TempDir()
+ writeLayerFiles(t, dir, "70", uldamanWorldMap)
+
+ l := landOf(t, dir, 70)
+ if !l[0].Available || l[0].URL != "/minimap/70.png" || l[0].Kind != "worldmap" {
+ t.Errorf("world map = %+v", l[0])
+ }
+ for _, i := range []int{1, 2} {
+ if l[i].Available || l[i].Reason == "" {
+ t.Errorf("slot %d = %+v, want unavailable with a reason", i, l[i])
+ }
+ }
+ // a main picture of another source stands in for that source
+ mini := uldamanWorldMap
+ mini.Source = "minimap"
+ dir2 := t.TempDir()
+ writeLayerFiles(t, dir2, "70", mini)
+ l = landOf(t, dir2, 70)
+ if l[0].Available || !l[1].Available || l[1].URL != "/minimap/70.png" {
+ t.Errorf("main minimap folder = %+v", l)
+ }
+}
+
+// .status.json gives a slot's own reason. An oversized or malformed file, an unknown kind, or a file outside the folder's
+// allow-list leaves the built-in reasons; a long string is cut to 160 characters.
+func TestMinimapSlotStatusReasons(t *testing.T) {
+ dir := t.TempDir()
+ writeLayerFiles(t, dir, "70", uldamanWorldMap)
+ status := `{"minimap":{"reason":"no indoor art yet"},"navmesh":{"reason":"run without --mmaps"},"bogus":{"reason":"x"}}`
+ if err := os.WriteFile(filepath.Join(dir, "70.status.json"), []byte(status), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ l := landOf(t, dir, 70)
+ if l[1].Reason != "no indoor art yet" || l[2].Reason != "run without --mmaps" {
+ t.Errorf("reasons = %q / %q", l[1].Reason, l[2].Reason)
+ }
+
+ // malformed: the defaults stand
+ if err := os.WriteFile(filepath.Join(dir, "70.status.json"), []byte("{broken"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if l := landOf(t, dir, 70); l[1].Reason != kindWhy["minimap"] || l[2].Reason != kindWhy["navmesh"] {
+ t.Errorf("malformed reasons = %q / %q", l[1].Reason, l[2].Reason)
+ }
+
+ // oversized: the file is not read at all
+ if err := os.WriteFile(filepath.Join(dir, "70.status.json"), []byte(strings.Repeat(" ", 1<<16)+status), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if l := landOf(t, dir, 70); l[1].Reason != kindWhy["minimap"] {
+ t.Errorf("oversized reason = %q", l[1].Reason)
+ }
+
+ // control characters become spaces: one line is all the panel has
+ ctrl := "line one\nline two\ttab\r\x00nul\x7fdel"
+ b, _ := json.Marshal(map[string]map[string]string{"minimap": {"reason": ctrl}})
+ if err := os.WriteFile(filepath.Join(dir, "70.status.json"), b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if l := landOf(t, dir, 70); l[1].Reason != "line one line two tab nul del" {
+ t.Errorf("control characters = %q", l[1].Reason)
+ }
+
+ // a long string is cut
+ long := strings.Repeat("x", 300)
+ b, _ = json.Marshal(map[string]map[string]string{"minimap": {"reason": long}})
+ if err := os.WriteFile(filepath.Join(dir, "70.status.json"), b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if l := landOf(t, dir, 70); len([]rune(l[1].Reason)) != maxReasonRunes || l[1].Reason != strings.Repeat("x", 160) {
+ t.Errorf("long reason = %d characters", len([]rune(l[1].Reason)))
}
- if got := MinimapLayers(dir, 209); len(got) != 0 {
- t.Errorf("a broken json gives layers: %v", got)
+
+ // another map's status file is not read
+ if err := os.WriteFile(filepath.Join(dir, "71.status.json"), b, 0o644); err != nil {
+ t.Fatal(err)
}
- if MinimapLayers("", 70) != nil {
- t.Error("no folder, no layers")
+ if l := landOf(t, dir, 70); l[1].Reason != strings.Repeat("x", 160) {
+ t.Errorf("71.status.json changed 70: %q", l[1].Reason)
}
+ // a status file that is a symbolic link out of the folder is refused
+ outside := t.TempDir()
+ target := filepath.Join(outside, "70.status.json")
+ if err := os.WriteFile(target, []byte(status), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ os.Remove(filepath.Join(dir, "70.status.json"))
+ if err := os.Symlink(target, filepath.Join(dir, "70.status.json")); err != nil {
+ t.Skip("no symbolic links here")
+ }
+ if l := landOf(t, dir, 70); l[1].Reason != kindWhy["minimap"] {
+ t.Errorf("symlinked status reason = %q", l[1].Reason)
+ }
+ if got := statusReasons(dir, ".."); got != nil {
+ t.Errorf("a relative name was read: %v", got)
+ }
+}
+
+// A map id can be anything: an empty folder is still answered with three unavailable slots, and no folder gives 404.
+func TestMinimapSlotsAndRoutes(t *testing.T) {
+ dir := t.TempDir()
+ writeLayerFiles(t, dir, "70", uldamanWorldMap)
+ writeLayerFiles(t, dir, "70.worldmap", uldamanWorldMap)
+ nav := uldamanWorldMap
+ nav.Source = "navmesh"
+ writeLayerFiles(t, dir, "70.navmesh", nav)
+ os.WriteFile(filepath.Join(dir, "209.json"), []byte("{broken"), 0o644)
+ os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("no"), 0o644)
+ os.WriteFile(filepath.Join(dir, "70.status.png"), []byte("\x89PNG fake"), 0o644)
+ os.WriteFile(filepath.Join(dir, "70.status-2f.png"), []byte("\x89PNG fake"), 0o644)
+
p := NewPoller(Options{MinimapDir: dir, ConfigDir: t.TempDir(), Agents: "auto"})
srv := httptest.NewServer(Handler(p, "", nil))
defer srv.Close()
@@ -141,10 +335,36 @@ func TestMinimapLayersAndRoutes(t *testing.T) {
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, resp.Header.Get("Content-Type"), string(b)
}
- if code, _, body := get("/api/minimap/70"); code != 200 || !strings.Contains(body, `"/minimap/70.png"`) {
+ if code, _, body := get("/api/minimap/70"); code != 200 || !strings.Contains(body, `"label":"World map"`) || !strings.Contains(body, `"/minimap/70.navmesh.png"`) {
t.Errorf("/api/minimap/70 = %d %s", code, body)
}
- for _, path := range []string{"/api/minimap/209", "/api/minimap/abc", "/api/minimap/", "/api/minimap/-1", "/api/minimap/70/x"} {
+ // a map with nothing in the folder: 200 and the three missing slots, each without picture fields
+ code, _, body := get("/api/minimap/999")
+ if code != 200 {
+ t.Fatalf("/api/minimap/999 = %d %s", code, body)
+ }
+ var slots []map[string]any
+ if err := json.Unmarshal([]byte(body), &slots); err != nil {
+ t.Fatal(err)
+ }
+ if len(slots) != 3 {
+ t.Fatalf("999 slots = %v", body)
+ }
+ for i, kind := range []string{"worldmap", "minimap", "navmesh"} {
+ if slots[i]["kind"] != kind || slots[i]["available"] != false || slots[i]["reason"] == "" {
+ t.Errorf("999 slot %d = %v", i, slots[i])
+ }
+ for _, k := range []string{"url", "x", "y", "w", "h"} {
+ if _, ok := slots[i][k]; ok {
+ t.Errorf("999 slot %d has %s: %v", i, k, slots[i])
+ }
+ }
+ }
+ // a broken main file is a map with no picture, not a server error
+ if code, _, body := get("/api/minimap/209"); code != 200 || !strings.Contains(body, `"available":false`) {
+ t.Errorf("/api/minimap/209 = %d %s", code, body)
+ }
+ for _, path := range []string{"/api/minimap/abc", "/api/minimap/", "/api/minimap/-1", "/api/minimap/70/x"} {
if code, _, _ := get(path); code != 404 {
t.Errorf("%s = %d, want 404", path, code)
}
@@ -152,7 +372,7 @@ func TestMinimapLayersAndRoutes(t *testing.T) {
if code, ct, body := get("/minimap/70.navmesh.png"); code != 200 || ct != "image/png" || !strings.HasPrefix(body, "\x89PNG") {
t.Errorf("image = %d %s", code, ct)
}
- for _, path := range []string{"/minimap/70.json", "/minimap/secret.txt", "/minimap/../secret.txt", "/minimap/%2e%2e/x.png", "/minimap/70.paper.png", "/minimap/", "/minimap/999.png"} {
+ for _, path := range []string{"/minimap/70.json", "/minimap/70.status.json", "/minimap/70.status.png", "/minimap/70.status-2f.png", "/minimap/secret.txt", "/minimap/../secret.txt", "/minimap/%2e%2e/x.png", "/minimap/70.paper.png", "/minimap/", "/minimap/999.png"} {
if code, _, _ := get(path); code == 200 {
t.Errorf("%s was served", path)
}
diff --git a/internal/dashboard/paths_test.go b/internal/dashboard/paths_test.go
index eb351476..9a473414 100644
--- a/internal/dashboard/paths_test.go
+++ b/internal/dashboard/paths_test.go
@@ -382,6 +382,128 @@ func TestHistoryGhostPartialRunIsNoClear(t *testing.T) {
}
}
+func TestHistoryGhostRunWithPacksLeftIsNoClear(t *testing.T) {
+ dir := t.TempDir()
+ w := fullRun("r-left", "zf", 100, 600, 4, "done", nil)
+ w.event(599000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`)
+ writeRun(t, dir, "g", w)
+ h := NewHistory(nil, dir)
+ var g GhostAnswer
+ if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil {
+ t.Fatal(err)
+ }
+ if !g.Found || g.Complete || g.Left != 2 || g.LegsDone != 4 {
+ t.Fatalf("a finished run with 2 packs left is not a clear: %+v", g)
+ }
+ clean := fullRun("r-clean", "zf", 200, 700, 4, "done", nil)
+ writeRun(t, dir, "h", clean)
+ h = NewHistory(nil, dir)
+ g = GhostAnswer{}
+ if err := json.Unmarshal(h.Ghost("zf", "r-left", 0), &g); err != nil { // without the run that left packs behind
+ t.Fatal(err)
+ }
+ if !g.Found || !g.Complete || g.Left != 0 || g.Run != "r-clean" {
+ t.Fatalf("a finished run with no packs left is a clear: %+v", g)
+ }
+}
+
+func TestHistoryGhostPrefersCleanClearOverFasterDirty(t *testing.T) {
+ dir := t.TempDir()
+ // dirty run is faster (600 s) with the same LegsDone; clean run is slower (700 s). The clean one must win.
+ dirty := fullRun("r-dirty", "zf", 100, 600, 4, "done", nil)
+ dirty.event(599000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`)
+ writeRun(t, dir, "g", dirty)
+ writeRun(t, dir, "h", fullRun("r-clean", "zf", 200, 700, 4, "done", nil))
+ h := NewHistory(nil, dir)
+ var g GhostAnswer
+ if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil {
+ t.Fatal(err)
+ }
+ if !g.Found || g.Run != "r-clean" || !g.Complete || g.Left != 0 || g.LegsDone != 4 {
+ t.Fatalf("clean slower run beats dirty faster one: %+v", g)
+ }
+}
+
+func TestHistoryGhostTwoCleanRunsFasterWins(t *testing.T) {
+ dir := t.TempDir()
+ writeRun(t, dir, "g", fullRun("r-slow", "zf", 100, 700, 4, "done", nil))
+ writeRun(t, dir, "h", fullRun("r-fast", "zf", 200, 600, 4, "done", nil))
+ h := NewHistory(nil, dir)
+ var g GhostAnswer
+ if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil {
+ t.Fatal(err)
+ }
+ if !g.Found || g.Run != "r-fast" || !g.Complete || g.Left != 0 {
+ t.Fatalf("faster of two clean runs wins: %+v", g)
+ }
+}
+
+func TestHistoryGhostOnlyDirtyRunsFasterWins(t *testing.T) {
+ dir := t.TempDir()
+ slow := fullRun("r-dirty-slow", "zf", 100, 700, 4, "done", nil)
+ slow.event(699000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":1`)
+ writeRun(t, dir, "g", slow)
+ fast := fullRun("r-dirty-fast", "zf", 200, 600, 4, "done", nil)
+ fast.event(599000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`)
+ writeRun(t, dir, "h", fast)
+ h := NewHistory(nil, dir)
+ var g GhostAnswer
+ if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil {
+ t.Fatal(err)
+ }
+ if !g.Found || g.Run != "r-dirty-fast" || g.Complete || g.Left != 2 {
+ t.Fatalf("faster of two dirty runs wins and stays incomplete: %+v", g)
+ }
+}
+
+func TestHistoryGhostAbortedLeftZeroIsNoClear(t *testing.T) {
+ dir := t.TempDir()
+ // An interrupted run never reported a final count, so its Left of 0 must
+ // not beat a finished run with packs left on the clean-clear rule: the
+ // quicker of the two wins.
+ writeRun(t, dir, "g", fullRun("r-aborted", "zf", 100, 700, 4, "failed", nil))
+ dirty := fullRun("r-dirty", "zf", 200, 600, 4, "done", nil)
+ dirty.event(599000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`)
+ writeRun(t, dir, "h", dirty)
+ h := NewHistory(nil, dir)
+ var g GhostAnswer
+ if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil {
+ t.Fatal(err)
+ }
+ if !g.Found || g.Run != "r-dirty" || g.Complete || g.Left != 2 || g.LegsDone != 4 {
+ t.Fatalf("an aborted Left-0 run must not beat a faster finished run with packs left: %+v", g)
+ }
+ // A confirmed clean clear beats both even when slower.
+ writeRun(t, dir, "i", fullRun("r-clean", "zf", 300, 800, 4, "done", nil))
+ h = NewHistory(nil, dir)
+ g = GhostAnswer{}
+ if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil {
+ t.Fatal(err)
+ }
+ if !g.Found || g.Run != "r-clean" || !g.Complete || g.Left != 0 || g.LegsDone != 4 {
+ t.Fatalf("a done clean run beats the aborted and dirty ones: %+v", g)
+ }
+}
+
+func TestHistoryGhostLaterRunLeaderWithoutLeftClears(t *testing.T) {
+ w := fullRun("r-stale", "zf", 100, 600, 4, "done", nil)
+ w.event(598000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`)
+ w.event(599000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done"`)
+ if d := digestOf(w).Snapshot(0); d.Left != 0 {
+ t.Fatalf("a later run_leader without left resets the count: %+v", d)
+ }
+ dir := t.TempDir()
+ writeRun(t, dir, "g", w)
+ h := NewHistory(nil, dir)
+ var g GhostAnswer
+ if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil {
+ t.Fatal(err)
+ }
+ if !g.Found || !g.Complete || g.Left != 0 {
+ t.Fatalf("a done run whose last report left no packs is a clear: %+v", g)
+ }
+}
+
func TestHistoryHeatBoundedToLatestRuns(t *testing.T) {
dir := t.TempDir()
for i := 0; i < HeatRuns+5; i++ {
diff --git a/internal/dashboard/static/app.js b/internal/dashboard/static/app.js
index 2c94b2d0..dec11344 100644
--- a/internal/dashboard/static/app.js
+++ b/internal/dashboard/static/app.js
@@ -2,12 +2,12 @@
// Live dashboard page. Everything from the agents is put in the page with textContent, never as markup; the only markup
// inserted is the plan drawing, which the server builds with escaped text.
const SVGNS = "http://www.w3.org/2000/svg";
-const MV = window.MapView, PT = window.PlainText, ML = window.MapLayers, UI = window.UnitInfo, MP = window.MapPaths, PD = window.PathDraw, PV = window.PlanView, RE = window.RouteEdit, RR = window.RealRoute; // planview.js (the plan view and its leg switches), view.js (zoom and pan math), text.js (plain wording), layers.js (what a map can draw), unitinfo.js (what a unit is doing, the hover box, events, progress)
+const MV = window.MapView, PT = window.PlainText, ML = window.MapLayers, PS = window.PictureSlots, UI = window.UnitInfo, MP = window.MapPaths, PD = window.PathDraw, PV = window.PlanView, RE = window.RouteEdit, RR = window.RealRoute; // planview.js (the plan view and its leg switches), view.js (zoom and pan math), text.js (plain wording), layers.js (what a map can draw), picslots.js (the map picture slots), unitinfo.js (what a unit is doing, the hover box, events, progress)
const RF = window.RefreshRate; // refresh.js (the refresh-rate picker: how often the page polls)
const $ = (id) => document.getElementById(id);
const cards = new Map(); // id -> {el, parts, geo}
const plans = new Map(); // name -> geometry | "loading" | null
-const minimaps = new Map(); // map id -> [layer] | "loading" | null (no image for that map; looked for again after a while)
+const minimaps = new Map(); // map id -> [picture slot] | "loading" | null (no minimap folder; looked for again after a while)
const minimapMiss = new Map(); // map id -> when it was found to have no image
let view = "grid", activeTab = null, last = null;
let dim = 30, followPref = true; // dim: percent the game map is darkened under the drawing; followPref: follow the party (per browser)
@@ -230,7 +230,8 @@ function makeCard(c) {
h("span", {}, "faded = already done; white ring = in a fight"));
parts.layer = h("select", { hidden: true, title: "background image of the map (extracted from your own client by turtle-agent minimap-extract)", "aria-label": "map background" });
parts.layer.addEventListener("change", () => { rec.layerName = parts.layer.value; rec.layerApplied = null; if (last) render(last); });
- parts.legend.append(parts.layer);
+ parts.picWhy = h("em", { class: "why", hidden: true }); // why the slot you asked for has no picture
+ parts.legend.append(parts.layer, parts.picWhy);
parts.debug = h("details", {}, h("summary", { text: "Debug: raw status and files" }), h("div", { class: "files" }), h("pre", { text: "(open to load)" }));
parts.debug.addEventListener("toggle", () => { if (parts.debug.open) loadRaw(c.id, parts); });
const el = h("section", { class: "card", "data-id": c.id }, parts.head, h("div", { class: "cbody" }, parts.map, parts.info, parts.legend), parts.debug);
@@ -269,13 +270,18 @@ function ensurePlan(c) {
return "loading";
}
+// The three picture slots of a map (the world map and its floors, the minimap, the navmesh); a server without a minimap
+// folder answers 404. An answer with no available picture at all is kept (its reasons still show) but looked at again after a
+// while: a dashboard left running through a re-extract then picks the pictures up without a reload.
function ensureMinimap(id) {
if (id == null) return undefined;
- if (minimaps.has(id) && !(minimaps.get(id) === null && Date.now() - (minimapMiss.get(id) || 0) > 30000)) return minimaps.get(id);
+ const have = minimaps.get(id);
+ if (have === "loading") return have;
+ if (minimaps.has(id) && PS.keep(have, minimapMiss.get(id), Date.now())) return have;
minimaps.set(id, "loading");
fetch("/api/minimap/" + encodeURIComponent(id), { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
- .then((ls) => { minimaps.set(id, ls && ls.length ? ls : null); minimapMiss.set(id, Date.now()); if (last) render(last); })
+ .then((ls) => { minimaps.set(id, Array.isArray(ls) ? ls : null); minimapMiss.set(id, Date.now()); if (last) render(last); })
.catch(() => { minimaps.set(id, null); minimapMiss.set(id, Date.now()); });
return "loading";
}
@@ -313,22 +319,29 @@ function ensureGhost(c) {
}
function ensureHeat(c) { return c.label.plan ? cachedFetch(heats, c.label.plan, "/api/heat/" + encodeURIComponent(c.label.plan)) : null; }
-// The image goes under the plan drawing; its rectangle is in the drawing's plane (yards), so the plan lines up with it.
+// The image goes under the plan drawing; its rectangle is in the drawing's plane (yards), so the plan lines up with it. The
+// picker lists every slot of the map (the world map and its floors, the minimap, the navmesh): a slot without a picture is
+// disabled and reads " (none)" with its reason on hover, and the drawing falls back to the first available slot.
function applyLayer(rec, svg, geo) {
const ls = ensureMinimap(geo.map), sel = rec.parts.layer;
- const have = Array.isArray(ls) ? ls : [];
- sel.hidden = have.length < 2;
- const names = have.map((l) => l.name).join("|");
- if (have.length >= 2 && sel.dataset.names !== names) { sel.dataset.names = names; sel.replaceChildren(...have.map((l) => h("option", { value: l.name, text: l.name }))); }
- const pick = have.find((l) => l.name === rec.layerName) || have[0];
- rec.pick = pick || null;
- const key = pick ? pick.url : "";
- if (rec.layerApplied === key) return;
+ const plan = PS.plan(Array.isArray(ls) ? ls : [], rec.layerName);
+ sel.hidden = !plan.shown;
+ const names = plan.options.map((o) => o.value + "|" + o.disabled + "|" + o.title).join("\n");
+ if (plan.shown && sel.dataset.names !== names) {
+ sel.dataset.names = names;
+ sel.replaceChildren(...plan.options.map((o) => h("option", { value: o.value, text: o.text, disabled: o.disabled, title: o.title })));
+ }
+ rec.picWhy = plan.reason;
+ rec.parts.picWhy.textContent = plan.reason;
+ rec.parts.picWhy.hidden = !plan.reason;
+ rec.pick = plan.pick || null;
+ const key = plan.pick ? plan.pick.url : "";
+ if (rec.layerApplied === key) { if (plan.pick) sel.value = plan.pick.label; return; }
rec.layerApplied = key;
svg.querySelectorAll("image.bg").forEach((e) => e.remove());
- if (!pick) return;
- sel.value = pick.name;
- const img = s("image", { class: "bg", href: pick.url, x: pick.x, y: pick.y, width: pick.w, height: pick.h, preserveAspectRatio: "none" });
+ if (!plan.pick) return;
+ sel.value = plan.pick.label;
+ const img = s("image", { class: "bg", href: plan.pick.url, x: plan.pick.x, y: plan.pick.y, width: plan.pick.w, height: plan.pick.h, preserveAspectRatio: "none" });
svg.insertBefore(img, svg.firstChild);
}
@@ -976,7 +989,7 @@ function renderMap(c, rec) {
renderFeed(rec, c);
const has = (sel) => !!svg.querySelector(sel), kind = (...ks) => c.mobs.some((m) => ks.includes(m.kind));
rec.avail = ML.availability({
- picture: !!rec.pick, route: has(".route, .direct"), packs: has(".pull, .pin"), cleared: c.mobs.some((m) => m.cleared) || has(".pull.cleared, .pin.cleared, .leg.done"),
+ picture: !!rec.pick, why: { picture: rec.picWhy || "" }, route: has(".route, .direct"), packs: has(".pull, .pin"), cleared: c.mobs.some((m) => m.cleared) || has(".pull.cleared, .pin.cleared, .leg.done"),
bosses: has(".boss"), doors: has(".obj"), sections: has(".legname"), steps: has(".stepn"), trail: (c.trail || []).length >= 2, trailtime: stamps.length > 0, heading: !!(hd && lead),
next: !!nt, bots: c.bots.some((b) => b.known), botTargets: botLines.length > 0, healer: healers.length > 0, commander: !!c.commander, cmdline: !!(c.commander && leadPos),
hostile: kind("hostile"), neutral: kind("neutral", "friendly"), critter: kind("critter"), threat: threat.length > 0, pulltargets: pulls.length > 0, zones: false,
diff --git a/internal/dashboard/static/index.html b/internal/dashboard/static/index.html
index fac3026c..59fab9eb 100644
--- a/internal/dashboard/static/index.html
+++ b/internal/dashboard/static/index.html
@@ -26,6 +26,7 @@ Commanders
+
diff --git a/internal/dashboard/static/layers.js b/internal/dashboard/static/layers.js
index 2829f437..9eb7bf95 100644
--- a/internal/dashboard/static/layers.js
+++ b/internal/dashboard/static/layers.js
@@ -135,10 +135,12 @@
}
function set(state, key, on) { const out = normalize(state); if (key in out) out[key] = !!on; return out; }
- // {key: {ok, why}} for one card's map. ctx has the booleans named by `needs`; a missing one is false.
+ // {key: {ok, why}} for one card's map. ctx has the booleans named by `needs`; a missing one is false. A caller may pass a
+ // better reason for a missing layer in ctx.why ("picture": the map picture slot's own reason), so the panel explains the
+ // real gap.
function availability(ctx) {
- const c = ctx || {}, out = {};
- DEFS.forEach((d) => { out[d.key] = !d.needs || c[d.needs] ? { ok: true, why: "" } : { ok: false, why: d.why || "no data" }; });
+ const c = ctx || {}, whys = c.why && typeof c.why === "object" ? c.why : {}, out = {};
+ DEFS.forEach((d) => { out[d.key] = !d.needs || c[d.needs] ? { ok: true, why: "" } : { ok: false, why: whys[d.needs] || d.why || "no data" }; });
return out;
}
const groups = () => DEFS.reduce((g, d) => (g.indexOf(d.group) < 0 ? g.concat(d.group) : g), []);
diff --git a/internal/dashboard/static/paths.js b/internal/dashboard/static/paths.js
index 514455ba..b96a08ae 100644
--- a/internal/dashboard/static/paths.js
+++ b/internal/dashboard/static/paths.js
@@ -276,7 +276,8 @@
// The words about the ghost for the hover box / legend: "best earlier run: cleared in 41:07 (folder)".
function ghostDescribe(g) {
if (!g || !g.found) return "";
- return (g.complete ? "fastest clear: " : "furthest earlier run (" + g.legs_done + (g.plan_legs ? " of " + g.plan_legs : "") + " sections): ") + clockMin(g.seconds) + (g.folder ? " (" + g.folder + ")" : "");
+ const left = g.left > 0 ? ", " + g.left + " pack" + (g.left === 1 ? "" : "s") + " left" : "";
+ return (g.complete ? "fastest clear: " : "furthest earlier run (" + g.legs_done + (g.plan_legs ? " of " + g.plan_legs : "") + " sections): ") + clockMin(g.seconds) + left + (g.folder ? " (" + g.folder + ")" : "");
}
// ---- stalls and refusals ----
diff --git a/internal/dashboard/static/picslots.js b/internal/dashboard/static/picslots.js
new file mode 100644
index 00000000..a2ee47d7
--- /dev/null
+++ b/internal/dashboard/static/picslots.js
@@ -0,0 +1,91 @@
+"use strict";
+// The map picture slots of a card, kept free of the page so a test can run them under node. The server lists three slots per
+// map in Zul'Farrak's order (World map, plus a slot per world-map floor, Minimap, Navmesh) and marks a slot without a picture
+// unavailable with one line of reason. The picker shows all of them: a missing one is disabled and reads " (none)";
+// the drawing falls back to the first available slot, so a choice saved before a picture went missing never leaves a blank map.
+(function (root) {
+ const KINDS = ["worldmap", "minimap", "navmesh"]; // the order Zul'Farrak has them in
+ const ASK_AGAIN_MS = 30000; // a map whose answer has no picture at all is asked again after this long (a re-extract fills it)
+ const TITLE = { worldmap: "World map", minimap: "Minimap", navmesh: "Navmesh" };
+ const num = (v) => typeof v === "number" && isFinite(v);
+ // the extractor's image names, and nothing else: "[.[-f]].png"
+ const PNG = /^\/minimap\/\d{1,9}(?:\.(?:minimap|worldmap|navmesh)(?:-\d+f)?)?\.png$/;
+
+ // labelFloor reads the floor off a label ("World map 2f" -> "2f") for an entry that has no floor field of its own.
+ function labelFloor(label) {
+ const m = /(?:world map|worldmap) (\d+f)$/i.exec(label || "");
+ return m ? m[1] : "";
+ }
+
+ // slots keeps what the page can draw: an available entry needs a picture URL and a finite, positive rectangle, as before;
+ // an unavailable entry passes through with its label and reason, so the picker can say what is missing and why.
+ function slots(list) {
+ const out = [];
+ for (const raw of Array.isArray(list) ? list : []) {
+ if (!raw || typeof raw !== "object") continue;
+ const kind = KINDS.indexOf(raw.kind) >= 0 ? raw.kind : "";
+ const label = typeof raw.label === "string" && raw.label ? raw.label : (TITLE[kind] || "");
+ if (!kind && !label) continue;
+ const floor = typeof raw.floor === "string" ? raw.floor : labelFloor(label);
+ if (raw.available === false) {
+ out.push({ kind, label, floor, available: false, reason: typeof raw.reason === "string" ? raw.reason : "" });
+ continue;
+ }
+ if (!label || !PNG.test(raw.url) || !num(raw.x) || !num(raw.y) || !num(raw.w) || !num(raw.h) || raw.w <= 0 || raw.h <= 0) continue;
+ out.push({ kind, label, floor, available: true, url: raw.url, x: raw.x, y: raw.y, w: raw.w, h: raw.h });
+ }
+ return out;
+ }
+
+ // order puts the slots in the three kinds' order, a world map's floors after its main picture. A kind the server left out
+ // is added as a missing slot, so the picker always offers the three.
+ function order(list) {
+ const out = [];
+ for (const kind of KINDS) {
+ const mine = list.filter((s) => s.kind === kind);
+ if (mine.length) out.push(...mine); else out.push({ kind, label: TITLE[kind], floor: "", available: false, reason: "" });
+ }
+ for (const s of list) if (KINDS.indexOf(s.kind) < 0) out.push(s);
+ return out;
+ }
+
+ // options are the picker's entries: all slots, a missing one disabled, reading " (none)" with its reason on hover.
+ function options(list) {
+ return list.map((s) => ({ value: s.label, text: s.available ? s.label : s.label + " (none)", disabled: !s.available, title: s.available ? "" : s.reason }));
+ }
+
+ // hasPicture says whether an answer gives at least one drawable slot.
+ function hasPicture(list) {
+ return slots(list).some((s) => s.available);
+ }
+
+ // keep says whether an answer already in hand can stand: one with a picture always can; one with none (or no answer at all)
+ // is asked again after ASK_AGAIN_MS, while its reasons stay on screen in the meantime.
+ function keep(list, askedAt, now) {
+ return hasPicture(list) || now - (askedAt || 0) <= ASK_AGAIN_MS;
+ }
+
+ // noPicture is the reason of the map picture layer when nothing can be drawn: every missing slot's own line, so it is clear
+ // that no kind has an image and not only which one.
+ function noPicture(list) {
+ const says = list.filter((s) => !s.available && s.reason);
+ if (!says.length) return "";
+ return "No picture for this map: " + says.map((s) => s.label + " (" + s.reason + ")").join("; ");
+ }
+
+ // plan is what the page draws and shows: the picker's entries, whether the picker is shown at all (at least one available
+ // slot), the slot to draw and the reason to print. A stored choice that is available wins; one that is now missing falls
+ // back to the first available slot and keeps its reason printed. With nothing available the picker stays hidden and the
+ // reason covers every missing slot, which greys the map picture layer as before.
+ function plan(list, stored) {
+ const all = order(slots(list));
+ const have = all.filter((s) => s.available);
+ const want = typeof stored === "string" ? all.find((s) => s.label === stored) : null;
+ const pick = (want && want.available ? want : null) || have[0] || null;
+ const reason = want && !want.available ? want.reason : pick ? "" : noPicture(all);
+ return { slots: all, options: options(all), shown: have.length > 0, pick, reason };
+ }
+
+ const api = { KINDS, TITLE, ASK_AGAIN_MS, slots, order, options, hasPicture, keep, noPicture, plan };
+ if (typeof module !== "undefined" && module.exports) module.exports = api; else root.PictureSlots = api;
+})(typeof self !== "undefined" ? self : this);
diff --git a/internal/dashboard/testdata/layers_check.js b/internal/dashboard/testdata/layers_check.js
index 70dcfbbe..a31fafff 100644
--- a/internal/dashboard/testdata/layers_check.js
+++ b/internal/dashboard/testdata/layers_check.js
@@ -60,6 +60,12 @@ assert.strictEqual(av.zonenames.ok, false);
av = L.availability({ trail: true, trailtime: true, threat: true, healer: true });
assert.strictEqual(av.threat.ok, true); assert.strictEqual(av.healring.ok, true); assert.strictEqual(av.trailtime.ok, true);
assert.strictEqual(av.heading.ok, false); assert.strictEqual(av.aggro.ok, false);
+// a caller's own reason for a missing layer wins over the built-in text (the map picture slot's reason)
+av = L.availability({ picture: false, why: { picture: "no minimap picture: indoor dungeon" } });
+assert.strictEqual(av.bg.ok, false);
+assert.strictEqual(av.bg.why, "no minimap picture: indoor dungeon");
+av = L.availability({ picture: false });
+assert.strictEqual(av.bg.why, L.DEFS.find((d) => d.key === "bg").why);
assert.deepStrictEqual(L.groups(), ["Map", "Plan", "Walk", "Party", "Mobs", "Marks", "Run"]);
// ---- guids: the feed prints them in different widths
diff --git a/internal/dashboard/testdata/paths_check.js b/internal/dashboard/testdata/paths_check.js
index 19536691..0350afad 100644
--- a/internal/dashboard/testdata/paths_check.js
+++ b/internal/dashboard/testdata/paths_check.js
@@ -159,6 +159,7 @@ assert.strictEqual(P.clockMin(0), "0:00"); assert.strictEqual(P.clockMin(2467),
assert.ok(/fastest clear: 41:07 \(f1\)/.test(P.ghostDescribe({ found: true, complete: true, seconds: 2467, folder: "f1" })));
assert.ok(/furthest earlier run \(3 sections\)/.test(P.ghostDescribe({ found: true, complete: false, legs_done: 3, seconds: 90 })));
assert.ok(/furthest earlier run \(3 of 14 sections\): 1:30/.test(P.ghostDescribe({ found: true, complete: false, legs_done: 3, plan_legs: 14, seconds: 90 })));
+assert.ok(/furthest earlier run \(4 sections\): 10:00, 2 packs left \(g\)/.test(P.ghostDescribe({ found: true, complete: false, legs_done: 4, seconds: 600, left: 2, folder: "g" })));
assert.strictEqual(P.ghostDescribe({ found: false }), "");
// ---- stalls, refusals, heat
diff --git a/internal/dashboard/testdata/picslots_check.js b/internal/dashboard/testdata/picslots_check.js
new file mode 100644
index 00000000..97ce6517
--- /dev/null
+++ b/internal/dashboard/testdata/picslots_check.js
@@ -0,0 +1,108 @@
+"use strict";
+// Run by view_test.go under node: the map picture slots (ZF's three, a missing kind, floors, the saved choice, the picker).
+const assert = require("assert");
+const P = require(process.argv[2]);
+
+const zf = [
+ { name: "World map", source: "worldmap", kind: "worldmap", floor: "", label: "World map", available: true, url: "/minimap/209.worldmap.png", x: -1625, y: -2052.08, w: 1383.33, h: 922.91 },
+ { name: "Minimap", source: "minimap", kind: "minimap", floor: "", label: "Minimap", available: true, url: "/minimap/209.minimap.png", x: -1600, y: -2666.67, w: 1600, h: 3733.33 },
+ { name: "Navmesh", source: "navmesh", kind: "navmesh", floor: "", label: "Navmesh", available: true, url: "/minimap/209.navmesh.png", x: -1612, y: -2678.67, w: 1624, h: 3756.53 },
+];
+const WHY = "No minimap picture: indoor dungeon (the client draws it from WMO tiles, which minimap-extract does not compose yet)";
+const uldaman = [
+ zf[0],
+ { name: "Minimap", source: "minimap", kind: "minimap", floor: "", label: "Minimap", available: false, reason: WHY },
+ { name: "Navmesh", source: "navmesh", kind: "navmesh", floor: "", label: "Navmesh", available: true, url: "/minimap/70.navmesh.png", x: -645, y: -204, w: 893, h: 595 },
+];
+
+// ---- what is drawn: an available entry needs a picture URL and a finite, positive rectangle, as before
+const bad = [
+ { kind: "worldmap", label: "World map", available: true, url: "javascript:alert(1)", x: 0, y: 0, w: 1, h: 1 },
+ { kind: "worldmap", label: "World map", available: true, url: "/other/70.png", x: 0, y: 0, w: 1, h: 1 },
+ { kind: "worldmap", label: "World map", available: true, url: "/minimap/70.png", x: NaN, y: 0, w: 1, h: 1 },
+ { kind: "worldmap", label: "World map", available: true, url: "/minimap/70.png", x: 0, y: 0, w: 0, h: 1 },
+ { kind: "worldmap", label: "World map", available: true, url: "/minimap/70.png", x: "0", y: 0, w: 1, h: 1 },
+ { kind: "worldmap", label: "World map", available: true, url: "/minimap/70.png", x: 0, y: 0, h: 1 },
+ null, 5, "x", {},
+];
+assert.deepStrictEqual(P.slots(bad), [], "no drawable picture survives a bad url or rectangle");
+assert.deepStrictEqual(P.slots(null), []);
+assert.deepStrictEqual(P.slots("nope"), []);
+// a missing entry keeps its label and reason, whatever its picture fields look like
+const kept = P.slots([
+ { kind: "minimap", label: "Minimap", floor: "", available: false, reason: WHY, url: "javascript:x" },
+ { kind: "navmesh", available: false },
+])[0];
+assert.strictEqual(kept.label, "Minimap"); assert.strictEqual(kept.reason, WHY); assert.strictEqual(kept.available, false);
+assert.strictEqual(P.slots([{ kind: "navmesh", available: false }])[0].label, "Navmesh", "a missing slot without a label gets its kind's name");
+assert.deepStrictEqual(P.slots([{}]), [], "an entry with no kind and no label is nothing");
+
+// ---- the three kinds, ZF's order, the pickup of the floors and the default choice
+let p = P.plan(zf);
+assert.strictEqual(p.shown, true);
+assert.deepStrictEqual(p.options.map((o) => o.value), ["World map", "Minimap", "Navmesh"]);
+assert.deepStrictEqual(p.options.map((o) => o.disabled), [false, false, false]);
+assert.strictEqual(p.pick.label, "World map", "the main picture is the default");
+assert.strictEqual(p.reason, "");
+assert.strictEqual(P.plan(zf, "Minimap").pick.label, "Minimap");
+assert.strictEqual(P.plan(zf, "Navmesh").pick.label, "Navmesh");
+assert.strictEqual(P.plan(zf, "nonsense").pick.label, "World map", "an unknown saved choice falls back");
+
+// ---- a missing kind: still an option, disabled, with "(none)" and the reason; a saved choice of it falls back
+p = P.plan(uldaman, "Minimap");
+assert.strictEqual(p.shown, true, "the picker shows with one or two available slots");
+assert.strictEqual(p.options.length, 3);
+assert.deepStrictEqual(p.options[1], { value: "Minimap", text: "Minimap (none)", disabled: true, title: WHY });
+assert.strictEqual(p.pick.label, "World map", "a saved choice of the missing kind falls back to the default");
+assert.strictEqual(p.reason, WHY, "and its reason is shown");
+assert.strictEqual(P.plan(uldaman).pick.label, "World map");
+assert.strictEqual(P.plan(uldaman).reason, "");
+assert.strictEqual(P.plan(uldaman, "Navmesh").pick.label, "Navmesh");
+
+// ---- one available slot still shows the picker; none available hides it and carries the minimap's reason
+const one = P.plan([zf[0], uldaman[1], uldaman[2]].map((s) => (s.kind === "navmesh" ? Object.assign({}, s, { available: false, reason: "no mmaps" }) : s)));
+assert.strictEqual(one.shown, true);
+assert.strictEqual(one.pick.label, "World map");
+const none = P.plan([Object.assign({}, zf[0], { available: false, reason: "no world map" }), uldaman[1], Object.assign({}, zf[2], { available: false, reason: "no mmaps" })]);
+assert.strictEqual(none.shown, false);
+assert.strictEqual(none.pick, null);
+assert.ok(none.reason.indexOf("No picture for this map:") === 0, "the greyed map picture says no slot has a picture: " + none.reason);
+["no world map", WHY, "no mmaps"].forEach((why) => assert.ok(none.reason.indexOf(why) >= 0, "the combined reason keeps " + why));
+assert.strictEqual(none.options.length, 3);
+assert.deepStrictEqual(none.options.map((o) => o.text), ["World map (none)", "Minimap (none)", "Navmesh (none)"]);
+
+// ---- an answer without a single picture is kept (its reasons show) but asked again after the window
+assert.strictEqual(P.hasPicture(zf), true);
+assert.strictEqual(P.hasPicture(uldaman), true);
+assert.strictEqual(P.hasPicture(none.slots), false);
+assert.strictEqual(P.hasPicture(null), false);
+assert.strictEqual(P.hasPicture([]), false);
+assert.strictEqual(P.keep(zf, 0, 1e12), true, "an answer with a picture is final");
+assert.strictEqual(P.keep([], 1000, 1000), true, "a just-asked empty answer keeps its reasons on screen");
+assert.strictEqual(P.keep([], 1000, 1000 + P.ASK_AGAIN_MS), true);
+assert.strictEqual(P.keep([], 1000, 1001 + P.ASK_AGAIN_MS), false, "an empty answer is asked again after 30 s");
+assert.strictEqual(P.keep(null, 1000, 2000), true, "a just-failed request is not repeated at once");
+assert.strictEqual(P.keep(null, 0, 1e12), false);
+assert.strictEqual(P.plan([]).reason, "", "with no reasons at all the page keeps its own text");
+
+// ---- floors stay with the world map, in the server's order, and a saved floor is drawn
+const sm = [
+ zf[0],
+ Object.assign({}, zf[0], { floor: "2f", label: "World map 2f", url: "/minimap/189.worldmap-2f.png" }),
+ Object.assign({}, zf[0], { floor: "3f", label: "World map 3f", url: "/minimap/189.worldmap-3f.png" }),
+ Object.assign({}, zf[0], { floor: "4f", label: "World map 4f", url: "/minimap/189.worldmap-4f.png" }),
+ uldaman[1],
+ zf[2],
+];
+p = P.plan(sm, "World map 3f");
+assert.deepStrictEqual(p.options.map((o) => o.value), ["World map", "World map 2f", "World map 3f", "World map 4f", "Minimap", "Navmesh"]);
+assert.strictEqual(p.pick.label, "World map 3f");
+assert.strictEqual(p.pick.url, "/minimap/189.worldmap-3f.png");
+// a missing kind is put in its place even when the server sends only the others (an older server)
+p = P.plan([zf[0]]);
+assert.deepStrictEqual(p.options.map((o) => o.value), ["World map", "Minimap", "Navmesh"]);
+assert.deepStrictEqual(p.options.map((o) => o.disabled), [false, true, true]);
+// the floor is read off a label when the entry has no floor field
+assert.strictEqual(P.slots([{ kind: "worldmap", label: "World map 5f", available: true, url: "/minimap/189.worldmap-5f.png", x: 0, y: 0, w: 1, h: 1 }])[0].floor, "5f");
+
+console.log("picslots.js ok");
diff --git a/internal/dashboard/view_test.go b/internal/dashboard/view_test.go
index b93b8d36..e55391ed 100644
--- a/internal/dashboard/view_test.go
+++ b/internal/dashboard/view_test.go
@@ -118,6 +118,10 @@ func TestMarkerCSSDoesNotStyleLabelPlates(t *testing.T) {
// TestMapLayers: the layer defaults, the one global saved choice, availability and the geometry of the newer layers.
func TestMapLayers(t *testing.T) { runCheck(t, "layers_check.js", "layers.js") }
+// TestPictureSlots: the three map picture slots in Zul'Farrak's order, a missing kind, the floor entries, the saved choice
+// and the picker's options.
+func TestPictureSlots(t *testing.T) { runCheck(t, "picslots_check.js", "picslots.js") }
+
// TestUnitInfo: what a character is doing, the hover box, status icons, threat colours, the event feed and the run progress.
// TestPathMath: trails, planned vs walked, pull-group states, objects, aggro rings, patrols, combat lines, the ghost and the heat bins.
func TestPathMath(t *testing.T) { runCheck(t, "paths_check.js", "paths.js") }
diff --git a/internal/dungeon/approach_clear_test.go b/internal/dungeon/approach_clear_test.go
new file mode 100644
index 00000000..4c644780
--- /dev/null
+++ b/internal/dungeon/approach_clear_test.go
@@ -0,0 +1,244 @@
+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 {
+ var out []standFinding
+ refs, patrols := groupStands(p)
+ 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 := r.stand, laterGroupClearYd
+ if len(r.g.Approach) == 0 {
+ 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
+}
+
+// groupRef is a pull group the run leader walks up to and pulls, with where its party stands to do it.
+type groupRef struct {
+ leg string
+ idx int
+ g PullGroup
+ stand Point
+}
+
+// groupStands lists, in plan order, the groups of the plan that the run leader walks up to and pulls, each with its stand (see the const
+// block), and the patrols of the groups counted. Groups the run leader does not walk up to (ambush, hold, off the navmesh) and repeats of a
+// pack an earlier leg lists are left out, and are not a stand's anchor.
+func groupStands(p *Plan) ([]groupRef, []Patrol) {
+ var refs []groupRef
+ 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
+ }
+ var stand Point
+ if n := len(g.Approach); n > 0 {
+ stand = g.Approach[n-1]
+ } else {
+ c := groupCentre(g)
+ d := groundDist(anchor, c)
+ if d <= runLeaderApproachYd {
+ stand = anchor
+ } else {
+ f := (d - runLeaderApproachYd) / d
+ stand = Point{anchor.X + float32(f)*(c.X-anchor.X), anchor.Y + float32(f)*(c.Y-anchor.Y), c.Z}
+ }
+ }
+ refs = append(refs, groupRef{l.Name, i, g, stand})
+ anchor = groupCentre(g)
+ }
+ }
+ return refs, patrols
+}
+
+// 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}
+
+// uldamanGuarded are the Uldaman groups the 2026-10-21 pull order changed (PLAN-ULDAMAN-GRIMLOK; key: the first spawn): the Stone Steward, the Sculptor
+// trio and the two southern Rocksmashers before the Ancient Stone Keeper, Grimlok's first pull with the third Brawler, the two groups moved in from the
+// keepers_door leg, the merged patrol and trio, and the pair before Grimlok. The rest of Uldaman stays report-only (the two findings of the old order
+// that sat on the moved groups are gone; one on the Keeper leg's third group remains).
+var uldamanGuarded = map[Point]bool{
+ {X: -39.6, Y: 272.8}: true, {X: -44.1, Y: 272.7}: true, {X: -49.6, Y: 202}: true,
+ {X: 30.5, Y: 375.8}: true, {X: 49.3, Y: 346.2}: true, {X: 37, Y: 331.8}: true, {X: 57.9, Y: 374}: true, {X: 37.6, Y: 438.8}: true,
+}
+
+// The guarded groups of Zul'Farrak and Uldaman 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 guarded := map[string]map[Point]bool{"zulfarrak": zfGuarded, "uldaman": uldamanGuarded}[p.Name]; guarded != nil {
+ for _, x := range approachClearanceOf(p, guarded) {
+ 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, "; "))
+}
diff --git a/internal/dungeon/buffs.go b/internal/dungeon/buffs.go
index 0cfbce13..809fded6 100644
--- a/internal/dungeon/buffs.go
+++ b/internal/dungeon/buffs.go
@@ -173,7 +173,7 @@ func (d *Driver) leaderHas(list []Buff) bool {
}
// sendBuffs has the leader put each of list on the member with ".aura ", every application flagged in the report
-// as a "buff" shortcut. Each command goes with the selection of the member as ONE session operation (Game.GMOn): the
+// as a "buff" shortcut. Each command goes only once the server has confirmed the selection of the member (Game.GMOn): the
// core runs .aura on whatever is selected when it arrives, and the target RPC or the addon host could change the
// selection between a separate Target and GM call (the world buffs would land on a boss).
//
diff --git a/internal/dungeon/driver.go b/internal/dungeon/driver.go
index 0f5f4949..cc850c7d 100644
--- a/internal/dungeon/driver.go
+++ b/internal/dungeon/driver.go
@@ -35,8 +35,8 @@ type Game interface {
Chat(ctx context.Context, channel, text string) error
// GM runs a GM command (".revive Name").
GM(ctx context.Context, text string) error
- // GMOn selects the unit and runs a GM command that acts on the selected unit (".aura ") as one operation,
- // so that nothing else can change the selection between the two.
+ // GMOn selects the unit, waits until the server is known to have that selection in force, and runs a GM command that
+ // acts on the selected unit (".aura ") only while it stands; an error, with nothing sent, when it is not confirmed.
GMOn(ctx context.Context, guid packet.GUID, text string) error
Teleport(ctx context.Context, to Point, mapID uint32) error
// ProbeAccount sends ".account" (SEC_PLAYER, Chat.cpp:77) and reads the account's security level from the server's reply
diff --git a/internal/dungeon/gossip_remote_test.go b/internal/dungeon/gossip_remote_test.go
index aa999790..33b9e73b 100644
--- a/internal/dungeon/gossip_remote_test.go
+++ b/internal/dungeon/gossip_remote_test.go
@@ -149,8 +149,9 @@ func TestRemoteGossipByTheCarrierSendsTheOrderAndTheAckEndsIt(t *testing.T) {
if err != nil {
t.Fatalf("%v\n%s", err, notesOf(rep))
}
- // The carrier is the first bot that is neither the lead, the tank nor the healer.
- if fmt.Sprint(g.orders) != "[cmd gossip 7607 Magey 0]" || g.talk.hellos != 0 || len(g.talk.selects) != 0 {
+ // The carrier is the first bot that is neither the lead, the tank nor the healer. The plan's option 0 (the menu's first line) is the
+ // order's 1: `cmd gossip` counts from 1 and refuses 0 (TortoiseBots GossipOrderPolicy.h).
+ if fmt.Sprint(g.orders) != "[cmd gossip 7607 Magey 1]" || g.talk.hellos != 0 || len(g.talk.selects) != 0 {
t.Fatalf("orders %v hellos %d selects %v", g.orders, g.talk.hellos, g.talk.selects)
}
s := rep.Remote.ObjectSteps[0]
@@ -242,3 +243,21 @@ func TestRemoteGossipByTheAgentNeedsHands(t *testing.T) {
t.Fatalf("%v", err)
}
}
+
+// The order counts the menu from 1 (TortoiseBots GossipOrderPolicy.h refuses 0), the plan's option is the menu's own index from 0: the order
+// carries option + 1, and a text goes as it is.
+func TestGossipOrderCountsFromOne(t *testing.T) {
+ two, zero := uint32(2), uint32(0)
+ for _, tc := range []struct {
+ g Gossip
+ want string
+ }{
+ {Gossip{NPC: 7607, Option: &zero}, "cmd gossip 7607 Tanky 1"},
+ {Gossip{NPC: 7607, Option: &two}, "cmd gossip 7607 Tanky 3"},
+ {Gossip{NPC: 7604, Text: "settled"}, "cmd gossip 7604 Tanky settled"},
+ } {
+ if got := gossipOrder(&tc.g, "Tanky"); got != tc.want {
+ t.Errorf("%+v: %q, want %q", tc.g, got, tc.want)
+ }
+ }
+}
diff --git a/internal/dungeon/hire_check.go b/internal/dungeon/hire_check.go
index 9fb56537..5b4a11c1 100644
--- a/internal/dungeon/hire_check.go
+++ b/internal/dungeon/hire_check.go
@@ -334,32 +334,38 @@ func (f *hireFlow) gearAndSupplies(ctx context.Context, b *hireBot) {
}
drinks, ammo, short := g.supplies(class, role)
if b.reused || (len(short) > 0 && !f.spec.NoFix) {
- before := fmt.Sprintf("drinks %d", drinks)
- if ammo != "" {
- before += ", " + ammo
- }
- switch v := maintainBot(ctx, f.e.g, b.name); v {
- case "maintained":
- if g2, err := f.readGear(ctx, b.name); err != nil {
- b.fail("inventory after maintenance: %v", err)
- return
- } else {
- g = g2
- }
- drinks, ammo, short = g.supplies(class, role)
- after := fmt.Sprintf("drinks %d", drinks)
+ if !f.e.maintain {
+ // An observed run's hire check (#253): `.bot maintenance` walks the hire to a vendor right after its autogear and scatters
+ // the party, which pauses the run leader ("the lead bot is not on the plan's map"). Supplies are reported as they are.
+ b.warn("observe mode: .bot maintenance skipped (observe.maintenance is off): supplies are as the hire came")
+ } else {
+ before := fmt.Sprintf("drinks %d", drinks)
if ammo != "" {
- after += ", " + ammo
+ before += ", " + ammo
}
- if before == after {
- b.rb.Fixed = append(b.rb.Fixed, "maintenance: repaired, nothing to restock ("+after+")")
- } else {
- b.rb.Fixed = append(b.rb.Fixed, fmt.Sprintf("maintenance: %s -> %s", before, after))
+ switch v := maintainBot(ctx, f.e.g, b.name); v {
+ case "maintained":
+ if g2, err := f.readGear(ctx, b.name); err != nil {
+ b.fail("inventory after maintenance: %v", err)
+ return
+ } else {
+ g = g2
+ }
+ drinks, ammo, short = g.supplies(class, role)
+ after := fmt.Sprintf("drinks %d", drinks)
+ if ammo != "" {
+ after += ", " + ammo
+ }
+ if before == after {
+ b.rb.Fixed = append(b.rb.Fixed, "maintenance: repaired, nothing to restock ("+after+")")
+ } else {
+ b.rb.Fixed = append(b.rb.Fixed, fmt.Sprintf("maintenance: %s -> %s", before, after))
+ }
+ case "disabled":
+ b.warn("maintenance is off on the server (AiPlayerbot.OwnedBotMaintenanceEnabled = 0): supplies are as the hire came")
+ default:
+ b.warn("maintenance: %s", v)
}
- case "disabled":
- b.warn("maintenance is off on the server (AiPlayerbot.OwnedBotMaintenanceEnabled = 0): supplies are as the hire came")
- default:
- b.warn("maintenance: %s", v)
}
}
if len(short) > 0 {
diff --git a/internal/dungeon/hire_test.go b/internal/dungeon/hire_test.go
index 864d7b27..2c546225 100644
--- a/internal/dungeon/hire_test.go
+++ b/internal/dungeon/hire_test.go
@@ -1508,3 +1508,39 @@ func TestHireEpicIsJudgedAgainstTheBotsOwnLevel(t *testing.T) {
t.Fatalf("an epic for level 44 passed on a bot of level 42: %v %s", err, rep.State)
}
}
+
+// The hire check's `.bot maintenance` follows the observe switch (#253): an observed run without observe.maintenance sends none (it walks
+// the hire to a vendor right after autogear); the supplies it finds are reported, and a driven run keeps topping them up.
+func TestHireMaintenanceFollowsTheObserveSwitch(t *testing.T) {
+ run := func(maintain bool) (*hireFake, *PrepareReport, error) {
+ g := newHireFake(t) // noSupplies: the hires come with empty bags
+ e := &prepEnv{g: g, plan: g.plan, spec: PrepareSpec{Party: PartyHire}, remote: true, maintain: maintain,
+ now: func() time.Time { return g.now }, sleep: g.clock().Sleep, note: func(string) {}, command: g.Command, settle: time.Second}
+ rep, err := runPrepare(context.Background(), e)
+ return g, rep, err
+ }
+ g, rep, err := run(false)
+ if n := g.count("maintenance"); n != 0 {
+ t.Fatalf("an observed hire check sent %d .bot maintenance lines: %v", n, g.botCmds)
+ }
+ if err == nil || !strings.Contains(err.Error(), "supplies:") {
+ t.Fatalf("the empty supplies were not reported: %v", err)
+ }
+ warned := false
+ for _, b := range rep.Ready.Bots {
+ for _, w := range b.Warnings {
+ warned = warned || strings.Contains(w, "observe mode: .bot maintenance skipped")
+ }
+ }
+ if !warned {
+ t.Fatalf("no warning names the skipped maintenance: %+v", rep.Ready.Bots)
+ }
+ g, rep, err = run(true)
+ if err != nil || !rep.Ready.Ready {
+ t.Fatalf("a driven hire check with maintenance failed: %v", err)
+ }
+ // The healer, the caster and the hunter (ammo); the tank and the rogue have nothing to top up.
+ if n := g.count("maintenance"); n != 3 || g.count("maintenance is disabled") != 0 {
+ t.Fatalf("maintenance lines %d: %v", n, g.botCmds)
+ }
+}
diff --git a/internal/dungeon/leave_clear_test.go b/internal/dungeon/leave_clear_test.go
new file mode 100644
index 00000000..81b76c9a
--- /dev/null
+++ b/internal/dungeon/leave_clear_test.go
@@ -0,0 +1,341 @@
+package dungeon
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/navmesh"
+)
+
+// The leave-clearance rule (zf-r6-19z24, PLAN-ZF-LESSONS section 3 F-a/F-c): a plan names the spawns a run leaves alone (a leg's `leave`
+// list: Zul'Farrak's lone Shadowhunter 7246, spawn 81610, on a navmesh island at 1591 1047.7). No approach point, stand or move point of the
+// plan may lie within LeaveClearYd of one: the run leader's hop pull takes every living hostile within 15 yd of a hop point that no later
+// step owns, and 848693b's approach rows (7.4 and 12.9 yd from 81610) sent the tank to fight it, onto the island, where every move was
+// refused. The agent's own fix of 15 Oct was plan data of the same kind (the group was dropped).
+
+type leaveFinding struct {
+ leg string
+ what string // "pull group 4 approach 0", "pull group 4 stand", "to", "via 2"
+ at Point
+ leave Leave
+ dist float64
+}
+
+func (f leaveFinding) String() string {
+ return fmt.Sprintf("leg %s %s %.1f %.1f is %.1f yd from the left-alone %d (spawn %d) at %.1f %.1f (at least %.0f wanted)", f.leg, f.what, f.at.X, f.at.Y, f.dist, f.leave.Entry, f.leave.Spawn, f.leave.At.X, f.leave.At.Y, LeaveClearYd)
+}
+
+// leaveClearance returns the findings of the rule for a plan, in plan order: every approach point of a pull group or boss, every pull
+// group's stand (its last approach point, or the point the run leader walks to 30 yd short of it), every leg's `to` and every via point
+// that stands within LeaveClearYd (ground distance) of a Leave of the plan.
+func leaveClearance(p *Plan) []leaveFinding {
+ leaves := p.Leaves()
+ if len(leaves) == 0 {
+ return nil
+ }
+ var out []leaveFinding
+ check := func(leg, what string, at Point) {
+ for _, lv := range leaves {
+ if d := groundDist(at, lv.At); d < LeaveClearYd {
+ out = append(out, leaveFinding{leg, what, at, lv, d})
+ }
+ }
+ }
+ refs, _ := groupStands(p)
+ for _, l := range p.Legs {
+ if l.To != (Point{}) {
+ check(l.Name, "to", l.To)
+ }
+ for i, w := range l.Via {
+ check(l.Name, fmt.Sprintf("via %d", i), w.Point)
+ }
+ for i, g := range l.PullGroups {
+ for j, a := range g.Approach {
+ check(l.Name, fmt.Sprintf("pull group %d approach %d", i, j), a)
+ }
+ }
+ if b := l.Boss; b != nil {
+ for j, a := range b.Approach {
+ check(l.Name, fmt.Sprintf("boss approach %d", j), a)
+ }
+ }
+ }
+ for _, r := range refs {
+ if len(r.g.Approach) == 0 { // the last approach point is checked above
+ check(r.leg, fmt.Sprintf("pull group %d stand", r.idx), r.stand)
+ }
+ }
+ return out
+}
+
+// Every shipped plan passes the rule; the finding reads which point, and how far.
+func TestPlansKeepClearOfLeave(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
+ }
+ for _, x := range leaveClearance(p) {
+ t.Errorf("%s: %s", p.Name, x)
+ }
+ }
+}
+
+// Zul'Farrak leaves the island Shadowhunter alone: spawn 81610 (entry 7246) at 1591 1047.7, named by the velratha leg.
+func TestZulfarrakLeavesTheIslandShadowhunter(t *testing.T) {
+ p := loadPlan(t, zulfarrakPlan)
+ var island *Leave
+ for _, lv := range p.Leaves() {
+ if lv.Spawn == 81610 {
+ island = &lv
+ }
+ }
+ if island == nil || island.Entry != 7246 || groundDist(island.At, Point{1591, 1047.7, 9}) > 1 {
+ t.Fatalf("the plan does not leave spawn 81610 (7246 at 1591 1047.7) alone: %+v", p.Leaves())
+ }
+ if island.Why == "" {
+ t.Error("the leave has no reason")
+ }
+ if fs := leaveClearance(p); len(fs) != 0 {
+ t.Fatalf("the plan has findings: %v", fs)
+ }
+}
+
+// The regression: 848693b's approach rows (1584 1050 and 1603 1043: 7.4 and 12.9 yd from 81610) must be found, on the first and on the last
+// approach point; a plan with no leave is clean.
+func TestLeaveClearanceFailsOnTheOldIslandApproach(t *testing.T) {
+ p := loadPlan(t, zulfarrakPlan)
+ for li := range p.Legs {
+ l := &p.Legs[li]
+ if l.Name != "velratha" {
+ continue
+ }
+ for gi := range l.PullGroups {
+ if len(l.PullGroups[gi].Approach) > 0 && l.PullGroups[gi].Entries[0] == 5650 && len(l.PullGroups[gi].Entries) == 5 {
+ l.PullGroups[gi].Approach = []Point{{1584, 1050, 9}, {1603, 1043, 9.8}}
+ }
+ }
+ }
+ var hit []string
+ for _, f := range leaveClearance(p) {
+ hit = append(hit, f.String())
+ }
+ all := strings.Join(hit, "\n")
+ if len(hit) != 2 || !strings.Contains(all, "approach 0 1584.0 1050.0 is 7.4 yd") || !strings.Contains(all, "approach 1 1603.0 1043.0 is 12.9 yd") {
+ t.Fatalf("848693b's approach rows are not found on both points: %v", hit)
+ }
+ t.Logf("old approach findings: %s", strings.Join(hit, "; "))
+ for li := range p.Legs {
+ p.Legs[li].Leave = nil
+ }
+ if fs := leaveClearance(p); len(fs) != 0 {
+ t.Errorf("a plan with no leave has findings: %v", fs)
+ }
+}
+
+// The stand the run leader computes (no approach rows) is held to the rule too, and a boss approach point and a via point are found.
+func TestLeaveClearanceChecksComputedStandsBossesAndVias(t *testing.T) {
+ p := &Plan{Entrance: Point{0, 0, 0}, Legs: []Leg{{
+ Name: "a",
+ To: Point{100, 0, 0},
+ Via: []Waypoint{{Point: Point{100, 40, 0}}},
+ Leave: []Leave{{Entry: 9, Spawn: 1, At: Point{100, 45, 0}, Why: "island"}},
+ PullGroups: []PullGroup{
+ {Entries: []uint32{1}, Positions: []Point{{100, 90, 0}}, Radius: 15},
+ },
+ Boss: &Boss{Entry: 2, Position: Point{100, 130, 0}, Approach: []Point{{100, 52, 0}}},
+ }}}
+ var got []string
+ for _, f := range leaveClearance(p) {
+ got = append(got, f.what)
+ }
+ // via 0 at 5 yd, the stand (60 yd short of... 30 yd from the group: 100 60) at 15.0 is NOT a finding, the boss approach at 7 yd is.
+ want := "via 0,boss approach 0"
+ if strings.Join(got, ",") != want {
+ t.Errorf("findings %v, want %s", got, want)
+ }
+ p.Legs[0].PullGroups[0].Positions = []Point{{100, 70, 0}} // the stand is now (100, 40): 5 yd from the leave
+ got = nil
+ for _, f := range leaveClearance(p) {
+ got = append(got, f.what)
+ }
+ if !strings.Contains(strings.Join(got, ","), "pull group 0 stand") {
+ t.Errorf("the computed stand is not checked: %v", got)
+ }
+}
+
+// walkMinYd is the shortest ground distance from at to any segment of the path (all segments, not the first that is close).
+func walkMinYd(at Point, path navmesh.Path) float64 {
+ min := math.Inf(1)
+ for i := 0; i+1 < len(path.Points); i++ {
+ x, y := path.Points[i], path.Points[i+1]
+ if d := pointToSegYd(at, Point{x[0], x[1], x[2]}, Point{y[0], y[1], y[2]}); d < min {
+ min = d
+ }
+ }
+ if len(path.Points) == 1 {
+ min = groundDist(at, Point{path.Points[0][0], path.Points[0][1], path.Points[0][2]})
+ }
+ return min
+}
+
+// leaveWalkClearance walks every pull group with approach rows on the navmesh, from where the party stands after the group before it (that
+// group's stand) through the approach points in order, and returns a finding for each walk whose shortest distance to a Leave, over all its
+// segments, is under LeaveClearYd; the finding carries that shortest distance. The points alone are not enough: zulfarrak's first velratha
+// rows 1584 1050 -> 1603 1043 are the navmesh's short way past the island, 0.8 yd from the Shadowhunter, whichever side the points are on.
+func leaveWalkClearance(t *testing.T, p *Plan, pl *navmesh.Planner) []leaveFinding {
+ t.Helper()
+ leaves := p.Leaves()
+ refs, _ := groupStands(p)
+ var out []leaveFinding
+ for k, r := range refs {
+ if k == 0 || len(r.g.Approach) == 0 {
+ continue
+ }
+ from := refs[k-1].stand
+ for j, a := range r.g.Approach {
+ path := planWalk(t, pl, p.Map, from, a)
+ if path.Status != navmesh.Complete {
+ t.Errorf("leg %s pull group %d approach %d %v from %v: %s %s", r.leg, r.idx, j, a, from, path.Status, path.Reason)
+ continue
+ }
+ for _, lv := range leaves {
+ if d := walkMinYd(lv.At, path); d < LeaveClearYd {
+ out = append(out, leaveFinding{r.leg, fmt.Sprintf("pull group %d walk to approach %d (from %.0f %.0f)", r.idx, j, from.X, from.Y), a, lv, d})
+ }
+ }
+ from = a
+ }
+ }
+ return out
+}
+
+func planWalk(t *testing.T, pl *navmesh.Planner, mapID uint32, from, to Point) navmesh.Path {
+ t.Helper()
+ path, err := pl.Plan(mapID, navmesh.Vec3{from.X, from.Y, from.Z}, navmesh.Vec3{to.X, to.Y, to.Z})
+ if errors.Is(err, navmesh.ErrNoEngine) {
+ t.Skip("built without cgo: no navmesh engine")
+ }
+ if err != nil {
+ t.Fatalf("walk %v -> %v: %v", from, to, err)
+ }
+ return path
+}
+
+// leavePullWalkClearance is the module's own walk after the approach rows: from each pull group's stand toward the pack (its centre and each
+// member's position: the tank walks to its firing stand, about 30 yd short of the pack, and the pull fetches the pack back along the same
+// ground), and the pack's way back to the stand. Both are navmesh paths of the stand and the pack, in both directions; a finding carries
+// the shortest distance to a Leave over every segment of every one of them. Unlike the approach rows these walks are not plan data: a
+// finding here is a risk for the module to meet, not a row to move.
+func leavePullWalkClearance(t *testing.T, p *Plan, pl *navmesh.Planner) []leaveFinding {
+ t.Helper()
+ leaves := p.Leaves()
+ refs, _ := groupStands(p)
+ var out []leaveFinding
+ for _, r := range refs {
+ targets := append([]Point{groupCentre(r.g)}, r.g.Positions...)
+ for _, lv := range leaves {
+ min, which := math.Inf(1), ""
+ for ti, to := range targets {
+ for dir, walk := range [2][2]Point{{r.stand, to}, {to, r.stand}} {
+ path := planWalk(t, pl, p.Map, walk[0], walk[1])
+ if len(path.Points) == 0 {
+ continue
+ }
+ if d := walkMinYd(lv.At, path); d < min {
+ min = d
+ which = fmt.Sprintf("stand to pack position %d", ti-1)
+ if ti == 0 {
+ which = "stand to pack centre"
+ }
+ if dir == 1 {
+ which = strings.Replace(which, "stand to pack", "pack to stand", 1)
+ }
+ }
+ }
+ }
+ if min < LeaveClearYd {
+ out = append(out, leaveFinding{r.leg, fmt.Sprintf("pull group %d %s (stand %.0f %.0f)", r.idx, which, r.stand.X, r.stand.Y), r.stand, lv, min})
+ }
+ }
+ }
+ return out
+}
+
+// zfKnownLeaveWalks are the walks of Zul'Farrak that pass the island Shadowhunter closer than LeaveClearYd and that plan data cannot move
+// (measured on the 209 navmesh, 2026-10-21; the value is the shortest distance over every segment of the walk, held to +-0.5 yd so a change
+// of the plan or of the mesh that makes one worse, or better, has to be looked at):
+// - velratha group 5, the approach walk 1606 1040 -> 1588 1080: 5.0 yd. The only way north from the east side to the next pack is the west
+// lane, which the island's mesh patch sits beside.
+// - velratha group 4, the pull walk from the stand 1606 1040 toward the pack at 1583 1087 (the tank walks to its firing stand about 30 yd
+// short of it, around y 1060, and the pull fetches the pack back along the same lane): 5.0 yd. The pack holds Shadowhunters (7246) like
+// the island mob, so a social-link aggro of 81610 by the dragged pack is possible too.
+// - velratha group 3, the lone Blood Drinker at 1585.1 1035.5: 13.6 yd, the pack's own position.
+//
+// The Shadowhunter is passive at such range (zf-r6: 4 passes at 100 % out of combat; the agent walks it every time), so what keeps the run
+// leader's hop pull and melee chase from taking it is the module (PLAN-ZF-LESSONS #2), not the plan.
+var zfKnownLeaveWalks = []struct {
+ what string
+ yd float64
+}{
+ {"pull group 5 walk to approach 0", 5.0},
+ {"pull group 4 stand to pack", 5.0},
+ {"pull group 3 stand to pack", 13.6},
+}
+
+// Zul'Farrak's walks to its approach points keep 15 yd from the island Shadowhunter except the known ones; the old rows, whose points were
+// too close anyway, are found by the walk too (their short way passes 0.8 yd from it).
+func TestZulfarrakApproachWalkKeepsClearOfLeave(t *testing.T) {
+ dir := zulfarrakMmaps()
+ if dir == "" {
+ t.Skip("no map 209 navmesh files: set TURTLE_AGENT_MMAPS to a folder holding 209.mmap and the 209*.mmtile tiles")
+ }
+ pl := navmesh.NewPlanner(dir)
+ defer pl.Close()
+ p := loadPlan(t, zulfarrakPlan)
+ seen := map[string]bool{}
+ for _, f := range append(leaveWalkClearance(t, p, pl), leavePullWalkClearance(t, p, pl)...) {
+ known := false
+ for _, k := range zfKnownLeaveWalks {
+ if f.leg == "velratha" && strings.HasPrefix(f.what, k.what) {
+ known = true
+ seen[k.what] = true
+ t.Logf("known: %s", f)
+ if f.dist < k.yd-0.5 || f.dist > k.yd+0.5 {
+ t.Errorf("the known walk %q is %.1f yd from the Shadowhunter, recorded %.1f: look at it and record the new figure", k.what, f.dist, k.yd)
+ }
+ }
+ }
+ if !known {
+ t.Errorf("%s", f)
+ }
+ }
+ for _, k := range zfKnownLeaveWalks {
+ if !seen[k.what] {
+ t.Errorf("the known walk %q is no longer within %.0f yd of the island Shadowhunter: remove it from zfKnownLeaveWalks", k.what, LeaveClearYd)
+ }
+ }
+ for li := range p.Legs {
+ l := &p.Legs[li]
+ for gi := range l.PullGroups {
+ if l.Name == "velratha" && len(l.PullGroups[gi].Approach) == 3 {
+ l.PullGroups[gi].Approach = []Point{{1584, 1050, 9}, {1603, 1043, 9.8}}
+ }
+ }
+ }
+ found := false
+ for _, f := range leaveWalkClearance(t, p, pl) {
+ if f.leg == "velratha" && strings.HasPrefix(f.what, "pull group 4 walk to approach 1") && f.dist < 3 {
+ found = true
+ t.Logf("old rows' walk: %s", f)
+ }
+ }
+ if !found {
+ t.Fatal("the walk of 848693b's rows (1584 1050 -> 1603 1043, 0.8 yd from the Shadowhunter) is not found")
+ }
+}
diff --git a/internal/dungeon/observe.go b/internal/dungeon/observe.go
index 6112b648..321a0758 100644
--- a/internal/dungeon/observe.go
+++ b/internal/dungeon/observe.go
@@ -5,12 +5,14 @@ import (
"errors"
"fmt"
"math"
+ "regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/pjerra/turtle-agent/internal/events"
+ "github.com/pjerra/turtle-agent/internal/packet"
"github.com/pjerra/turtle-agent/internal/tbm"
)
@@ -36,6 +38,17 @@ type ObserveSpec struct {
// PausedWait is how long a paused run waits for a resume (by a person on the server, or dungeon_resume) before the watch stops it and
// fails (default 10 min). The run leader pauses itself at 3 dead, an unsupported step or a ceiling and never goes on alone.
PausedWait time.Duration
+ // Maintenance sends `.bot maintenance ` for every bot at the preflight, as a driven run does. Off by default: maintenance
+ // walks each bot to a vendor, bank or mailbox, and a party scattered over the maps right after autogear pauses the run leader ("the
+ // lead bot is not on the plan's map"). The inventory and talent reads still run.
+ Maintenance bool
+ // AllowFarStart lets a partway start go on although the lead bot stands more than farStartYd from the start step's point: the note
+ // says so and the run leader walks there (or pauses) on its own. Off by default: a party in the wrong place is refused.
+ AllowFarStart bool
+ // KeepLootSetting leaves the bots' corpse looting as the operator set it: the start sends no `.bot behavior loot on` (the
+ // run start's undo of a crashed run's boss `loot off`). Off by default. Observe only: a driven run always sends the undo, since
+ // its legs turn looting off and on themselves and a crashed run may have left it off.
+ KeepLootSetting bool
}
// Observe defaults.
@@ -48,6 +61,12 @@ const (
observeGone = 20 * time.Second
// MaxRunChanges bounds the changes kept in the report.
MaxRunChanges = 300
+ // wipeDeadReArm is the dead-member count below which the wipe latch re-arms (TortoiseBots DungeonRunPolicy.kWipeDead, 3): a party
+ // that is all dead, one member alive for a poll, all dead again, is one collapse, not two.
+ wipeDeadReArm = 3
+ // wipeWindow is a wipe counted from the feed's deaths: every member of the party dying within this long, even when the lead bot
+ // revives in between (the RFC run: all five dead within 63 s, never in one frame).
+ wipeWindow = 90 * time.Second
)
func (s ObserveSpec) withDefaults(plan *Plan) ObserveSpec {
@@ -69,6 +88,31 @@ func (s ObserveSpec) withDefaults(plan *Plan) ObserveSpec {
return s
}
+// PartialStart is where an observed run's `.bot run start from ...` puts the run leader (#248).
+type PartialStart struct {
+ // Words are the `from ...` words: "from 4", "from leg serpentis", "from step 207"; "" for the first step.
+ Words string
+ // Step is the requested step in the run leader's own numbering when the mapping knows it (from_step, from_pull_group); 0 when only
+ // the leg is known. It is the expected value of the start reply's step; the reply never changes it.
+ Step int
+ // Leg is the plan leg it starts at (-1 for a step start), LegNumber the leg's number in the exported rows and LegName the name sent
+ // ("" when the number was sent because the name is not safe for the command line or is not unique). ExpectLeg is the plan leg's name
+ // for a step start whose leg the mapping knows (it may be empty when the rows could not be reproduced).
+ Leg int
+ LegNumber int
+ LegName string
+ ExpectLeg string
+ // Point is where the start step (or the leg's first point) stands on the plan's map; HavePoint says it is known. It is the party's
+ // place: a partway start is refused when the lead bot stands far from it (ObserveSpec.AllowFarStart overrides).
+ Point Point
+ HavePoint bool
+}
+
+// StartFromResolver maps an observed run's partial start (from_leg, from_pull_group, from_step) to how it starts once the server's step
+// count for the plan is known. The mapping is the cmd layer's (it owns the plan exporter); the driver only asks. An error refuses the
+// run before a bot is hired.
+type StartFromResolver func(serverSteps int) (PartialStart, error)
+
// RunLeaderWatcher is the optional part of a RemoteGame that has read the TBM:RUN lines the module pushes: the newest one and how
// many have been read. Without it the observer relies on the replies to `.bot run start` and `.bot run status`.
type RunLeaderWatcher interface {
@@ -85,6 +129,7 @@ type RunChange struct {
Kind string `json:"kind,omitempty"`
Phase string `json:"phase,omitempty"`
Note string `json:"note,omitempty"`
+ Left int `json:"left,omitempty"` // packs the run leader left alive
}
// ObserveReport is the report's part for an observed run (Report.Remote.Observe).
@@ -99,6 +144,7 @@ type ObserveReport struct {
Kind string `json:"kind,omitempty"`
Phase string `json:"phase,omitempty"`
Note string `json:"note,omitempty"`
+ Left int `json:"left,omitempty"` // packs the run leader left alive
// Text is the status in plain words ("sentinel · pull 7/41 · pulling").
Text string `json:"text,omitempty"`
// Started: `.bot run start` was acknowledged (or an own run of the plan was adopted).
@@ -109,6 +155,8 @@ type ObserveReport struct {
Pauses int `json:"pauses"`
// Controls are the pause, resume, skip and stop requests sent, in order, each with its answer.
Controls []string `json:"controls,omitempty"`
+ // PlayerOrders are the pauses the module made for a player's order (the observer's `cmd summon`, a move), each with the step and the order.
+ PlayerOrders []string `json:"player_orders,omitempty"`
// Ended is how the watch ended: done, or the reason of a failure or stop.
Ended string `json:"ended,omitempty"`
// Changes are the status changes, oldest first (the newest MaxRunChanges).
@@ -121,23 +169,35 @@ func (r *ObserveReport) clone() *ObserveReport {
}
c := *r
c.Controls = append([]string(nil), r.Controls...)
+ c.PlayerOrders = append([]string(nil), r.PlayerOrders...)
c.Changes = append([]RunChange{}, r.Changes...)
return &c
}
// observeState is the observer's own state.
type observeState struct {
- spec ObserveSpec
- started bool
- startedAt time.Time
- last tbm.RunStatus
- haveLast bool
- lastAt time.Time // when the status last changed
- pausedAt time.Time
- seenN uint64
- baseN uint64 // lines read before the start: older ones are another run's
- lastPoll time.Time
- serverEnd bool // the module said the run is done: no stop is needed
+ spec ObserveSpec
+ started bool
+ startedAt time.Time
+ last tbm.RunStatus
+ haveLast bool
+ lastAt time.Time // when the status last changed
+ pausedAt time.Time
+ seenN uint64
+ baseN uint64 // lines read before the start: older ones are another run's
+ lastPoll time.Time
+ serverEnd bool // the module said the run is done: no stop is needed
+ wiped bool // the feed showed the whole party dead: one rising edge of it is one wipe
+ wasDead map[packet.GUID]bool // dead in the previous frame (nil until the first frame)
+ deathAt map[packet.GUID]time.Time // when each member last went dead
+ leadDownAt time.Time // when the lead bot last went dead
+ leadDown bool
+ lastWipeAt time.Time // when the last wipe was counted
+ // The run leader's own rest (observe mode has no gate of the agent's): the phase rest of its status is the gate.
+ restSince time.Time // when the run leader's current rest phase began
+ restOpen bool
+ // playerPaused: the run is paused for a player's order (the module's pause for a `cmd`), so the status says why it waits.
+ playerPaused bool
mu sync.Mutex
reqs []string // pause, resume, skip requests from the RPC
@@ -184,6 +244,10 @@ func runLeaderRefusal(verb, plan string, e *tbm.Err) string {
"lead-unavailable": "the lead bot is between maps",
"no-run": "no run is going",
"state": "the run is not in a state that allows it",
+ "no-leg": "the run leader's plan has no such leg: from_leg counts the exported rows (an optional leg takes no number); start at a later leg or pass observe.from_step",
+ "no-step": "the run leader's plan has no such step: observe.from_step is the number .bot run status shows, from 1",
+ "not-in-instance": "a partway start needs the party inside the instance: enter it first, or start from the beginning",
+ "party-scattered": "a partway start needs the whole party in the lead bot's instance (.bot cmd summon first)",
}[e.Code]
if hint == "" {
return fmt.Sprintf("%s (%s)", e.Code, e.Message)
@@ -214,10 +278,35 @@ func (d *RemoteDriver) probeRunLeader(ctx context.Context) error {
if list != "" && !strings.Contains(" "+strings.ReplaceAll(strings.ReplaceAll(list, ",", " "), "(", " ")+" ", " "+spec.Plan+" ") {
return fmt.Errorf("observe mode: the server's run leader has no plan %q (it lists: %s): export the plan with `turtle-agent plan-export`, apply the migration and restart the server", spec.Plan, list)
}
+ d.planSteps = listedSteps(list, spec.Plan)
+ if d.opt.StartFrom != nil {
+ // A partial start is mapped here, before anything is hired: an unmappable from_pull_group must not pay for a party first.
+ start, err := d.opt.StartFrom(d.planSteps)
+ if err != nil {
+ return fmt.Errorf("observe mode: %w", err)
+ }
+ d.start = &start
+ if start.Words != "" {
+ d.rep.note(fmt.Sprintf("observe mode: the run leader starts partway (`run start %s %s`; the server lists %d steps)", spec.Plan, start.Words, d.planSteps))
+ }
+ }
d.rep.note("observe mode: the server's run leader answered; plans: " + nonEmpty(list, "(not listed)"))
return nil
}
+// listedSteps reads a plan's step count out of `.bot run list` ("wailing_caverns (map 43, 226 steps)"). 0: the plan is not in the
+// list, or the list does not carry a count (an older module).
+func listedSteps(list, plan string) int {
+ // The plan name is followed by its map and count; a name is [a-z0-9_-] (planNameRe in the RPC layer).
+ re := regexp.MustCompile(`(?:^|, )` + regexp.QuoteMeta(plan) + ` \(map \d+, (\d+) steps`)
+ if m := re.FindStringSubmatch(list); m != nil {
+ if n, err := strconv.Atoi(m[1]); err == nil {
+ return n
+ }
+ }
+ return 0
+}
+
func nonEmpty(s, def string) string {
if s == "" {
return def
@@ -236,6 +325,9 @@ func (d *RemoteDriver) observe(ctx context.Context) (err error) {
})
// Whatever way the watch ends, a run still alive on the server is stopped: the agent leaves the party idle, not half-led.
defer func() {
+ if o.restOpen {
+ d.endObservedRest(d.clk.Now())
+ }
why, reason := "", ""
var ceiling *runCeilingError
switch {
@@ -261,6 +353,9 @@ func (d *RemoteDriver) observe(ctx context.Context) (err error) {
if w, ok := d.g.(RunLeaderWatcher); ok {
_, o.baseN, _ = w.RunStatus()
}
+ if err := d.checkStartPlace(); err != nil {
+ return err
+ }
if err := d.startRunLeader(ctx); err != nil {
return err
}
@@ -277,7 +372,26 @@ func (d *RemoteDriver) observe(ctx context.Context) (err error) {
return err
}
d.resendViewIfSilent(ctx)
- d.countDeaths()
+ // The wipe count of an observed run: the run leader owns the run (it stops and recovers on its own), the watch only counts. The
+ // result of countDeaths is an edge, not a level: without the wiped flag three polls of a dead party would count three wipes. The
+ // latch re-arms only once fewer than wipeDeadReArm members are dead (the module's kWipeDead): a single in-place revive (one member
+ // alive for a poll, dead again the next) re-arms a plain "not all dead" check and double counts the collapse. The "wipe" event goes
+ // to the trace, so the tracecheck deaths oracle sees the wipes of an observed run too (stepFailedRecover never runs in observe mode).
+ // The window rule counts a wipe the frames never show: every member died within wipeWindow, each death newer than the last counted
+ // wipe, so a lead bot that revives between their deaths does not hide the collapse. trackObservedDeaths reads the same frames for
+ // the lead bot's deaths and revives.
+ all := d.countDeaths()
+ window := d.trackObservedDeaths(d.clk.Now())
+ if (all || window) && !o.wiped {
+ d.rep.update(func(r *Report) { r.Wipes++ })
+ o.lastWipeAt = d.clk.Now()
+ d.tr.Event("wipe", map[string]any{"leg": d.curLeg, "error": "the whole party is dead", "observe": true, "window": !all})
+ }
+ if all || window {
+ o.wiped = true
+ } else if deadCount(d.fr) < wipeDeadReArm {
+ o.wiped = false
+ }
d.serveRunRequests(ctx)
d.readRunLines()
if d.clk.Now().Sub(o.lastPoll) >= spec.Poll {
@@ -289,17 +403,23 @@ func (d *RemoteDriver) observe(ctx context.Context) (err error) {
}
}
-// startRunLeader sends `.bot run start ` and takes its answer.
+// startRunLeader sends `.bot run start [from ...]` and takes its answer. The `from ...` words are the probe's resolution of a
+// partial start (from_leg, from_pull_group, from_step): the run leader begins at that step of its own plan (#248).
func (d *RemoteDriver) startRunLeader(ctx context.Context) error {
o := d.obs
- r, err := d.g.Command(ctx, "run start "+o.spec.Plan)
+ line := "run start " + o.spec.Plan
+ if d.start != nil && d.start.Words != "" {
+ line += " " + d.start.Words
+ }
+ r, err := d.g.Command(ctx, line)
if err != nil {
return fmt.Errorf("run start: %w", err)
}
d.takeLines(r.Lines)
switch {
case r.Kind == tbm.KindErr && r.Err != nil && r.Err.Code == "busy":
- // A run of this plan is already going (the agent restarted, or the run was started by hand): watch it, unless it is another plan's.
+ // A run of this plan is already going (the agent restarted, or the run was started by hand): watch it, unless it is another plan's
+ // or a partial start was asked for and the run going is at another step (A: adopting it would watch a run from the wrong place).
sr, serr := d.g.Command(ctx, "run status")
if serr != nil {
return fmt.Errorf("run start: busy, and run status: %w", serr)
@@ -308,11 +428,18 @@ func (d *RemoteDriver) startRunLeader(ctx context.Context) error {
if !o.haveLast || o.last.Plan != o.spec.Plan || o.last.State == tbm.RunDone || o.last.State == tbm.RunNone {
return fmt.Errorf("run start refused: %s", runLeaderRefusal("start", o.spec.Plan, r.Err))
}
+ if why := d.startMatchesStatus(o.last); why != "" {
+ return fmt.Errorf("run start refused: %s", why)
+ }
d.rep.note(fmt.Sprintf("observe mode: a run of %s was already going (%s); watching it", o.spec.Plan, o.last.Text()))
case r.Kind == tbm.KindErr:
return fmt.Errorf("run start refused: %s", runLeaderRefusal("start", o.spec.Plan, r.Err))
case r.Kind == tbm.KindTimeout && !o.haveLast:
return errors.New("run start: no answer: this server has no run leader (`.bot run` needs TortoiseBots PRs #962 and #963), or it is not answering")
+ default:
+ if err := d.verifyStartReply(ctx, r.Lines); err != nil {
+ return err
+ }
}
o.started = true
d.remote(func(rr *RemoteReport) { rr.Observe.Started = true })
@@ -320,6 +447,162 @@ func (d *RemoteDriver) startRunLeader(ctx context.Context) error {
return nil
}
+// startMatchesStatus says why a run already going cannot stand for the requested partial start ("" when it can). A step start needs the
+// status at that step; a leg start by name needs the status in that leg; a leg start by number cannot be compared with the status line's
+// leg (a name) reliably, so it never adopts (A).
+func (d *RemoteDriver) startMatchesStatus(st tbm.RunStatus) string {
+ s := d.start
+ if s == nil || s.Words == "" {
+ return ""
+ }
+ switch {
+ case s.Step > 0:
+ if st.Step != s.Step {
+ return fmt.Sprintf("busy: the run going is at step %d/%d, not the requested step %d: stop it (.bot run stop) or wait for it to end", st.Step, st.Steps, s.Step)
+ }
+ case s.LegName != "":
+ if !strings.EqualFold(st.Leg, s.LegName) {
+ return fmt.Sprintf("busy: the run going is in leg %q, not the requested leg %q: stop it (.bot run stop) or wait for it to end", st.Leg, s.LegName)
+ }
+ default:
+ return "busy: a run going cannot be compared with a from_leg sent as a number (its status line names its leg): stop it (.bot run stop) and start again, or wait for it to end"
+ }
+ return ""
+}
+
+// startLineRe is the run leader's start message for a partway start (TortoiseBots, `.bot run start ... from ...`): "The run starts at
+// step 207 of 226 (leg serpentis): the party is in the instance; ...". A trailing sentence says when the start was moved back into the
+// step that opens a scripted event (G).
+var startLineRe = regexp.MustCompile(`The run starts at step (\d+) of (\d+) \(leg ([^)]+)\)`)
+
+// verifyStartReply proves where a partial start really began, and refuses the watch otherwise (D, fail closed). The first source is the
+// run leader's own start message ("The run starts at step X of Y (leg NAME)"), which must name the plan leg asked for. A module that
+// answers only with an ACK (or a message whose text changed, or a reply collection that dropped it) is asked with `run status` right
+// after the start: the reported step (for a step start) and leg must be the requested ones. When neither source proves it, the newly
+// started run is stopped and the watch is refused: no unverified run is watched, and the failed run's unbind leaves no hire bound.
+func (d *RemoteDriver) verifyStartReply(ctx context.Context, lines []string) error {
+ if d.start == nil || d.start.Words == "" {
+ return nil
+ }
+ for _, line := range lines {
+ m := startLineRe.FindStringSubmatch(line)
+ if m == nil {
+ continue
+ }
+ step, _ := strconv.Atoi(m[1])
+ steps, _ := strconv.Atoi(m[2])
+ leg := strings.TrimSpace(m[3])
+ movedBack := strings.Contains(line, "moved back")
+ // The reply's step is checked against the requested one; the reply never overwrites it. The one documented difference is the
+ // module moving a start that fell inside a scripted event back to the step that opens it (the reply says "moved back").
+ if d.start.Step > 0 && step != d.start.Step && !movedBack {
+ d.stopRunLeader("the start line is not the requested start", "")
+ return fmt.Errorf("observe mode: run start: the run leader started at step %d of %d (leg %s), not the requested step %d: the deployed start is not the plan's; the run was stopped and is not watched", step, steps, leg, d.start.Step)
+ }
+ if want, ok := d.expectedStartLeg(); ok && !movedBack && !strings.EqualFold(leg, want) {
+ d.stopRunLeader("the start line is not the requested start", "")
+ return fmt.Errorf("observe mode: run start: the run leader started in leg %q, not the requested leg %q: the deployed start is not the plan's; the run was stopped and is not watched", leg, want)
+ }
+ d.rep.note(fmt.Sprintf("observe mode: the run leader starts at step %d of %d (leg %s)", step, steps, leg))
+ if movedBack {
+ d.rep.note("observe mode: the run leader moved the start back to the step that opens the scripted event it fell in")
+ }
+ return nil
+ }
+ // No start message: the status line right after the start is the second source.
+ why := "`run status` gave no answer"
+ if sr, err := d.g.Command(ctx, "run status"); err != nil {
+ why = "`run status`: " + err.Error()
+ } else {
+ d.takeLines(sr.Lines)
+ if proof := d.startProof(); proof == "" {
+ d.rep.note("observe mode: the start message was not read; `run status` proves the start (step " + strconv.Itoa(d.obs.last.Step) + "/" + strconv.Itoa(d.obs.last.Steps) + " leg " + d.obs.last.Leg + ")")
+ return nil
+ } else {
+ why = "`run status` " + proof
+ }
+ }
+ d.stopRunLeader("the start could not be proven", "")
+ return fmt.Errorf("observe mode: run start: the run leader's start was not proven (no start message, and %s); the run was stopped and is not watched", why)
+}
+
+// startProof says why the newest status line does not prove the requested start ("" when it does). A step start needs that step; the
+// leg too when the mapping knows it. A leg start needs the plan leg's name (or its number, for an unnamed leg). Without the start line
+// there is no documented "moved back" sentence, so no step difference is accepted here.
+func (d *RemoteDriver) startProof() string {
+ o, s := d.obs, d.start
+ if !o.haveLast || o.last.Plan != o.spec.Plan {
+ return "does not show this run's status"
+ }
+ if s.Step > 0 && o.last.Step != s.Step {
+ return fmt.Sprintf("shows step %d/%d, not the requested step %d", o.last.Step, o.last.Steps, s.Step)
+ }
+ if want, ok := d.expectedStartLeg(); ok && !strings.EqualFold(o.last.Leg, want) {
+ return fmt.Sprintf("shows leg %q, not %q", o.last.Leg, want)
+ }
+ if s.Step == 0 && s.Leg < 0 {
+ return "has no leg to compare"
+ }
+ return ""
+}
+
+// expectedStartLeg is the plan leg a partial start must report: the leg asked for, or the legacy of the requested step (ExpectLeg, from
+// the plan rows). "" when the mapping could not resolve it (a from_step whose rows are not reproduced): then only the step is checked.
+func (d *RemoteDriver) expectedStartLeg() (string, bool) {
+ s := d.start
+ if s.ExpectLeg != "" {
+ return s.ExpectLeg, true
+ }
+ if s.Leg < 0 || s.Leg >= len(d.plan.Legs) {
+ return "", false
+ }
+ if name := d.plan.Legs[s.Leg].Name; name != "" {
+ return name, true
+ }
+ return strconv.Itoa(s.LegNumber), true
+}
+
+// farStartYd is how far from the start step's point a partway start tolerates the lead bot (ObserveSpec.AllowFarStart overrides).
+const farStartYd = 80.0
+
+// checkStartPlace refuses a partway start whose step stands far from the party (E): the run leader walks from wherever the party is,
+// so a party left at the entrance for a step deep in the dungeon would walk the plan from the wrong place. It fails closed: a start
+// whose point or the lead bot's position is not known is refused too, since the place cannot be checked. observe.allow_far_start turns
+// each refusal into a note.
+func (d *RemoteDriver) checkStartPlace() error {
+ if d.start == nil || d.start.Words == "" {
+ return nil
+ }
+ allow := d.opt.Observe.withDefaults(d.plan).AllowFarStart
+ if !d.start.HavePoint {
+ msg := "the start step's point is not known (the deployed rows could not be reproduced from the plan file), so the party's place cannot be checked"
+ if allow {
+ d.rep.note("observe mode: " + msg + " (allow_far_start: going on)")
+ return nil
+ }
+ return fmt.Errorf("observe mode: %s: put the party at the start step first, or pass observe.allow_far_start", msg)
+ }
+ lead, ok := d.leadPos()
+ if !ok {
+ msg := "the lead bot's position is not known, so the party's place at the start step cannot be checked"
+ if allow {
+ d.rep.note("observe mode: " + msg + " (allow_far_start: going on)")
+ return nil
+ }
+ return fmt.Errorf("observe mode: %s: pass observe.allow_far_start to start anyway", msg)
+ }
+ dist := dist3(lead, d.start.Point)
+ if dist <= farStartYd {
+ return nil
+ }
+ at := fmt.Sprintf("the lead bot stands %.0f yd from the start step's point %.0f %.0f %.0f", dist, d.start.Point.X, d.start.Point.Y, d.start.Point.Z)
+ if allow {
+ d.rep.note("observe mode: " + at + " (allow_far_start: the run leader walks from there)")
+ return nil
+ }
+ return fmt.Errorf("observe mode: %s: put the party there first, or pass observe.allow_far_start", at)
+}
+
// runCeilingError is the watch ending at the run ceiling (ObserveSpec.MaxRun, observe.max_run_s).
type runCeilingError struct {
max time.Duration
@@ -439,6 +722,22 @@ func (d *RemoteDriver) takeLines(lines []string) {
}
}
+// playerOrderOf reads the order out of the note of a pause the module made for a player's order: "player order (): ". A later
+// status of the same pause appends to the note ("; X meanwhile: ..."), so the order ends at the first "): ". ok is false for any other note.
+func playerOrderOf(note string) (order string, ok bool) {
+ rest, ok := strings.CutPrefix(note, "player order (")
+ if !ok {
+ return "", false
+ }
+ if order, _, ok := strings.Cut(rest, "): "); ok {
+ return order, true
+ }
+ if i := strings.LastIndex(rest, ")"); i >= 0 {
+ return rest[:i], true
+ }
+ return "", false
+}
+
// takeStatus folds one status into the observer, the report, the trace, the events and the current-step label.
func (d *RemoteDriver) takeStatus(st tbm.RunStatus) {
o := d.obs
@@ -453,6 +752,16 @@ func (d *RemoteDriver) takeStatus(st tbm.RunStatus) {
}
prev, had := o.last, o.haveLast
o.last, o.haveLast, o.lastAt = st, true, now
+ // The run leader's rest phase is the observed run's rest: a running status still in phase rest keeps it open (one rest), any
+ // other phase or state closes it before the leg switch below, so the rest lands in the leg it happened in. A paused status keeps
+ // the phase it paused in, but the pause is waiting for a person, not resting: it closes the rest, and the resume opens a new one.
+ if st.Phase == "rest" && st.State == tbm.RunRunning {
+ if !o.restOpen {
+ o.restSince, o.restOpen = now, true
+ }
+ } else if o.restOpen {
+ d.endObservedRest(now)
+ }
if st.State == tbm.RunPaused && (!had || prev.State != tbm.RunPaused) {
o.pausedAt = now
}
@@ -462,10 +771,10 @@ func (d *RemoteDriver) takeStatus(st tbm.RunStatus) {
if st.State == tbm.RunDone {
o.serverEnd = true
}
- ch := RunChange{At: now, State: st.State, Step: st.Step, Steps: st.Steps, Leg: st.Leg, Kind: st.Kind, Phase: st.Phase, Note: st.Note}
+ ch := RunChange{At: now, State: st.State, Step: st.Step, Steps: st.Steps, Leg: st.Leg, Kind: st.Kind, Phase: st.Phase, Note: st.Note, Left: st.Left}
d.remote(func(r *RemoteReport) {
ob := r.Observe
- ob.State, ob.Step, ob.Steps, ob.Leg, ob.Kind, ob.Phase, ob.Note, ob.Text = st.State, st.Step, st.Steps, st.Leg, st.Kind, st.Phase, st.Note, st.Text()
+ ob.State, ob.Step, ob.Steps, ob.Leg, ob.Kind, ob.Phase, ob.Note, ob.Left, ob.Text = st.State, st.Step, st.Steps, st.Leg, st.Kind, st.Phase, st.Note, st.Left, st.Text()
ob.Lines++
if st.State == tbm.RunPaused && (!had || prev.State != tbm.RunPaused) {
ob.Pauses++
@@ -475,13 +784,38 @@ func (d *RemoteDriver) takeStatus(st tbm.RunStatus) {
ob.Changes = append([]RunChange{}, ob.Changes[n-MaxRunChanges:]...)
}
})
+ // A pause the module made for a player's order (the observer's own `cmd summon`, a move) is the observer's doing: the report names the
+ // order so a person sees why the run waited. Only the first status of that pause counts: the module appends to the note of a pause it
+ // already made. The run going on again is the resume after the order, whether the agent or a person sent it.
+ if order, ok := playerOrderOf(st.Note); st.State == tbm.RunPaused && ok && !o.playerPaused {
+ o.playerPaused = true
+ d.remote(func(r *RemoteReport) {
+ if ob := r.Observe; len(ob.PlayerOrders) < 50 {
+ ob.PlayerOrders = append(ob.PlayerOrders, fmt.Sprintf("step %d/%d (%s): %s", st.Step, st.Steps, st.Leg, order))
+ }
+ })
+ d.control("player order: " + order)
+ d.tr.Event("player_order", map[string]any{"order": order, "step": st.Step, "leg_name": st.Leg})
+ d.rep.note("observe mode: the run paused for a player order (" + order + ")")
+ }
+ if o.playerPaused {
+ switch st.State {
+ case tbm.RunRunning:
+ o.playerPaused = false
+ d.control(fmt.Sprintf("resumed after the player order at step %d", st.Step))
+ d.rep.note("observe mode: the run went on after the player order")
+ case tbm.RunDone:
+ // The run stopped instead of going on: the pause is over, but there was no resume to record (#270).
+ o.playerPaused = false
+ }
+ }
label := "run leader: " + st.Text()
if st.State != tbm.RunRunning {
label = "run leader " + st.State + ": " + st.Text()
}
d.rep.setCurrent(label)
d.wd.progress("run leader: " + st.Text())
- data := map[string]any{"mode": "observe", "state": st.State, "step": st.Step, "steps": st.Steps, "leg_name": st.Leg, "kind": st.Kind, "phase": st.Phase, "note": st.Note}
+ data := map[string]any{"mode": "observe", "state": st.State, "step": st.Step, "steps": st.Steps, "leg_name": st.Leg, "kind": st.Kind, "phase": st.Phase, "note": st.Note, "left": st.Left}
d.tr.Event("run_leader", data)
// The legs: a new leg name ends the one before it and starts the next, so the report has the per-leg times.
if li := d.legOf(st.Leg); li >= 0 && li != d.curLeg && st.State != tbm.RunNone {
@@ -494,8 +828,84 @@ func (d *RemoteDriver) takeStatus(st tbm.RunStatus) {
d.emit(events.KindDungeonLeg, fmt.Sprintf("leg %d %s: start", li, name), map[string]any{"leg": li, "name": name, "phase": "start", "mode": "observe"})
}
if !had || prev.Step != st.Step || prev.Kind != st.Kind {
- d.emit(events.KindDungeonStep, "run leader: "+st.Text(), map[string]any{"leg": d.curLeg, "step": st.Step, "steps": st.Steps, "action": st.Kind, "ok": true, "mode": "observe", "state": st.State, "phase": st.Phase, "note": st.Note})
+ d.emit(events.KindDungeonStep, "run leader: "+st.Text(), map[string]any{"leg": d.curLeg, "step": st.Step, "steps": st.Steps, "action": st.Kind, "ok": true, "mode": "observe", "state": st.State, "phase": st.Phase, "note": st.Note, "left": st.Left})
+ }
+}
+
+// endObservedRest closes the run leader's rest phase: it counts as a rest gate (Result "run_leader"), in the report, the leg and the trace.
+func (d *RemoteDriver) endObservedRest(now time.Time) {
+ o := d.obs
+ o.restOpen = false
+ secs := max(now.Sub(o.restSince).Seconds(), 0)
+ leg := max(d.curLeg, 0)
+ d.rep.update(func(r *Report) {
+ r.RestSeconds += secs
+ if n := len(r.Legs); n > 0 {
+ r.Legs[n-1].RestSeconds += secs
+ }
+ if len(r.RestGates) < maxRestGates {
+ r.RestGates = append(r.RestGates, RestGate{Leg: leg, Seconds: secs, Result: "run_leader"})
+ }
+ })
+ d.tr.Event("rest", map[string]any{"leg": d.curLeg, "seconds": secs, "source": "run_leader"})
+}
+
+// deadCount is how many members of the frame's party are dead (the latch of the wipe count compares it with wipeDeadReArm; tbm.ViewFrame
+// counts it nowhere: AllDead is the all-rounded edge, not the level).
+func deadCount(fr tbm.ViewFrame) int {
+ n := 0
+ for _, p := range fr.Party {
+ if p.Dead {
+ n++
+ }
+ }
+ return n
+}
+
+// trackObservedDeaths follows the party's deaths in the newest frame: the lead bot's deaths and revives go into the report
+// (LeaderDeaths, Resurrections, SecondsDead, the leg's too) and the trace (event leader_death), and the result says that every
+// member has died within wipeWindow, each death newer than the last counted wipe.
+func (d *RemoteDriver) trackObservedDeaths(now time.Time) bool {
+ o := d.obs
+ if !d.haveFr {
+ return false
+ }
+ if o.wasDead == nil { // the first frame is the baseline: a bot already dead in it is not a death
+ o.wasDead, o.deathAt = map[packet.GUID]bool{}, map[packet.GUID]time.Time{}
+ for _, p := range d.fr.Party {
+ o.wasDead[p.GUID] = p.Dead
+ }
+ return false
+ }
+ for _, p := range d.fr.Party {
+ isLead := d.fr.Lead != 0 && p.GUID == d.fr.Lead
+ if p.Dead && !o.wasDead[p.GUID] {
+ o.deathAt[p.GUID] = now
+ if isLead {
+ o.leadDown, o.leadDownAt = true, now
+ d.rep.update(func(r *Report) { r.LeaderDeaths++ })
+ d.legStat(func(l *LegReport) { l.LeaderDeaths++ })
+ d.tr.Event("leader_death", map[string]any{"name": p.Name, "a": guidText(p.GUID), "leg": d.curLeg})
+ }
+ }
+ if !p.Dead && o.wasDead[p.GUID] && isLead && o.leadDown {
+ o.leadDown = false
+ secs := now.Sub(o.leadDownAt).Seconds()
+ d.rep.update(func(r *Report) { r.Resurrections++; r.SecondsDead += secs })
+ d.legStat(func(l *LegReport) { l.Resurrections++; l.SecondsDead += secs })
+ }
+ o.wasDead[p.GUID] = p.Dead
+ }
+ if len(d.fr.Party) < 2 {
+ return false
+ }
+ for _, p := range d.fr.Party {
+ at, ok := o.deathAt[p.GUID]
+ if !ok || !at.After(o.lastWipeAt) || now.Sub(at) > wipeWindow {
+ return false
+ }
}
+ return true
}
// legOf finds the plan leg a status line names (by name, or by number when the module had no name); -1 when none.
diff --git a/internal/dungeon/observe_test.go b/internal/dungeon/observe_test.go
index 4d294671..f7f75ffb 100644
--- a/internal/dungeon/observe_test.go
+++ b/internal/dungeon/observe_test.go
@@ -3,12 +3,20 @@ package dungeon
import (
"context"
"encoding/json"
+ "errors"
"fmt"
+ "os"
+ "path/filepath"
+ "slices"
"strings"
"testing"
"time"
+ "github.com/pjerra/turtle-agent/internal/packet"
"github.com/pjerra/turtle-agent/internal/tbm"
+ "github.com/pjerra/turtle-agent/internal/trace"
+ "github.com/pjerra/turtle-agent/internal/tracecheck"
+ "github.com/pjerra/turtle-agent/internal/tracerun"
)
// leaderStep is a status the fake run leader reaches after `at` of running time.
@@ -25,8 +33,9 @@ type fakeLeader struct {
listText string // the answer of `run list`; empty: the server has no run leader and says nothing
startErr string // the code `run start` is refused with
script []leaderStep
- noStopReason bool // a module from before TortoiseBots #1070: `run stop ` is invalid
- pushOff bool // the feed does not deliver the pushed lines: only `run status` shows them
+ noStopReason bool // a module from before TortoiseBots #1070: `run stop ` is invalid
+ pushOff bool // the feed does not deliver the pushed lines: only `run status` shows them
+ startExtra []string // extra lines the module's answer to `run start` carries (its start message)
running, paused bool
progress time.Duration
@@ -58,7 +67,11 @@ func runLine(st tbm.RunStatus) string {
}
return s
}
- return fmt.Sprintf("TBM:RUN|%s|%s|%d/%d|%s|%s|%s|%s", d(st.Plan), st.State, st.Step, st.Steps, d(st.Leg), d(st.Kind), d(st.Phase), d(st.Note))
+ s := fmt.Sprintf("TBM:RUN|%s|%s|%d/%d|%s|%s|%s|%s", d(st.Plan), st.State, st.Step, st.Steps, d(st.Leg), d(st.Kind), d(st.Phase), d(st.Note))
+ if st.Left > 0 {
+ s += fmt.Sprintf("|left %d", st.Left)
+ }
+ return s
}
func (l *fakeLeader) emit(st tbm.RunStatus) {
@@ -129,7 +142,7 @@ func (l *fakeLeader) Command(ctx context.Context, command string) (tbm.Reply, er
l.running = true
r := ack(intent)
l.advanceLeader()
- r.Lines = l.statusLines()
+ r.Lines = append(slices.Clone(l.startExtra), l.statusLines()...)
return r, nil
case "status":
r := ack(intent)
@@ -258,6 +271,26 @@ func TestObserveWatchesARunToTheEnd(t *testing.T) {
}
}
+// A done line with packs left behind keeps the count: the note is bare and the report carries Left.
+func TestObserveKeepsThePacksLeftCount(t *testing.T) {
+ done := rs("done", 5, 5, "second", "move", "walk", "plan done")
+ done.Left = 2
+ g := newFakeLeader(t,
+ leaderStep{0, rs("running", 1, 5, "first", "move", "walk", "walking")},
+ leaderStep{s(10), done})
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ ob := rep.Remote.Observe
+ if ob.Left != 2 || ob.Note != "plan done" || ob.State != "done" {
+ t.Fatalf("observe %+v", ob)
+ }
+ if last := ob.Changes[len(ob.Changes)-1]; last.Left != 2 || last.Note != "plan done" {
+ t.Fatalf("last change %+v", last)
+ }
+}
+
func TestObservePollsWhenNothingIsPushed(t *testing.T) {
g := newFakeLeader(t, happyScript()...)
g.pushOff = true
@@ -273,6 +306,431 @@ func TestObservePollsWhenNothingIsPushed(t *testing.T) {
}
}
+// wipeParty kills the whole fake party in place (or brings it back): the next frame the feed publishes shows it.
+func wipeParty(g *fakeRemote, dead bool) {
+ for _, p := range g.party {
+ p.Dead, p.HP, p.State = dead, 0, "d"
+ if !dead {
+ p.HP, p.State = p.MaxHP, "n"
+ }
+ }
+}
+
+// wipeScript keeps the run going while the party dies and lives again: statuses every 5 s (no stall), done at 20 s.
+func wipeScript() []leaderStep {
+ return []leaderStep{
+ {0, rs("running", 1, 5, "first", "move", "walk", "walking to -1 2 3")},
+ {s(5), rs("running", 2, 5, "first", "pull", "pull", "pulling 299")},
+ {s(10), rs("running", 3, 5, "first", "boss", "pull", "pulling boss")},
+ {s(20), rs("done", 5, 5, "first", "move", "walk", "plan done")},
+ }
+}
+
+// deathsOnTicks drives the party's deaths by the ticks of the watch (one sleep of cfg.Poll, one frame, one countDeaths each):
+// first all dead for 3 polls, then alive for 1, then all dead again for 2, then alive to the end.
+func deathsOnTicks(g *fakeLeader, deadTicks int, reviveAt int, deadAgainTo int) {
+ base := g.onTick
+ ticks := 0
+ g.onTick = func(fr *fakeRemote) {
+ base(fr)
+ if !g.running {
+ return
+ }
+ ticks++
+ switch {
+ case ticks <= deadTicks, ticks > reviveAt && ticks <= deadAgainTo:
+ wipeParty(fr, true)
+ default:
+ wipeParty(fr, false)
+ }
+ }
+}
+
+// A wipe of an observed run is counted once, on the rising edge of "all dead": three polls of a dead party are one wipe, and the
+// party coming back alive lets the next wipe count again. The run leader owns the run, so the watch neither stops nor revives.
+func TestObserveCountsAWipeOnItsRisingEdge(t *testing.T) {
+ g := newFakeLeader(t, wipeScript()...)
+ g.party = append(g.party, &tbm.PartyRec{GUID: packet.GUID(0x2D), Name: "Loxy", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"},
+ &tbm.PartyRec{GUID: packet.GUID(0x2E), Name: "Grok", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"})
+ deathsOnTicks(g, 3, 4, 6)
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), nil)
+ if err != nil || rep.State != "done" || rep.Remote.Observe.Ended != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if rep.Wipes != 2 {
+ t.Fatalf("wipes %d (want 2)\n%s", rep.Wipes, notesOf(rep))
+ }
+}
+
+// A single member alive does not re-arm the wipe latch: all dead, one member alive (4 of 5), all dead again within the latch, then a
+// real regroup (nobody dead), then all dead again: two collapses, so Wipes == 2 and exactly 2 trace "wipe" events, and the tracecheck
+// deaths oracle reads them ("%d wipes" in its detail: stepFailedRecover never runs in an observed run).
+func TestObserveWipeLatchReArmsBelowThreeDead(t *testing.T) {
+ g := newFakeLeader(t, wipeScript()...)
+ g.party = append(g.party, &tbm.PartyRec{GUID: packet.GUID(0x2D), Name: "Loxy", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"},
+ &tbm.PartyRec{GUID: packet.GUID(0x2E), Name: "Grok", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"})
+ // the dead count of the tick, alive when negative
+ deadOf := map[int]int{1: 5, 2: 4, 3: 5, 4: 0, 5: 5}
+ base := g.onTick
+ ticks := 0
+ g.onTick = func(fr *fakeRemote) {
+ base(fr)
+ if !g.running {
+ return
+ }
+ ticks++
+ n := deadOf[ticks]
+ for i, p := range fr.party {
+ if dead := i < n; dead {
+ p.Dead, p.HP, p.State = true, 0, "d"
+ } else {
+ p.Dead, p.HP, p.State = false, p.MaxHP, "n"
+ }
+ }
+ }
+ dir := t.TempDir()
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{Poll: s(3)}), func(c *Config) { c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-wipe-hyst"} })
+ if err != nil || rep.State != "done" || rep.Remote.Observe.Ended != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if rep.Wipes != 2 {
+ t.Fatalf("wipes %d (want 2)\n%s", rep.Wipes, notesOf(rep))
+ }
+ f, err := trace.Read(filepath.Join(dir, "r-wipe-hyst.jsonl"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ wipes := 0
+ for _, e := range f.Events {
+ if e.Ev == "wipe" {
+ wipes++
+ }
+ }
+ if wipes != 2 {
+ t.Fatalf("%d wipe events in the trace (want 2)", wipes)
+ }
+ for _, c := range tracecheck.Score(f, tracecheck.Defaults()) {
+ if c.ID == tracecheck.IDDeaths && !strings.Contains(c.Detail, ", 2 wipes") {
+ t.Fatalf("the deaths oracle does not say 2 wipes: %+v", c)
+ }
+ }
+}
+
+// An observed run is driven by the server's run leader, which is the lead bot: the trace header names it as the tank, so the threat
+// oracle does not have to guess the tank from the frames. A driven run keeps the tank it was given (TestTraceOfARemoteRun).
+func TestObserveTraceNamesTheLeadAsTheTank(t *testing.T) {
+ g := newFakeLeader(t, happyScript()...)
+ dir := t.TempDir()
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), func(c *Config) {
+ c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-observe-tank"}
+ })
+ if err != nil || rep.Trace == nil || rep.Trace.Path == "" {
+ t.Fatalf("%v %+v", err, rep.Trace)
+ }
+ f, err := trace.Read(rep.Trace.Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.Header.Lead != "Tanky" || f.Header.Tank != f.Header.Lead {
+ t.Fatalf("header lead %q tank %q, want both Tanky", f.Header.Lead, f.Header.Tank)
+ }
+}
+
+// A party with one bot alive is no wipe: four dead of five count nothing.
+func TestObserveCountsNoWipeWhileABotLives(t *testing.T) {
+ g := newFakeLeader(t, wipeScript()...)
+ g.party = append(g.party, &tbm.PartyRec{GUID: packet.GUID(0x2D), Name: "Loxy", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"},
+ &tbm.PartyRec{GUID: packet.GUID(0x2E), Name: "Grok", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"})
+ base := g.onTick
+ ticks := 0
+ g.onTick = func(fr *fakeRemote) {
+ base(fr)
+ if !g.running {
+ return
+ }
+ ticks++
+ for i, p := range fr.party {
+ p.Dead, p.State = i < 4, "n"
+ if i < 4 {
+ p.HP, p.State = 0, "d"
+ }
+ }
+ }
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if rep.Wipes != 0 {
+ t.Fatalf("wipes %d (want 0)\n%s", rep.Wipes, notesOf(rep))
+ }
+}
+
+// observeRunScript keeps an observed run going past the death tables of the window tests: a status every 20 s and done at 200 s.
+func observeRunScript() []leaderStep {
+ return []leaderStep{
+ {0, rs("running", 1, 5, "first", "move", "walk", "walking")},
+ {s(20), rs("running", 2, 5, "first", "pull", "pull", "pulling")},
+ {s(40), rs("running", 3, 5, "first", "boss", "pull", "pulling")},
+ {s(60), rs("running", 3, 5, "first", "boss", "fight", "fighting")},
+ {s(80), rs("running", 4, 5, "first", "move", "walk", "walking")},
+ {s(100), rs("running", 4, 5, "first", "move", "walk", "walking on")},
+ {s(120), rs("running", 4, 5, "first", "move", "walk", "walking on")},
+ {s(140), rs("running", 4, 5, "first", "move", "walk", "walking on")},
+ {s(160), rs("running", 4, 5, "first", "move", "walk", "walking on")},
+ {s(180), rs("running", 4, 5, "first", "move", "walk", "walking on")},
+ {s(200), rs("done", 5, 5, "first", "move", "walk", "plan done")},
+ }
+}
+
+// deathsOnTickTable kills the party indexes the table names for each tick of the watch (one cfg.Poll sleep, one frame): the ticks
+// after the table's last key are all alive.
+func deathsOnTickTable(g *fakeLeader, table map[int][]int) {
+ base := g.onTick
+ ticks := 0
+ g.onTick = func(fr *fakeRemote) {
+ base(fr)
+ if !g.running {
+ return
+ }
+ ticks++
+ dead := map[int]bool{}
+ for _, i := range table[ticks] {
+ dead[i] = true
+ }
+ for i, p := range fr.party {
+ if dead[i] {
+ p.Dead, p.HP, p.State = true, 0, "d"
+ } else {
+ p.Dead, p.HP, p.State = false, p.MaxHP, "n"
+ }
+ }
+ }
+}
+
+// fiveBotLeader is the party of the wipe tests: five bots, the lead bot first (the frame's Lead).
+func fiveBotLeader(t *testing.T, script ...leaderStep) *fakeLeader {
+ g := newFakeLeader(t, script...)
+ g.party = append(g.party, &tbm.PartyRec{GUID: packet.GUID(0x2D), Name: "Loxy", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"},
+ &tbm.PartyRec{GUID: packet.GUID(0x2E), Name: "Grok", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"})
+ return g
+}
+
+// An observed run's report counts the lead bot's own deaths and revives from the feed: two deaths, each revived 5 s later, and no
+// wipe (the other four never died).
+func TestObserveCountsALeadDeathAndItsRevive(t *testing.T) {
+ g := fiveBotLeader(t, observeRunScript()...)
+ deathsOnTickTable(g, map[int][]int{2: {0}, 5: {0}})
+ dir := t.TempDir()
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), func(c *Config) {
+ c.Poll = 5 * time.Second
+ c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-lead-death"}
+ })
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if rep.LeaderDeaths != 2 || rep.Resurrections != 2 {
+ t.Fatalf("leader deaths %d resurrections %d (want 2 and 2)\n%s", rep.LeaderDeaths, rep.Resurrections, notesOf(rep))
+ }
+ if rep.SecondsDead < 5 || rep.SecondsDead > 20 {
+ t.Fatalf("seconds dead %v (want 5..20)", rep.SecondsDead)
+ }
+ if rep.Wipes != 0 {
+ t.Fatalf("wipes %d (want 0)\n%s", rep.Wipes, notesOf(rep))
+ }
+ if len(rep.Deaths) != 2 {
+ t.Fatalf("deaths %d (want 2): %+v", len(rep.Deaths), rep.Deaths)
+ }
+ legDeaths := 0
+ for _, l := range rep.Legs {
+ legDeaths += l.LeaderDeaths
+ }
+ if legDeaths != 2 {
+ t.Fatalf("leg leader deaths %d (want 2): %+v", legDeaths, rep.Legs)
+ }
+ f, err := trace.Read(filepath.Join(dir, "r-lead-death.jsonl"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := 0
+ for _, e := range f.Events {
+ if e.Ev == "leader_death" {
+ n++
+ if e.D["name"] != "Tanky" {
+ t.Fatalf("leader_death names %v (want Tanky)", e.D["name"])
+ }
+ }
+ }
+ if n != 2 {
+ t.Fatalf("%d leader_death events in the trace (want 2)", n)
+ }
+ if c := f.End.Counter("leader_deaths"); c != 2 {
+ t.Fatalf("trace end leader_deaths %v (want 2)", c)
+ }
+}
+
+// The RFC case: the lead bot revives, so the whole party is never dead in one frame, but all five died within 25 s: one wipe.
+func TestObserveCountsAWipeSpreadOverOneEpisode(t *testing.T) {
+ g := fiveBotLeader(t, observeRunScript()...)
+ deathsOnTickTable(g, map[int][]int{2: {0}, 3: {1}, 4: {1, 2}, 5: {1, 2, 3}, 6: {1, 2, 3, 4}, 7: {0, 2, 3, 4}})
+ dir := t.TempDir()
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), func(c *Config) {
+ c.Poll = 5 * time.Second
+ c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-window-wipe"}
+ })
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if rep.Wipes != 1 {
+ t.Fatalf("wipes %d (want 1)\n%s", rep.Wipes, notesOf(rep))
+ }
+ if rep.LeaderDeaths != 2 {
+ t.Fatalf("leader deaths %d (want 2)", rep.LeaderDeaths)
+ }
+ f, err := trace.Read(filepath.Join(dir, "r-window-wipe.jsonl"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ wipes := 0
+ for _, e := range f.Events {
+ if e.Ev == "wipe" {
+ wipes++
+ if e.D["window"] != true {
+ t.Fatalf("the wipe event is not a window wipe: %+v", e.D)
+ }
+ }
+ }
+ if wipes != 1 {
+ t.Fatalf("%d wipe events in the trace (want 1)", wipes)
+ }
+ for _, c := range tracecheck.Score(f, tracecheck.Defaults()) {
+ if c.ID == tracecheck.IDDeaths && !strings.Contains(c.Detail, ", 1 wipes") {
+ t.Fatalf("the deaths oracle does not say 1 wipes: %+v", c)
+ }
+ }
+}
+
+// The same deaths 30 s apart are outside the 90 s window: the lead's first death is 120 s older than the last member's, so no wipe.
+func TestObserveNoWipeWhenTheDeathsAreOutsideTheWindow(t *testing.T) {
+ g := fiveBotLeader(t,
+ leaderStep{0, rs("running", 1, 5, "first", "move", "walk", "walking")},
+ leaderStep{s(100), rs("running", 2, 5, "first", "pull", "pull", "pulling")},
+ leaderStep{s(200), rs("running", 3, 5, "first", "boss", "pull", "pulling")},
+ leaderStep{s(300), rs("running", 4, 5, "first", "move", "walk", "walking")},
+ leaderStep{s(400), rs("done", 5, 5, "first", "move", "walk", "plan done")})
+ deathsOnTickTable(g, map[int][]int{2: {0}, 3: {1}, 4: {1, 2}, 5: {1, 2, 3}, 6: {1, 2, 3, 4}, 7: {0, 2, 3, 4}})
+ dir := t.TempDir()
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), func(c *Config) {
+ c.Poll = 30 * time.Second
+ c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-window-out"}
+ })
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if rep.Wipes != 0 {
+ t.Fatalf("wipes %d (want 0)\n%s", rep.Wipes, notesOf(rep))
+ }
+ f, err := trace.Read(filepath.Join(dir, "r-window-out.jsonl"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range f.Events {
+ if e.Ev == "wipe" {
+ t.Fatalf("a wipe event outside the window: %+v", e.D)
+ }
+ }
+}
+
+// The window wipe and the all-dead latch are one wipe: counted at tick 6, the all-dead ticks 7 and 8 must not count it again.
+func TestObserveWindowWipeIsNotCountedTwiceWhenTheyThenAllDieTogether(t *testing.T) {
+ g := fiveBotLeader(t, observeRunScript()...)
+ deathsOnTickTable(g, map[int][]int{2: {0}, 3: {1}, 4: {1, 2}, 5: {1, 2, 3}, 6: {1, 2, 3, 4}, 7: {0, 1, 2, 3, 4}, 8: {0, 1, 2, 3, 4}})
+ dir := t.TempDir()
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), func(c *Config) {
+ c.Poll = 5 * time.Second
+ c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-window-once"}
+ })
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if rep.Wipes != 1 {
+ t.Fatalf("wipes %d (want 1)\n%s", rep.Wipes, notesOf(rep))
+ }
+ f, err := trace.Read(filepath.Join(dir, "r-window-once.jsonl"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ wipes := 0
+ for _, e := range f.Events {
+ if e.Ev == "wipe" {
+ wipes++
+ }
+ }
+ if wipes != 1 {
+ t.Fatalf("%d wipe events in the trace (want 1)", wipes)
+ }
+}
+
+// A lone death after the counted wipe is no second wipe. Loxy has been dead for 100 s when the other four die: the all-dead frame counts
+// the wipe while the window rule is false (Loxy's death is outside the 90 s), so the revive below three dead re-arms the latch. Loxy then
+// dies again 10 s after the wipe: the window rule reads deaths newer than the counted wipe only, so the four deaths of the wipe do not
+// make it fire again. The whole party dying inside the window cannot test this: the window rule then stays true for the 90 s of the
+// counted wipe, the latch never re-arms, and a lone death counts nothing either way.
+func TestObserveNoSecondWipeFromALoneDeathAfterAWindowWipe(t *testing.T) {
+ g := fiveBotLeader(t, observeRunScript()...)
+ base, ticks := g.onTick, 0
+ g.onTick = func(fr *fakeRemote) {
+ base(fr)
+ if !g.running {
+ return
+ }
+ ticks++
+ dead := map[int]bool{}
+ if ticks == 22 {
+ dead = map[int]bool{0: true, 1: true, 2: true, 4: true}
+ }
+ if ticks == 24 {
+ dead[3] = true
+ }
+ if ticks >= 2 && ticks <= 22 {
+ dead[3] = true // Loxy stays down through the collapse: a death older than the wipe's window
+ }
+ for i, p := range fr.party {
+ if dead[i] {
+ p.Dead, p.HP, p.State = true, 0, "d"
+ } else {
+ p.Dead, p.HP, p.State = false, p.MaxHP, "n"
+ }
+ }
+ }
+ dir := t.TempDir()
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), func(c *Config) {
+ c.Poll = 5 * time.Second
+ c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-window-lone"}
+ })
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if rep.Wipes != 1 {
+ t.Fatalf("wipes %d (want 1)\n%s", rep.Wipes, notesOf(rep))
+ }
+ if len(rep.Deaths) != 6 {
+ t.Fatalf("deaths %d (want 6: Loxy's two and the other four in the wipe): %+v", len(rep.Deaths), rep.Deaths)
+ }
+ f, err := trace.Read(filepath.Join(dir, "r-window-lone.jsonl"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ wipes := 0
+ for _, e := range f.Events {
+ if e.Ev == "wipe" {
+ wipes++
+ }
+ }
+ if wipes != 1 {
+ t.Fatalf("%d wipe events in the trace (want 1)", wipes)
+ }
+}
+
func TestObserveRefusesAServerWithoutTheRunLeaderBeforeHiring(t *testing.T) {
g := newFakeLeader(t)
g.listText = ""
@@ -353,6 +811,156 @@ func TestObservePauseAndResumeGoThroughTheRPC(t *testing.T) {
}
}
+func TestPlayerOrderOf(t *testing.T) {
+ for _, c := range []struct {
+ note string
+ order string
+ ok bool
+ }{
+ {"player order (cmd summon Healy): .bot run resume to go on", "cmd summon Healy", true},
+ {"player order (move 1 2 3): .bot run resume to go on; cave cleared meanwhile: .bot run resume goes on with step 4", "move 1 2 3", true},
+ {"player order (cmd revive)", "cmd revive", true},
+ {"player order (cmd summon Healy): .bot run resume to go on; note (2): a later aside", "cmd summon Healy", true},
+ {"3 dead: regroup (cmd revive), then .bot run resume", "", false},
+ {"", "", false},
+ } {
+ order, ok := playerOrderOf(c.note)
+ if ok != c.ok || order != c.order {
+ t.Fatalf("playerOrderOf(%q) = %q, %v", c.note, order, ok)
+ }
+ }
+}
+
+// A pause the module made for a player's order (the observer's own `cmd summon`, a move) is the observer's doing: the report names the order,
+// and the run going on again is recorded as the resume after it.
+func TestObserveRecordsAPlayerOrderPauseAndItsResume(t *testing.T) {
+ g := newFakeLeader(t,
+ leaderStep{0, rs("running", 1, 5, "first", "move", "walk", "walking")},
+ leaderStep{s(5), rs("paused", 2, 5, "first", "pull", "gather", "player order (cmd summon Healy): .bot run resume to go on")},
+ leaderStep{s(10), rs("running", 3, 5, "first", "pull", "pull", "pulling 299")},
+ leaderStep{s(20), rs("done", 5, 5, "second", "move", "walk", "plan done")})
+ var d *RemoteDriver
+ paused, asked := false, false
+ g.onStatus = func(l *fakeLeader, st tbm.RunStatus) {
+ // the module paused the run for the order: the leader holds it until a resume
+ if st.State == tbm.RunPaused {
+ paused = true
+ l.paused = true
+ }
+ }
+ tick := g.onTick
+ g.onTick = func(gr *fakeRemote) {
+ // the resume comes a look after the paused status: the watch serves requests before it reads the feed
+ if paused && !asked {
+ asked = true
+ if err := d.RunControl("resume"); err != nil {
+ t.Error(err)
+ }
+ }
+ tick(gr)
+ }
+ d = NewRemoteDriver(observePlan(), g, DefaultConfig(), observeOpts(ObserveSpec{}), g.clock())
+ err := d.Run(context.Background(), 0)
+ rep, _ := d.Status()
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ ob := rep.Remote.Observe
+ if len(ob.PlayerOrders) != 1 || !strings.Contains(ob.PlayerOrders[0], "cmd summon Healy") || !strings.Contains(ob.PlayerOrders[0], "step 2/") {
+ t.Fatalf("player orders %v", ob.PlayerOrders)
+ }
+ var order, resumed, ack bool
+ for _, c := range ob.Controls {
+ order = order || c == "player order: cmd summon Healy"
+ resumed = resumed || strings.HasPrefix(c, "resumed after the player order")
+ ack = ack || strings.HasPrefix(c, "resume: ack")
+ }
+ if !order || !resumed || !ack {
+ t.Fatalf("controls %v", ob.Controls)
+ }
+}
+
+// #270: a pause for a player order that ends in a stop did not resume: the latch lets go with no "resumed after" entry.
+func TestObservePlayerOrderPauseEndingInAStopHasNoResume(t *testing.T) {
+ g := newFakeLeader(t,
+ leaderStep{0, rs("running", 1, 5, "first", "move", "walk", "walking")},
+ leaderStep{s(5), rs("paused", 2, 5, "first", "pull", "gather", "player order (cmd summon Healy): .bot run resume to go on")},
+ leaderStep{s(10), rs("done", 5, 5, "second", "move", "walk", "plan done")})
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ ob := rep.Remote.Observe
+ if len(ob.PlayerOrders) != 1 {
+ t.Fatalf("player orders %v", ob.PlayerOrders)
+ }
+ for _, c := range ob.Controls {
+ if strings.HasPrefix(c, "resumed after the player order") {
+ t.Fatalf("a stop wrote a resume: %v", ob.Controls)
+ }
+ }
+}
+
+func TestObserveDoesNotCountAnOrdinaryPauseAsAPlayerOrder(t *testing.T) {
+ g := newFakeLeader(t,
+ leaderStep{0, rs("running", 1, 5, "first", "move", "walk", "walking")},
+ leaderStep{s(5), rs("paused", 2, 5, "first", "pull", "gather", "3 dead: regroup (cmd revive), then .bot run resume")},
+ leaderStep{s(10), rs("running", 3, 5, "first", "pull", "pull", "pulling 299")},
+ leaderStep{s(20), rs("done", 5, 5, "second", "move", "walk", "plan done")})
+ var d *RemoteDriver
+ paused, asked := false, false
+ g.onStatus = func(l *fakeLeader, st tbm.RunStatus) {
+ if st.State == tbm.RunPaused {
+ paused = true
+ l.paused = true
+ }
+ }
+ tick := g.onTick
+ g.onTick = func(gr *fakeRemote) {
+ if paused && !asked {
+ asked = true
+ if err := d.RunControl("resume"); err != nil {
+ t.Error(err)
+ }
+ }
+ tick(gr)
+ }
+ d = NewRemoteDriver(observePlan(), g, DefaultConfig(), observeOpts(ObserveSpec{}), g.clock())
+ err := d.Run(context.Background(), 0)
+ rep, _ := d.Status()
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if g.indexOf("run resume") < 0 {
+ t.Fatalf("the run was never resumed: %v", g.orders)
+ }
+ ob := rep.Remote.Observe
+ if len(ob.PlayerOrders) != 0 {
+ t.Fatalf("player orders %v", ob.PlayerOrders)
+ }
+ for _, c := range ob.Controls {
+ if strings.HasPrefix(c, "player order") || strings.HasPrefix(c, "resumed after") {
+ t.Fatalf("controls %v", ob.Controls)
+ }
+ }
+}
+
+// The module appends to the note of a pause it already made ("; boss meanwhile: ..."): that is the same player order, counted once.
+func TestObservePlayerOrderPauseRepeatedNoteCountsOnce(t *testing.T) {
+ g := newFakeLeader(t,
+ leaderStep{0, rs("running", 1, 5, "first", "move", "walk", "walking")},
+ leaderStep{s(5), rs("paused", 2, 5, "first", "pull", "gather", "player order (cmd summon Healy): .bot run resume to go on")},
+ leaderStep{s(10), rs("paused", 2, 5, "first", "pull", "gather", "player order (cmd summon Healy): .bot run resume to go on; boss meanwhile: .bot run resume goes on with step 3")},
+ leaderStep{s(20), rs("done", 5, 5, "second", "move", "walk", "plan done")})
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if ob := rep.Remote.Observe; len(ob.PlayerOrders) != 1 {
+ t.Fatalf("player orders %v", ob.PlayerOrders)
+ }
+}
+
func TestObserveAPausedRunThatNobodyResumesIsStopped(t *testing.T) {
g := newFakeLeader(t,
leaderStep{0, rs("running", 1, 5, "first", "move", "walk", "walking")},
@@ -500,3 +1108,620 @@ func TestRunControlNeedsAnObservedRun(t *testing.T) {
t.Fatal("a driven run took a run control")
}
}
+
+// maintenanceFake answers plain `.bot` lines (the preflight's maintenance, inv and talents) and records them, the way the real game does.
+type maintenanceFake struct {
+ *fakeLeader
+ botCmds []string
+}
+
+func (g *maintenanceFake) BotLines(_ context.Context, command string, _ time.Duration, done func(string) bool) ([]string, bool, error) {
+ g.botCmds = append(g.botCmds, command)
+ return nil, false, nil
+}
+
+// maintenanceCmds are the `.bot maintenance` lines among the plain lines.
+func maintenanceCmds(cmds []string) []string {
+ var out []string
+ for _, c := range cmds {
+ if strings.HasPrefix(c, "maintenance ") {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+// An observed run's preflight sends no `.bot maintenance` (it walks every bot to a vendor and scatters the party right after the hires'
+// autogear, which pauses the run leader with "the lead bot is not on the plan's map", #253); the kit check's reads still run.
+func TestObserveStartSendsNoMaintenanceByDefault(t *testing.T) {
+ g := &maintenanceFake{fakeLeader: newFakeLeader(t, happyScript()...)}
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if got := maintenanceCmds(g.botCmds); len(got) != 0 {
+ t.Fatalf("an observed start sent maintenance: %v", got)
+ }
+ if !slices.Contains(g.botCmds, "inv Tanky") || !slices.Contains(g.botCmds, "command Tanky talents") {
+ t.Fatalf("the kit check's reads did not run: %v", g.botCmds)
+ }
+ if !strings.Contains(strings.Join(rep.Notes, "\n"), "maintenance skipped (observe mode") {
+ t.Fatalf("notes %v", rep.Notes)
+ }
+}
+
+// observe.maintenance turns it back on, one line per bot of the first frame.
+func TestObserveMaintenanceOption(t *testing.T) {
+ g := &maintenanceFake{fakeLeader: newFakeLeader(t, happyScript()...)}
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{Maintenance: true}), nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ want := []string{"maintenance Tanky", "maintenance Healy", "maintenance Magey"}
+ if got := maintenanceCmds(g.botCmds); !slices.Equal(got, want) {
+ t.Fatalf("maintenance %v, want %v", got, want)
+ }
+}
+
+// A partial start (#248) goes out in the start line: the probe asks the resolver with the step count `.bot run list` gave, and
+// startRunLeader sends its `from ...` words.
+func TestObserveStartsPartway(t *testing.T) {
+ g := newFakeLeader(t, happyScript()...)
+ o := observeOpts(ObserveSpec{})
+ asked := -1
+ o.StartFrom = func(serverSteps int) (PartialStart, error) {
+ asked = serverSteps
+ return PartialStart{Words: "from step 3", Step: 3, Leg: -1, Point: Point{}, HavePoint: true}, nil
+ }
+ g.startExtra = []string{"The run starts at step 3 of 5 (leg first): the party is in the instance."}
+ rep, err := runRemote(t, g, observePlan(), o, nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if asked != 5 { // the fake lists "rt (map 0, 5 steps)"
+ t.Fatalf("the resolver saw %d steps, want 5", asked)
+ }
+ if !slices.Contains(g.orders, "run start rt from step 3") {
+ t.Fatalf("orders %v", g.orders)
+ }
+ if !strings.Contains(strings.Join(rep.Notes, "\n"), "starts partway") {
+ t.Fatalf("notes %v", rep.Notes)
+ }
+}
+
+// A partial start the resolver cannot map is refused at the probe, before a bot is hired or bound.
+func TestObserveStartFromRefusedAtTheProbe(t *testing.T) {
+ g := newFakeLeader(t, happyScript()...)
+ o := observeOpts(ObserveSpec{})
+ o.StartFrom = func(int) (PartialStart, error) {
+ return PartialStart{}, errors.New("from_pull_group 3: the server's plan has 226 steps, the plan file's rows give 198")
+ }
+ _, err := runRemote(t, g, observePlan(), o, nil)
+ if err == nil || !strings.Contains(err.Error(), "observe mode: from_pull_group 3") {
+ t.Fatalf("%v", err)
+ }
+ if len(g.ordersOf("commander bind")) != 0 || len(g.ordersOf("run start")) != 0 {
+ t.Fatalf("something was sent: %v", g.orders)
+ }
+}
+
+// A busy run of the same plan is adopted for a partial start only when its status proves it is at the requested step (A).
+func TestObserveBusyAtTheRequestedStepIsAdopted(t *testing.T) {
+ g := newFakeLeader(t, happyScript()...)
+ g.running = true
+ g.emit(rs("running", 3, 5, "first", "move", "walk", "walking"))
+ g.progress, g.next = s(20), 3
+ o := observeOpts(ObserveSpec{})
+ o.StartFrom = func(int) (PartialStart, error) {
+ return PartialStart{Words: "from step 3", Step: 3, Leg: -1, Point: Point{}, HavePoint: true}, nil
+ }
+ rep, err := runRemote(t, g, observePlan(), o, nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if !strings.Contains(strings.Join(rep.Notes, "\n"), "already going") {
+ t.Fatalf("notes %v", rep.Notes)
+ }
+}
+
+// At another step the run going is not watched: the watch would not be the requested start (A).
+func TestObserveBusyAtAnotherStepIsRefused(t *testing.T) {
+ g := newFakeLeader(t, happyScript()...)
+ g.running = true
+ g.emit(rs("running", 2, 5, "first", "pull", "pull", "pulling"))
+ g.progress, g.next = s(9), 2
+ o := observeOpts(ObserveSpec{})
+ o.StartFrom = func(int) (PartialStart, error) {
+ return PartialStart{Words: "from step 3", Step: 3, Leg: -1, Point: Point{}, HavePoint: true}, nil
+ }
+ _, err := runRemote(t, g, observePlan(), o, nil)
+ if err == nil || !strings.Contains(err.Error(), "busy") || !strings.Contains(err.Error(), "step 2") || !strings.Contains(err.Error(), "requested step 3") {
+ t.Fatalf("%v", err)
+ }
+ if g.indexOf("run stop") >= 0 {
+ t.Fatalf("another run was stopped: %v", g.orders)
+ }
+}
+
+// A leg start by name is adopted only when the status stands in that leg; a leg sent as a number cannot be compared, so it never adopts.
+func TestObserveBusyLegStart(t *testing.T) {
+ newBusy := func(leg string) *fakeLeader {
+ g := newFakeLeader(t,
+ leaderStep{0, rs("running", 4, 5, leg, "move", "walk", "walking")},
+ leaderStep{s(30), rs("done", 5, 5, leg, "move", "walk", "plan done")})
+ g.running = true
+ g.emit(g.script[0].st) // the status `run status` answers with, before the clock moves
+ g.next = 1
+ return g
+ }
+ legStart := func(name string) func(int) (PartialStart, error) {
+ return func(int) (PartialStart, error) {
+ return PartialStart{Words: "from leg " + name, Leg: 1, LegNumber: 1, LegName: name, Point: Point{}, HavePoint: true}, nil
+ }
+ }
+ o := observeOpts(ObserveSpec{})
+ o.StartFrom = legStart("second")
+ if rep, err := runRemote(t, newBusy("second"), observePlan(), o, nil); err != nil || rep.State != "done" {
+ t.Fatalf("same leg: %v %s %s", err, rep.State, rep.Error)
+ }
+ o = observeOpts(ObserveSpec{})
+ o.StartFrom = legStart("second")
+ if _, err := runRemote(t, newBusy("first"), observePlan(), o, nil); err == nil || !strings.Contains(err.Error(), `leg "first"`) {
+ t.Fatalf("another leg: %v", err)
+ }
+ o = observeOpts(ObserveSpec{})
+ o.StartFrom = func(int) (PartialStart, error) {
+ return PartialStart{Words: "from 1", Leg: 1, LegNumber: 1, Point: Point{}, HavePoint: true}, nil
+ }
+ if _, err := runRemote(t, newBusy("second"), observePlan(), o, nil); err == nil || !strings.Contains(err.Error(), "cannot be compared") {
+ t.Fatalf("a numeric leg: %v", err)
+ }
+}
+
+// The run leader's start message names the leg it really started in; a mismatch with the plan file is a drifted deployment: the run is
+// stopped and not watched (D). The message is also the proof of the start step and of a start moved back into an event (G).
+func TestObserveChecksTheStartMessage(t *testing.T) {
+ partial := func() RemoteOptions {
+ o := observeOpts(ObserveSpec{})
+ o.StartFrom = func(int) (PartialStart, error) {
+ return PartialStart{Words: "from leg second", Leg: 1, LegNumber: 1, LegName: "second", Point: Point{}, HavePoint: true}, nil
+ }
+ return o
+ }
+ g := newFakeLeader(t, happyScript()...)
+ g.startExtra = []string{"The run starts at step 4 of 5 (leg second): the party is in the instance; the steps before it are not done or checked. The start was moved back to the step that starts the event it fell in."}
+ rep, err := runRemote(t, g, observePlan(), partial(), nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ notes := strings.Join(rep.Notes, "\n")
+ if !strings.Contains(notes, "starts at step 4 of 5 (leg second)") || !strings.Contains(notes, "moved the start back") {
+ t.Fatalf("notes %v", rep.Notes)
+ }
+
+ g = newFakeLeader(t, happyScript()...)
+ g.startExtra = []string{"The run starts at step 1 of 5 (leg first): the party is in the instance."}
+ _, err = runRemote(t, g, observePlan(), partial(), nil)
+ if err == nil || !strings.Contains(err.Error(), "not the requested leg") {
+ t.Fatalf("%v", err)
+ }
+ if g.indexOf("run stop") < 0 {
+ t.Fatalf("the drifted run was not stopped: %v", g.orders)
+ }
+ // Not watched: no status poll of the watch (the end's own `cmd stay` is every remote run's).
+ if len(g.ordersOf("run status")) != 0 {
+ t.Fatalf("the drifted run was watched: %v", g.orders)
+ }
+}
+
+// A partway start is refused when the lead bot stands far from its step (E); observe.allow_far_start turns it into a note.
+func TestObservePartwayStartChecksThePlace(t *testing.T) {
+ at := func(allow bool) RemoteOptions {
+ o := observeOpts(ObserveSpec{AllowFarStart: allow})
+ o.StartFrom = func(int) (PartialStart, error) {
+ return PartialStart{Words: "from step 3", Step: 3, Leg: -1, Point: Point{X: 300, Y: 0, Z: 0}, HavePoint: true}, nil
+ }
+ return o
+ }
+ g := newFakeLeader(t, happyScript()...)
+ _, err := runRemote(t, g, observePlan(), at(false), nil)
+ if err == nil || !strings.Contains(err.Error(), "yd from the start step's point") || !strings.Contains(err.Error(), "allow_far_start") {
+ t.Fatalf("%v", err)
+ }
+ if g.indexOf("run start") >= 0 {
+ t.Fatalf("the run was started from the wrong place: %v", g.orders)
+ }
+ g = newFakeLeader(t, happyScript()...)
+ g.startExtra = []string{"The run starts at step 3 of 5 (leg first): the party is in the instance."}
+ rep, err := runRemote(t, g, observePlan(), at(true), nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if !strings.Contains(strings.Join(rep.Notes, "\n"), "allow_far_start") {
+ t.Fatalf("notes %v", rep.Notes)
+ }
+}
+
+// A partial start is proven, not trusted (fail closed): an ACK without the start message is asked of `run status`; at another step the
+// newly started run is stopped and the watch refused (no polls after the proof ask).
+func TestObserveACKOnlyStartWithoutProofIsStopped(t *testing.T) {
+ g := newFakeLeader(t, happyScript()...) // its first status is step 1, the start asks for step 3
+ o := observeOpts(ObserveSpec{})
+ o.StartFrom = func(int) (PartialStart, error) {
+ return PartialStart{Words: "from step 3", Step: 3, Leg: -1, Point: Point{}, HavePoint: true}, nil
+ }
+ _, err := runRemote(t, g, observePlan(), o, nil)
+ if err == nil || !strings.Contains(err.Error(), "start was not proven") || !strings.Contains(err.Error(), "not the requested step 3") {
+ t.Fatalf("%v", err)
+ }
+ if g.indexOf("run stop") < 0 {
+ t.Fatalf("the unproven run was not stopped: %v", g.orders)
+ }
+ if n := len(g.ordersOf("run status")); n != 1 {
+ t.Fatalf("%d run status asks: one for the proof, none for a watch: %v", n, g.orders)
+ }
+}
+
+// The status line is the second source: a module that answers the start with an ACK alone is accepted when its status is at the step.
+func TestObserveACKOnlyStartWithStatusProofIsAccepted(t *testing.T) {
+ g := newFakeLeader(t,
+ leaderStep{0, rs("running", 3, 5, "first", "move", "walk", "walking")},
+ leaderStep{s(5), rs("done", 5, 5, "first", "move", "walk", "plan done")})
+ o := observeOpts(ObserveSpec{})
+ o.StartFrom = func(int) (PartialStart, error) {
+ return PartialStart{Words: "from step 3", Step: 3, Leg: -1, Point: Point{}, HavePoint: true}, nil
+ }
+ rep, err := runRemote(t, g, observePlan(), o, nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if !strings.Contains(strings.Join(rep.Notes, "\n"), "proves the start") {
+ t.Fatalf("notes %v", rep.Notes)
+ }
+}
+
+// No start-step point means the place cannot be checked: refused, unless observe.allow_far_start (fail closed).
+func TestObservePartwayStartNeedsThePlace(t *testing.T) {
+ o := func(allow bool) RemoteOptions {
+ spec := ObserveSpec{AllowFarStart: allow}
+ opts := observeOpts(spec)
+ opts.StartFrom = func(int) (PartialStart, error) {
+ return PartialStart{Words: "from step 3", Step: 3, Leg: -1}, nil // no point
+ }
+ return opts
+ }
+ g := newFakeLeader(t, happyScript()...)
+ _, err := runRemote(t, g, observePlan(), o(false), nil)
+ if err == nil || !strings.Contains(err.Error(), "start step's point is not known") || !strings.Contains(err.Error(), "allow_far_start") {
+ t.Fatalf("%v", err)
+ }
+ if g.indexOf("run start") >= 0 {
+ t.Fatalf("the run was started without a place to check: %v", g.orders)
+ }
+ g = newFakeLeader(t, happyScript()...)
+ g.startExtra = []string{"The run starts at step 3 of 5 (leg first): the party is in the instance."}
+ rep, err := runRemote(t, g, observePlan(), o(true), nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if !strings.Contains(strings.Join(rep.Notes, "\n"), "point is not known") || !strings.Contains(strings.Join(rep.Notes, "\n"), "allow_far_start") {
+ t.Fatalf("notes %v", rep.Notes)
+ }
+}
+
+// The parsed start reply must report the requested step and leg (round 4): a reply at another step is stopped once, without a watch
+// poll, for a from_step start and for a from_pull_group start; the requested one is accepted; the module's documented move-back into a
+// scripted event is the only step difference accepted, with a note.
+func TestObserveStartReplyStepMustMatch(t *testing.T) {
+ opts := func(ps PartialStart) RemoteOptions {
+ o := observeOpts(ObserveSpec{})
+ o.StartFrom = func(int) (PartialStart, error) { return ps, nil }
+ return o
+ }
+ stepStart := PartialStart{Words: "from step 3", Step: 3, Leg: -1, Point: Point{}, HavePoint: true}
+ for _, c := range []struct {
+ name string
+ start PartialStart
+ line string
+ want string
+ }{
+ {"from_step at another step", stepStart, "The run starts at step 1 of 5 (leg first): the party is in the instance.", "not the requested step 3"},
+ {"from_pull_group at another step", PartialStart{Words: "from step 4", Step: 4, Leg: 1, LegNumber: 1, LegName: "second", ExpectLeg: "second", Point: Point{}, HavePoint: true},
+ "The run starts at step 2 of 5 (leg second): the party is in the instance.", "not the requested step 4"},
+ {"from_step in another leg", PartialStart{Words: "from step 3", Step: 3, Leg: -1, ExpectLeg: "first", Point: Point{}, HavePoint: true},
+ "The run starts at step 3 of 5 (leg second): the party is in the instance.", `not the requested leg "first"`},
+ } {
+ g := newFakeLeader(t, happyScript()...)
+ g.startExtra = []string{c.line}
+ _, err := runRemote(t, g, observePlan(), opts(c.start), nil)
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Errorf("%s: %v", c.name, err)
+ continue
+ }
+ if n := len(g.ordersOf("run stop")); n != 1 {
+ t.Errorf("%s: %d run stops, want exactly 1: %v", c.name, n, g.orders)
+ }
+ if n := len(g.ordersOf("run status")); n != 0 {
+ t.Errorf("%s: %d status asks, want none (no watch): %v", c.name, n, g.orders)
+ }
+ }
+
+ g := newFakeLeader(t, happyScript()...)
+ g.startExtra = []string{"The run starts at step 3 of 5 (leg first): the party is in the instance."}
+ if rep, err := runRemote(t, g, observePlan(), opts(stepStart), nil); err != nil || rep.State != "done" {
+ t.Fatalf("the requested step: %v %s %s", err, rep.State, rep.Error)
+ }
+
+ g = newFakeLeader(t, happyScript()...)
+ g.startExtra = []string{"The run starts at step 2 of 5 (leg first): the party is in the instance. The start was moved back to the step that starts the event it fell in."}
+ rep, err := runRemote(t, g, observePlan(), opts(stepStart), nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("the documented move-back: %v %s %s", err, rep.State, rep.Error)
+ }
+ if n := len(g.ordersOf("run stop")); n != 0 {
+ t.Fatalf("the documented move-back was stopped: %v", g.orders)
+ }
+ if !strings.Contains(strings.Join(rep.Notes, "\n"), "moved the start back") {
+ t.Fatalf("notes %v", rep.Notes)
+ }
+}
+
+// A run starts by undoing the `loot off` a crashed run's boss may have left (lootOnAtStart). An observed run may keep the operator's
+// setting instead (observe.keep_loot_setting): the graves legs turn the companions' looting off by themselves, and an observed run never
+// reaches them. The setting is observe only: a driven run always sends the orders.
+func TestLootOnAtStartSendsBehaviorOrdersUnlessKept(t *testing.T) {
+ all := []string{"Tanky loot true", "Healy loot true", "Magey loot true"}
+ for _, c := range []struct {
+ name string
+ o RemoteOptions
+ want []string
+ }{
+ {"driven", opts(), all},
+ {"observe", observeOpts(ObserveSpec{}), all},
+ {"observe with keep_loot_setting", observeOpts(ObserveSpec{KeepLootSetting: true}), nil},
+ } {
+ g := newFakeCarrier(t)
+ d := NewRemoteDriver(observePlan(), g, DefaultConfig(), c.o, g.clock())
+ d.fr.Party = []tbm.PartyRec{{Name: "Tanky"}, {Name: "Healy"}, {Name: "Magey"}}
+ d.lootOnAtStart(context.Background())
+ if !slices.Equal(g.behaviors, c.want) {
+ t.Errorf("%s: behaviors %v, want %v", c.name, g.behaviors, c.want)
+ }
+ notes := strings.Join(d.Report().Notes, "\n")
+ if c.want == nil {
+ if !strings.Contains(notes, "loot setting kept as the operator set it") {
+ t.Errorf("%s: no note about the kept setting: %v", c.name, notes)
+ }
+ } else if strings.Contains(notes, "loot setting kept") {
+ t.Errorf("%s: the kept-setting note went out: %v", c.name, notes)
+ }
+ }
+}
+
+// restScript walks, rests 20 s in the pull, pulls, rests 10 s at the boss, pulls, and is done 40 s later.
+func restScript() []leaderStep {
+ return []leaderStep{
+ {0, rs("running", 1, 5, "first", "move", "walk", "walking")},
+ {s(10), rs("running", 2, 5, "first", "pull", "rest", "resting")},
+ {s(30), rs("running", 2, 5, "first", "pull", "pull", "pulling")},
+ {s(50), rs("running", 3, 5, "first", "boss", "rest", "resting")},
+ {s(60), rs("running", 3, 5, "first", "boss", "pull", "pulling")},
+ {s(100), rs("done", 5, 5, "second", "move", "walk", "plan done")},
+ }
+}
+
+// restEvents are the trace's "rest" events.
+func restEvents(f *trace.File) []trace.Event {
+ var out []trace.Event
+ for _, e := range f.Events {
+ if e.Ev == "rest" {
+ out = append(out, e)
+ }
+ }
+ return out
+}
+
+// The run leader's own rest phase is the observed run's rest: an observe run has no gate of the agent's, so the time a status says
+// phase rest counts as a rest gate, in the report, the leg and the trace.
+func TestObserveCountsTheRunLeadersRestPhase(t *testing.T) {
+ g := newFakeLeader(t, restScript()...)
+ dir := t.TempDir()
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), func(c *Config) {
+ c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-observe-rest"}
+ })
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if rep.RestSeconds < 25 || rep.RestSeconds > 35 {
+ t.Fatalf("rest seconds %v (want 30, 20 s + 10 s)\n%s", rep.RestSeconds, notesOf(rep))
+ }
+ if len(rep.RestGates) != 2 {
+ t.Fatalf("rest gates %+v (want 2)", rep.RestGates)
+ }
+ for _, gate := range rep.RestGates {
+ if gate.Result != "run_leader" {
+ t.Errorf("gate %+v: result %q, want run_leader", gate, gate.Result)
+ }
+ }
+ var sum float64
+ for _, l := range rep.Legs {
+ sum += l.RestSeconds
+ }
+ if diff := sum - rep.RestSeconds; diff > 0.001 || diff < -0.001 {
+ t.Errorf("legs rest %v, report rest %v", sum, rep.RestSeconds)
+ }
+ if rep.RestShare <= 0 {
+ t.Errorf("rest share %v", rep.RestShare)
+ }
+ f, err := trace.Read(filepath.Join(dir, "r-observe-rest.jsonl"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rests := restEvents(f)
+ if len(rests) != 2 {
+ t.Fatalf("%d rest events in the trace (want 2)", len(rests))
+ }
+ for _, e := range rests {
+ if e.D["source"] != "run_leader" {
+ t.Errorf("rest event %v: source %v", e.Ev, e.D["source"])
+ }
+ if secs, ok := e.D["seconds"].(float64); !ok || secs <= 0 {
+ t.Errorf("rest event seconds %v (%T)", e.D["seconds"], e.D["seconds"])
+ }
+ }
+ for _, c := range tracecheck.Score(f, tracecheck.Defaults()) {
+ if c.ID == tracecheck.IDRestShare && c.Value <= 0 {
+ t.Fatalf("the rest_share check reads %v: %+v", c.Value, c)
+ }
+ }
+}
+
+// A pause is not rest: a paused status still carries the phase it was paused in, but the time a run waits paused is not the run
+// leader resting (PausedWait is 10 min, and that would fail rest_share). The open rest closes when the run pauses; a resume in the
+// same phase opens a new one.
+func TestObservePausedTimeIsNotRest(t *testing.T) {
+ g := newFakeLeader(t,
+ leaderStep{0, rs("running", 1, 5, "first", "move", "walk", "walking")},
+ leaderStep{s(10), rs("running", 2, 5, "first", "pull", "rest", "resting")},
+ leaderStep{s(20), rs("paused", 2, 5, "first", "pull", "rest", "3 dead: regroup (cmd revive), then .bot run resume")},
+ leaderStep{s(300), rs("running", 2, 5, "first", "pull", "pull", "pulling 299")},
+ leaderStep{s(320), rs("done", 5, 5, "first", "pull", "pull", "plan done")},
+ )
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), nil)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if rep.RestSeconds < 9 || rep.RestSeconds > 11 {
+ t.Fatalf("rest seconds %v (want about 10: the rest before the pause, not the 280 s pause)\n%s", rep.RestSeconds, notesOf(rep))
+ }
+ if len(rep.RestGates) != 1 {
+ t.Fatalf("rest gates %+v (want 1)", rep.RestGates)
+ }
+ if rep.RestGates[0].Result != "run_leader" {
+ t.Errorf("gate %+v: result %q, want run_leader", rep.RestGates[0], rep.RestGates[0].Result)
+ }
+}
+
+// A rest the run leader is still in when the run ends is closed at the end, so its time is not lost.
+func TestObserveCountsARestThatIsOpenWhenTheRunEnds(t *testing.T) {
+ g := newFakeLeader(t,
+ leaderStep{0, rs("running", 1, 5, "first", "move", "walk", "walking")},
+ leaderStep{s(10), rs("running", 2, 5, "first", "pull", "rest", "resting")},
+ leaderStep{s(40), rs("done", 5, 5, "first", "move", "rest", "plan done")},
+ )
+ dir := t.TempDir()
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), func(c *Config) {
+ c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-observe-open-rest"}
+ })
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if len(rep.RestGates) != 1 {
+ t.Fatalf("rest gates %+v (want 1)", rep.RestGates)
+ }
+ if rep.RestSeconds < 25 {
+ t.Fatalf("rest seconds %v (want about 30)", rep.RestSeconds)
+ }
+ f, err := trace.Read(filepath.Join(dir, "r-observe-open-rest.jsonl"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rests := restEvents(f); len(rests) != 1 {
+ t.Fatalf("%d rest events in the trace (want 1)", len(rests))
+ }
+}
+
+// A run that never says phase rest has no rest: the leader's phase is not a rest gate until it says so.
+func TestObserveWithNoRestPhaseCountsNoRest(t *testing.T) {
+ g := newFakeLeader(t, happyScript()...)
+ dir := t.TempDir()
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), func(c *Config) {
+ c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-observe-no-rest"}
+ })
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s %s", err, rep.State, rep.Error)
+ }
+ if rep.RestSeconds != 0 || len(rep.RestGates) != 0 {
+ t.Fatalf("rest %v in %+v, want none", rep.RestSeconds, rep.RestGates)
+ }
+ f, err := trace.Read(filepath.Join(dir, "r-observe-no-rest.jsonl"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rests := restEvents(f); len(rests) != 0 {
+ t.Fatalf("%d rest events in the trace (want none)", len(rests))
+ }
+}
+
+// instanceEvents are the trace's "instance" events.
+func instanceEvents(f *trace.File) []trace.Event {
+ var out []trace.Event
+ for _, e := range f.Events {
+ if e.Ev == "instance" {
+ out = append(out, e)
+ }
+ }
+ return out
+}
+
+// The lead bot's instance id comes from the feed: the report carries it, the trace has one instance event, and the summary reads it
+// from that event.
+func TestObserveExposesTheInstanceId(t *testing.T) {
+ g := newFakeLeader(t, happyScript()...)
+ g.party[0].Instance = 4321
+ dir := t.TempDir()
+ rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), func(c *Config) {
+ c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-observe-inst"}
+ })
+ if err != nil || rep.State != "done" || rep.Trace == nil {
+ t.Fatalf("%v %s %s %+v", err, rep.State, rep.Error, rep.Trace)
+ }
+ if rep.Instance != 4321 {
+ t.Fatalf("report instance %d, want 4321", rep.Instance)
+ }
+ f, err := trace.Read(rep.Trace.Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ insts := instanceEvents(f)
+ if len(insts) != 1 {
+ t.Fatalf("%d instance events in the trace (want 1)", len(insts))
+ }
+ if v, ok := insts[0].D["instance"].(float64); !ok || v != 4321 {
+ t.Fatalf("instance event %v", insts[0].D)
+ }
+ if _, ok := insts[0].D["map"]; !ok {
+ t.Fatalf("the instance event has no map: %v", insts[0].D)
+ }
+ b, err := os.ReadFile(trace.SummaryPath(rep.Trace.Path))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var sum trace.Summary
+ if err := json.Unmarshal(b, &sum); err != nil {
+ t.Fatal(err)
+ }
+ if sum.Instance != 4321 {
+ t.Fatalf("summary instance %d, want 4321: %s", sum.Instance, b)
+ }
+
+ // A feed with no instance id says none, and writes no instance event.
+ g = newFakeLeader(t, happyScript()...)
+ dir = t.TempDir()
+ rep, err = runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), func(c *Config) {
+ c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-observe-no-inst"}
+ })
+ if err != nil || rep.State != "done" || rep.Trace == nil {
+ t.Fatalf("%v %s %s %+v", err, rep.State, rep.Error, rep.Trace)
+ }
+ if rep.Instance != 0 {
+ t.Fatalf("report instance %d, want 0", rep.Instance)
+ }
+ f, err = trace.Read(rep.Trace.Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if insts := instanceEvents(f); len(insts) != 0 {
+ t.Fatalf("%d instance events in the trace (want none)", len(insts))
+ }
+}
diff --git a/internal/dungeon/plan.go b/internal/dungeon/plan.go
index 9ba0a4fa..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"`
@@ -141,6 +144,15 @@ func (p *Plan) Band() (lo, hi int) {
return p.LevelBand[0], p.LevelBand[1]
}
+// Leaves lists every Leave of the plan, in leg order: a spawn stands for the whole run, so a leg's list holds for every leg's points.
+func (p *Plan) Leaves() []Leave {
+ var out []Leave
+ for _, l := range p.Legs {
+ out = append(out, l.Leave...)
+ }
+ return out
+}
+
// Portal is a point of the outer map: the centre of the entrance's area trigger.
type Portal struct {
Map uint32 `json:"map"`
@@ -163,9 +175,14 @@ type Leg struct {
Via []Waypoint `json:"via,omitempty"`
PullGroups []PullGroup `json:"pull_groups,omitempty"`
// Skip lists creature entries the leg never waits for: rares, escorts, friendly NPCs, and adds that die with the boss.
- Skip []uint32 `json:"skip,omitempty"`
- Steps []Step `json:"steps,omitempty"`
- Boss *Boss `json:"boss,omitempty"`
+ Skip []uint32 `json:"skip,omitempty"`
+ // Leave lists the spawns a run must not pull, fight or walk up to (zf-r6: the lone Shadowhunter 7246, spawn 81610, on a navmesh island at
+ // 1591 1047.7, which the module's hop pull engaged and whose island took the tank). The plan check keeps every approach point and stand of
+ // the plan LeaveClearYd away from them (leave_clear_test.go). The run leader's rows have no place for it yet: plan-export writes it into
+ // the leg row's comment and warns.
+ Leave []Leave `json:"leave,omitempty"`
+ Steps []Step `json:"steps,omitempty"`
+ Boss *Boss `json:"boss,omitempty"`
// CompanionsLoot false has the driver order the party's corpse and object looting off (".bot behavior loot off") from the start
// of the leg to its end, and on again after: the Zul'Farrak graveyard, where a bot that loots opens a Shallow Grave chest and wakes
// what lies in it. It is the leg's whole stretch, not only the boss fight (Boss.CompanionsLoot, which the boss's own loot_item needs).
@@ -187,6 +204,18 @@ type Leg struct {
Notes []string `json:"notes,omitempty"`
}
+// LeaveClearYd is how far an approach point or a stand keeps from a Leave spawn: more than the run leader's hop pull reaches (kHopPullYd, 15).
+const LeaveClearYd = 15.0
+
+// Leave is one spawn a run leaves alone (Leg.Leave): Entry is its creature entry, Spawn its creature guid (0: not named), At where it stands
+// and Why the reason, for the reader and for the exported leg row.
+type Leave struct {
+ Entry uint32 `json:"entry"`
+ Spawn uint32 `json:"spawn,omitempty"`
+ At Point `json:"at"`
+ Why string `json:"why"`
+}
+
// OptionalLeg is Leg.Optional: the item the leg needs (item entry, and its name for the messages).
type OptionalLeg struct {
Item uint32 `json:"item"`
@@ -309,8 +338,19 @@ type PullGroup struct {
// first and Entries) as the event's mobs. A move row straight before it to the same spot is left out (the hold walks there itself), and
// a game object wait straight after it waits at most HoldDoorWaitS (the event's DONE is what opens the door). The agent's own driver
// does not read it: it fights the group (or the boss) and waits for the steps as written.
+//
+// A wait_for may carry one too (WaitFor.Hold): it is that wait's run leader form, for an event whose script keeps its progress in an
+// instance data value the agent's driver does not read (Zul'Farrak's pyramid phase, instance data 1). plan-export then writes the hold
+// row in place of the wait (which the run leader could not run), and the agent's driver runs the wait as written.
type Hold struct {
- Data uint32 `json:"instance_data"`
+ Data uint32 `json:"instance_data"`
+ // Value, when set, is the value of instance data Data that ends the event, read as the script's own number, not as an encounter state:
+ // plan-export writes param Value with flags 8 alone (done by instance data), without the encounter flag 32, so the run leader neither
+ // reads a FAIL nor a reset into it (TortoiseBots DungeonPlanPolicy.h: a hold with flag 8 and not 32 takes any param). The run leader
+ // ends the hold when the data reads exactly Value, so it must be a value the script keeps for a while (a wave that is being fought),
+ // not one it passes through in a single update. Nil: the encounter state DONE (3), flags 40, as before. Only a wait_for's hold takes a
+ // value, and never 0 (the value instance data starts at): the plan check refuses both.
+ Value *uint32 `json:"value,omitempty"`
Spot Point `json:"spot"`
Radius float32 `json:"radius"`
TimeoutS int `json:"timeout_s"`
@@ -610,6 +650,9 @@ type WaitFor struct {
// FailText is added to the failure when the wait times out: the reason a person should read (Uldaman's altar waits say that the
// ritual needs three participants).
FailText string `json:"fail_text,omitempty"`
+ // Hold is this wait's form for the TortoiseBots run leader (see Hold): plan-export writes it as a hold row instead of the wait. The
+ // agent's own driver does not read it.
+ Hold *Hold `json:"hold,omitempty"`
}
// ObjectState waits for a game object to be in a state: ready (a door closed), active (open or used) or active_alt.
@@ -704,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"`
@@ -735,6 +779,7 @@ type rawLeg struct {
Via []rawWaypoint `json:"via"`
PullGroups []rawPullGroup `json:"pull_groups"`
Skip []uint32 `json:"skip"`
+ Leave []rawLeave `json:"leave"`
Steps []json.RawMessage `json:"steps"`
Boss *rawBoss `json:"boss"`
AfterBoss []json.RawMessage `json:"after_boss"`
@@ -747,6 +792,13 @@ type rawLeg struct {
NeedsWalk bool `json:"needs_walk"`
}
+type rawLeave struct {
+ Entry uint32 `json:"entry"`
+ Spawn uint32 `json:"spawn"`
+ At *rawPoint `json:"at"`
+ Why string `json:"why"`
+}
+
type rawPullGroup struct {
Entries []uint32 `json:"entries"`
Positions []rawPoint `json:"positions"`
@@ -766,6 +818,7 @@ type rawHold struct {
Radius float32 `json:"radius"`
TimeoutS int `json:"timeout_s"`
Entries []uint32 `json:"entries"`
+ Value *uint32 `json:"value"`
}
type rawPatrol struct {
@@ -894,6 +947,7 @@ type rawWait struct {
Unit *rawUnitWait `json:"unit"`
TimeoutS *int `json:"timeout_s"`
FailText string `json:"fail_text"`
+ Hold *rawHold `json:"hold"`
}
type rawObjState struct {
@@ -979,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)
}
@@ -1242,9 +1303,28 @@ func (c *checker) leg(path string, r *rawLeg) Leg {
pg.Patrol = pat
}
pg.Approach = c.approach(gp+".approach", g.Approach)
- pg.Hold = c.hold(gp+".hold", g.Hold)
+ pg.Hold = c.hold(gp+".hold", g.Hold, false)
l.PullGroups = append(l.PullGroups, pg)
}
+ for i, lv := range r.Leave {
+ lp := fmt.Sprintf("%s.leave[%d]", path, i)
+ if lv.Entry == 0 {
+ c.fail(lp+".entry", "required (the creature entry to leave alone)")
+ }
+ if strings.TrimSpace(lv.Why) == "" {
+ c.fail(lp+".why", "required (why the run leaves it: the reader and the exported leg row need the reason)")
+ }
+ at := c.point(lp+".at", lv.At)
+ for j, g := range r.PullGroups {
+ for k, gp := range g.Positions {
+ if gp.X != nil && gp.Y != nil && lv.Entry != 0 && slices.Contains(g.Entries, lv.Entry) &&
+ math.Hypot(float64(*gp.X-at.X), float64(*gp.Y-at.Y)) < 3 {
+ c.fail(lp, "pull_groups[%d].positions[%d] is this spawn: the leg would pull what it leaves", j, k)
+ }
+ }
+ }
+ l.Leave = append(l.Leave, Leave{Entry: lv.Entry, Spawn: lv.Spawn, At: at, Why: strings.TrimSpace(lv.Why)})
+ }
c.entries(path+".skip", r.Skip, false)
for i, e := range r.Skip {
for j, g := range r.PullGroups {
@@ -1285,7 +1365,7 @@ func (c *checker) leg(path string, r *rawLeg) Leg {
}
}
b.Approach = c.approach(bp+".approach", r.Boss.Approach)
- b.Hold = c.hold(bp+".hold", r.Boss.Hold)
+ b.Hold = c.hold(bp+".hold", r.Boss.Hold, false)
if b.Hold != nil && b.LootItem != nil {
c.fail(bp+".hold", "a boss with a hold has no loot_item: the run leader's hold row has no loot")
}
@@ -1352,12 +1432,13 @@ func (c *checker) approach(path string, ps []rawPoint) []Point {
return out
}
-// hold reads a pull group's or a boss's hold.
-func (c *checker) hold(path string, r *rawHold) *Hold {
+// hold reads a pull group's, a boss's or a wait's hold. A value is allowed on a wait's hold only (a pull group's or a boss's hold is an
+// encounter that ends DONE), and never 0: an instance data value starts at 0, so such a hold would end the moment it begins.
+func (c *checker) hold(path string, r *rawHold, valueAllowed bool) *Hold {
if r == nil {
return nil
}
- h := &Hold{Data: r.Data, Radius: r.Radius, TimeoutS: r.TimeoutS, Entries: r.Entries}
+ h := &Hold{Data: r.Data, Radius: r.Radius, TimeoutS: r.TimeoutS, Entries: r.Entries, Value: r.Value}
h.Spot = c.point(path+".spot", r.Spot)
if r.Data == 0 {
c.fail(path+".instance_data", "required (the index of the event's instance data, 1 or more)")
@@ -1369,6 +1450,12 @@ func (c *checker) hold(path string, r *rawHold) *Hold {
c.fail(path+".timeout_s", "%d s is outside 1-%d", r.TimeoutS, MaxTimeoutS)
}
c.entries(path+".entries", r.Entries, false)
+ switch {
+ case r.Value != nil && !valueAllowed:
+ c.fail(path+".value", "only a wait_for's hold takes a value; a pull group's or a boss's hold ends on the encounter state DONE")
+ case r.Value != nil && *r.Value == 0:
+ c.fail(path+".value", "0 is the value instance data starts at, so the hold would end at once: give the value the event reaches")
+ }
return h
}
@@ -1654,6 +1741,7 @@ func (c *checker) wait(path string, r *rawWait) *WaitFor {
}
w.HostilesGone = h
}
+ w.Hold = c.hold(path+".hold", r.Hold, true)
return w
}
diff --git a/internal/dungeon/plan_test.go b/internal/dungeon/plan_test.go
index 8e084966..3b4c9f57 100644
--- a/internal/dungeon/plan_test.go
+++ b/internal/dungeon/plan_test.go
@@ -84,6 +84,11 @@ func TestParseErrors(t *testing.T) {
{"first holds boss", leg(`{"to":{"x":0,"y":0,"z":0},"boss":{"entry":4,"first":[4],"position":{"x":0,"y":0,"z":0}}}`), "legs[1].boss.first[0]: the boss (4) is killed last"},
{"skip holds boss", leg(`{"to":{"x":0,"y":0,"z":0},"skip":[4],"boss":{"entry":4,"position":{"x":0,"y":0,"z":0}}}`), "legs[1].skip[0]: skips the boss (4)"},
{"skip also pulled", leg(`{"to":{"x":0,"y":0,"z":0},"skip":[1],"pull_groups":[{"entries":[1],"positions":[{"x":0,"y":0,"z":0}]}]}`), "legs[1].skip[0]: 1 is also in pull_groups[0].entries"},
+ {"leave no entry", leg(`{"to":{"x":0,"y":0,"z":0},"leave":[{"at":{"x":5,"y":5,"z":0},"why":"island"}],"pull_groups":[{"entries":[5],"positions":[{"x":0,"y":0,"z":0}]}]}`), "legs[1].leave[0].entry: required"},
+ {"leave no why", leg(`{"to":{"x":0,"y":0,"z":0},"leave":[{"entry":9,"at":{"x":5,"y":5,"z":0}}],"pull_groups":[{"entries":[5],"positions":[{"x":0,"y":0,"z":0}]}]}`), "legs[1].leave[0].why: required"},
+ {"leave no point", leg(`{"to":{"x":0,"y":0,"z":0},"leave":[{"entry":9,"why":"island"}],"pull_groups":[{"entries":[5],"positions":[{"x":0,"y":0,"z":0}]}]}`), "legs[1].leave[0].at: required"},
+ {"leave also pulled", leg(`{"to":{"x":0,"y":0,"z":0},"leave":[{"entry":5,"at":{"x":1,"y":1,"z":0},"why":"island"}],"pull_groups":[{"entries":[5],"positions":[{"x":0,"y":0,"z":0}]}]}`), "legs[1].leave[0]: pull_groups[0].positions[0] is this spawn"},
+ {"leave unknown field", leg(`{"to":{"x":0,"y":0,"z":0},"leave":[{"entry":9,"at":{"x":5,"y":5,"z":0},"why":"x","radius":3}],"pull_groups":[{"entries":[5],"positions":[{"x":0,"y":0,"z":0}]}]}`), `unknown field "radius"`},
{"skip also first", leg(`{"to":{"x":0,"y":0,"z":0},"skip":[3],"boss":{"entry":4,"first":[3],"position":{"x":0,"y":0,"z":0}}}`), "legs[1].skip[0]: 3 is also in boss.first"},
{"empty step", step(`{}`), "legs[0].steps[0]: needs use_object, wait_for, loot_object, use_item, ritual, area_trigger or gossip"},
{"two things", step(`{"use_object":{"entry":1},"wait_for":{"hostiles_gone":{"radius":5}}}`), "legs[0].steps[0]: has more than one of use_object, wait_for, loot_object, use_item, ritual, area_trigger and gossip"},
@@ -243,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/plan_waithold_test.go b/internal/dungeon/plan_waithold_test.go
new file mode 100644
index 00000000..af1f4930
--- /dev/null
+++ b/internal/dungeon/plan_waithold_test.go
@@ -0,0 +1,79 @@
+package dungeon
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// A wait_for may carry a hold (its run leader form) and a hold may carry a value (a script value of its instance data): both parse, keep
+// their form through Marshal, and a bad hold on a wait is refused like one on a pull group.
+func TestWaitForHoldAndHoldValueParse(t *testing.T) {
+ p := mustPlan(t, eventPlan(`{"wait_for":{"unit":{"entries":[7796],"state":"seen"},"timeout_s":600,"hold":{"instance_data":1,"value":7,"spot":{"x":5,"y":6,"z":7},"radius":30,"timeout_s":600,"entries":[7787,7788]}}},{"wait_for":{"unit":{"entries":[7],"state":"seen"}}}`, ""))
+ h := p.Legs[0].Steps[0].WaitFor.Hold
+ if h == nil || h.Data != 1 || h.Value == nil || *h.Value != 7 || h.Spot != (Point{5, 6, 7}) || h.Radius != 30 || h.TimeoutS != 600 || len(h.Entries) != 2 {
+ t.Fatalf("hold %+v", h)
+ }
+ if p.Legs[0].Steps[0].WaitFor.Unit == nil {
+ t.Fatal("the wait's own unit condition must stay (the agent's driver runs it)")
+ }
+ if p.Legs[0].Steps[1].WaitFor.Hold != nil {
+ t.Fatal("a wait without a hold has none")
+ }
+ out, err := Marshal(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(out), `"instance_data": 1,`+"\n"+` "value": 7,`) {
+ t.Fatalf("the value is not next to its instance_data:\n%s", out)
+ }
+ q, err := Parse("again.json", out)
+ if err != nil {
+ t.Fatalf("%v\n%s", err, out)
+ }
+ if again, _ := Marshal(q); !bytes.Equal(out, again) {
+ t.Fatalf("%s", out)
+ }
+ if _, err := Parse("bad.json", []byte(eventPlan(`{"wait_for":{"unit":{"entries":[7796],"state":"seen"},"hold":{"instance_data":0,"spot":{"x":5,"y":6,"z":7},"radius":30,"timeout_s":600}}}`, ""))); err == nil || !strings.Contains(err.Error(), "wait_for.hold.instance_data") {
+ t.Fatalf("a hold without its instance data must be refused: %v", err)
+ }
+}
+
+// The run leader's party stands on a wait hold's spot (plan-export writes the wait as its hold row), so the reach of a boss after it is
+// measured from there, not from the leg's to.
+func TestRunLeaderReachCountsAWaitHoldSpot(t *testing.T) {
+ plan := func(hold string) *Plan {
+ return mustPlan(t, `{"version":1,"name":"t","map":1,"entrance":{"x":0,"y":0,"z":0},"legs":[{"to":{"x":0,"y":0,"z":0},
+ "steps":[{"wait_for":{"unit":{"entries":[7],"state":"seen"}`+hold+`}}],"boss":{"entry":9,"position":{"x":100,"y":0,"z":0}}}]}`)
+ }
+ if n := len(plan("").RunLeaderReach(0)); n != 1 {
+ t.Fatalf("without a hold the boss is 100 yd from the to: %d warnings", n)
+ }
+ if w := plan(`,"hold":{"instance_data":1,"value":7,"spot":{"x":80,"y":0,"z":0},"radius":30,"timeout_s":600}`).RunLeaderReach(0); len(w) != 0 {
+ t.Fatalf("the hold spot is 20 yd from the boss: %v", w)
+ }
+}
+
+// A hold's value: never 0 (instance data starts at 0, so the hold would end at once), and only on a wait's hold (a pull group's or a boss's
+// hold is an encounter that ends DONE).
+func TestHoldValueIsRefusedWhereItCannotWork(t *testing.T) {
+ const hold = `"hold":{"instance_data":1,%s"spot":{"x":5,"y":6,"z":7},"radius":30,"timeout_s":600}`
+ h := func(value string) string { return strings.Replace(hold, "%s", value, 1) }
+ for _, tc := range []struct{ name, js, want string }{
+ {"value 0 on a wait", eventPlan(`{"wait_for":{"unit":{"entries":[7],"state":"seen"},`+h(`"value":0,`)+`}}`, ""), "wait_for.hold.value: 0 is the value instance data starts at"},
+ {"value on a pull group", `{"version":1,"name":"t","map":1,"entrance":{"x":0,"y":0,"z":0},"legs":[{"to":{"x":0,"y":0,"z":0},"pull_groups":[{"entries":[4857],"positions":[{"x":5,"y":6,"z":7}],` + h(`"value":5,`) + `}]}]}`, "hold.value: only a wait_for's hold takes a value"},
+ {"value on a boss", `{"version":1,"name":"t","map":1,"entrance":{"x":0,"y":0,"z":0},"legs":[{"to":{"x":0,"y":0,"z":0},"boss":{"entry":2748,"position":{"x":5,"y":6,"z":7},` + h(`"value":5,`) + `}}]}`, "hold.value: only a wait_for's hold takes a value"},
+ } {
+ if _, err := Parse("bad.json", []byte(tc.js)); err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Errorf("%s: %v", tc.name, err)
+ }
+ }
+ for _, ok := range []string{
+ eventPlan(`{"wait_for":{"unit":{"entries":[7],"state":"seen"},`+h(`"value":7,`)+`}}`, ""),
+ `{"version":1,"name":"t","map":1,"entrance":{"x":0,"y":0,"z":0},"legs":[{"to":{"x":0,"y":0,"z":0},"boss":{"entry":2748,"position":{"x":5,"y":6,"z":7},` + h(``) + `}}]}`,
+ } {
+ if _, err := Parse("ok.json", []byte(ok)); err != nil {
+ t.Errorf("%v", err)
+ }
+ }
+}
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 06f9d239..b2e9efc3 100644
--- a/internal/dungeon/preflight.go
+++ b/internal/dungeon/preflight.go
@@ -149,9 +149,20 @@ func (d *RemoteDriver) preflight(ctx context.Context) error {
}
began := d.clk.Now()
pf := &Preflight{Mode: mode, OK: true}
- maintenanceOn := true
+ maintenanceOn := d.preflightMaintains()
+ maintenanceNote := "not sent"
+ if !maintenanceOn {
+ // An observed run's preflight, by default: `.bot maintenance` walks each bot to a vendor, and hires autogeared a moment ago
+ // scatter over the maps, which pauses the run leader ("the lead bot is not on the plan's map", #253). The inventory and
+ // talent reads below still run; observe.maintenance brings maintenance back.
+ 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: "not sent"}
+ b := PreflightBot{Name: m.Name, Maintenance: maintenanceNote}
if maintenanceOn {
b.Maintenance = d.maintain(ctx, bl, m.Name)
if b.Maintenance == "disabled" {
@@ -189,6 +200,15 @@ func (d *RemoteDriver) preflight(ctx context.Context) error {
return nil
}
+// preflightMaintains says whether the preflight sends `.bot maintenance`: a driven run always does; an observed run only with
+// observe.maintenance (Off by default: the run leader's party must not wander off to vendors, #253).
+func (d *RemoteDriver) preflightMaintains() bool {
+ if d.observing() {
+ return d.opt.Observe.withDefaults(d.plan).Maintenance
+ }
+ return true
+}
+
// maintain sends `.bot maintenance name` and reads the verdict.
func (d *RemoteDriver) maintain(ctx context.Context, bl BotLiner, name string) string {
verdict := ""
diff --git a/internal/dungeon/preflight_party.go b/internal/dungeon/preflight_party.go
new file mode 100644
index 00000000..c468bc46
--- /dev/null
+++ b/internal/dungeon/preflight_party.go
@@ -0,0 +1,55 @@
+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
+ }
+ 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/prepare.go b/internal/dungeon/prepare.go
index 8344af9a..d565746f 100644
--- a/internal/dungeon/prepare.go
+++ b/internal/dungeon/prepare.go
@@ -273,9 +273,11 @@ type prepEnv struct {
memory map[string]tbm.Who
// remote: the run is in remote mode (the only mode that prepares).
remote bool
- now func() time.Time
- sleep func(ctx context.Context, d time.Duration) error
- note func(string)
+ // maintain: the hire check may send `.bot maintenance` (observe mode: only with observe.maintenance, #253).
+ maintain bool
+ now func() time.Time
+ sleep func(ctx context.Context, d time.Duration) error
+ note func(string)
// shortcut books a GM line the step sent (setup_shortcuts and the trace).
shortcut func(line string)
// entryOK is the ledger check before a fresh entry (nil: no ledger).
diff --git a/internal/dungeon/pulltest_test.go b/internal/dungeon/pulltest_test.go
index 2cdf8299..6b86b8a5 100644
--- a/internal/dungeon/pulltest_test.go
+++ b/internal/dungeon/pulltest_test.go
@@ -65,8 +65,8 @@ func TestPulltestEquivalentPacks(t *testing.T) {
if len(g.mobs) != 0 || len(g.ordersOf("cmd pullback")) != 3 {
t.Fatalf("mobs %v pullbacks %v", g.mobs, g.ordersOf("cmd pullback"))
}
- // GM use: the start's two lines only; nothing was respawned.
- if g.respawns != 0 || rep.Remote.GMCommands != 2 || rep.Remote.RunGMAssists != 0 || len(pt.GMCommands) != 0 {
+ // GM use: the start's three lines only (.appear, .go xyz and .gps); nothing was respawned.
+ if g.respawns != 0 || rep.Remote.GMCommands != 3 || rep.Remote.RunGMAssists != 0 || len(pt.GMCommands) != 0 {
t.Fatalf("respawns %d gm %d assists %d", g.respawns, rep.Remote.GMCommands, rep.Remote.RunGMAssists)
}
if pt.Summary.TimeToKillMeanS <= 0 || pt.Summary.HealerMinManaPct != 100 {
@@ -94,7 +94,7 @@ func TestPulltestRespawn(t *testing.T) {
if len(pt.Tries) != 3 || pt.Summary.Killed != 3 || pt.Tries[1].Reset != "respawn" || pt.Tries[2].Reset != "respawn" || pt.Tries[1].PullGroup != 0 {
t.Fatalf("%+v", pt)
}
- if g.respawns != 2 || rep.Remote.RunGMAssists != 2 || rep.Remote.GMCommands != 2 || len(pt.GMCommands) != 2 {
+ if g.respawns != 2 || rep.Remote.RunGMAssists != 2 || rep.Remote.GMCommands != 3 || len(pt.GMCommands) != 2 {
t.Fatalf("respawns %d assists %d gm %d / %v", g.respawns, rep.Remote.RunGMAssists, rep.Remote.GMCommands, pt.GMCommands)
}
n := 0
diff --git a/internal/dungeon/remote.go b/internal/dungeon/remote.go
index 80877d8c..77a20a49 100644
--- a/internal/dungeon/remote.go
+++ b/internal/dungeon/remote.go
@@ -158,6 +158,11 @@ type RemoteOptions struct {
// Observe makes the run a watch of the TortoiseBots run leader instead of a driven one (observe.go): after the same prepare, bind and
// enter, `.bot run start ` goes out and the agent sends no order, it reads the TBM:RUN status. Nil: a driven (commander) run.
Observe *ObserveSpec
+ // StartFrom resolves the `from ...` words of an observed run's `.bot run start [from ...]` when the run starts partway
+ // (from_leg, from_pull_group or from_step, #248). The run leader counts the steps of its own SQL plan, which the agent's plan legs and
+ // pull groups are not: the resolver gets the step count the server listed for the plan and returns the words ("" for the first step).
+ // Nil: the run starts at the plan's first step. It is checked before anything is hired.
+ StartFrom StartFromResolver
// RezWait is how long a dead bot waits for a living caster's resurrection, out of combat, before it is released with `cmd revive`.
// 0: 30 s; negative: no wait.
RezWait time.Duration
@@ -531,6 +536,7 @@ type RemoteDriver struct {
bound bool
unbinding bool
+ gpsWarned bool // the note that the start's z check could not read .gps is written
serverTrace bool // the run owns the server's run trace: its `trace start` was acked, and no `trace stop` was answered yet (stopServerTrace)
// groupLocked is set when the enter phase begins to send the party in: no leave, invite or bind may follow until the run ends.
groupLocked bool
@@ -619,11 +625,12 @@ type RemoteDriver struct {
seenHostile map[packet.GUID]uint32 // units listed alive in the feed at some point of the run, with their entry (the team gate's evidence)
killedGUID map[packet.GUID]bool // units a won pull killed
- pts *ptSampler // the pull-test try being scored (pulltest.go); nil outside one
- tr *tracerun.Recorder // the run id and the trace (nil-safe; no file when tracing is off)
- trSeen uint64 // the feed frame count the trace last took
- trRoster bool
- trStalled bool
+ pts *ptSampler // the pull-test try being scored (pulltest.go); nil outside one
+ tr *tracerun.Recorder // the run id and the trace (nil-safe; no file when tracing is off)
+ trSeen uint64 // the feed frame count the trace last took
+ trRoster bool
+ trInstance bool
+ trStalled bool
// The progress watchdog (watchdog.go) and the requests its recoveries leave for the loops they interrupt.
wd *watchdog
@@ -632,6 +639,10 @@ type RemoteDriver struct {
viewResent bool // `commander view on` went out again since the feed was last heard from
obs *observeState // the run leader watch of an observed run (observe.go); nil otherwise
+ // The observed run's partial start (#248): planSteps is the step count the server listed for the plan in `.bot run list`, and start
+ // is how the run leader starts (probeRunLeader asks RemoteOptions.StartFrom with that count). Nil: the first step.
+ planSteps int
+ start *PartialStart
fl fleeState // the mobs that run at low health and the ones that got away (remote_flee.go)
tx tuneExtra // the thresholds that were constants (tune.go)
diff --git a/internal/dungeon/remote_batch_test.go b/internal/dungeon/remote_batch_test.go
index 41386f64..bddfd2ae 100644
--- a/internal/dungeon/remote_batch_test.go
+++ b/internal/dungeon/remote_batch_test.go
@@ -1338,3 +1338,7 @@ func TestAgentHomeIsThePreBatchSpot(t *testing.T) {
t.Fatalf("home %v (the batch centre is 33 0 0)\n%s", last, notesOf(rep2))
}
}
+
+func (*agentHandsGame) GPS(context.Context) (string, error) {
+ return "", errors.New("no .gps in this fake")
+}
diff --git a/internal/dungeon/remote_cc.go b/internal/dungeon/remote_cc.go
index e2b85e38..f3f4f7f8 100644
--- a/internal/dungeon/remote_cc.go
+++ b/internal/dungeon/remote_cc.go
@@ -236,6 +236,11 @@ func (d *RemoteDriver) ccAllowed(h tbm.HostileRec) bool {
if (slices.Contains(d.plan.KillFirst, h.Entry) && !slices.Contains(d.plan.CCAlways, h.Entry)) || slices.Contains(d.plan.NoCC, h.Entry) {
return false // a kill_first entry that is cc_always too is sheeped (one at a time: the cc bot holds one CC) and the others killed first
}
+ if d.bossFight && d.curLeg >= 0 && d.curLeg < len(d.plan.Legs) { // the active boss's `first` adds are killed in the plan's order, never sheeped (Grimlok's Geomancer 4853)
+ if b := d.plan.Legs[d.curLeg].Boss; b != nil && slices.Contains(b.First, h.Entry) {
+ return false
+ }
+ }
for _, l := range d.plan.Legs {
if b := l.Boss; b != nil && b.Entry == h.Entry { // the bosses' own entries only: a boss's `first` add may stand in a pack that gets its CC (Uldaman's Grimlok and Galgann)
return false
diff --git a/internal/dungeon/remote_cmdenter.go b/internal/dungeon/remote_cmdenter.go
index 3a724bfb..9e0f6814 100644
--- a/internal/dungeon/remote_cmdenter.go
+++ b/internal/dungeon/remote_cmdenter.go
@@ -40,6 +40,7 @@ const (
cmdEnterCombatWait = 45 * time.Second // an order refused in-combat waits for the fight to end this long
cmdEnterMax = 4 * time.Minute // the whole of the walk and the order: past it the phase fails
cmdEnterGather = 30 // yards from the portal at which a bot counts as at the door
+ cmdEnterDoorDZ = 12 // yards above or below the portal within which the lead bot still counts as at the door
)
// CmdEnterReport is what the `cmd enter` order did, in the enter report.
diff --git a/internal/dungeon/remote_cmdenter_test.go b/internal/dungeon/remote_cmdenter_test.go
index 44a6c456..6b11661c 100644
--- a/internal/dungeon/remote_cmdenter_test.go
+++ b/internal/dungeon/remote_cmdenter_test.go
@@ -291,3 +291,46 @@ func TestRemoteEnterCmdCeilingDoesNotCountAFightOnTheWay(t *testing.T) {
t.Fatalf("without the fight the ceiling holds: %v", err)
}
}
+
+// A door in a hill or a cave stands above the walkable ground: the module's own move never lifts a bot to the portal's Z (the fake
+// clamps the lead bot back to the ground), so with `cmd enter` only the ground-plane rule (within cmdEnterGather of the portal,
+// cmdEnterDoorDZ above or below it) gets the order out at the door; a 3D MoveArrive check would walk to the 4-minute ceiling instead.
+func TestRemoteEnterCmdIsSentAtADoorAboveTheGround(t *testing.T) {
+ g := newEnterOrders(t, enterAck(3))
+ g.plan.Portal = &Portal{Map: 0, Point: Point{304, 0, 9}} // 4 yd off on the ground plane, 9 yd up
+ base := g.fakeRemote.onTick
+ g.fakeRemote.onTick = func(fr *fakeRemote) {
+ base(fr)
+ fr.lead().Z = 0 // the bot walks on the ground: it never stands on the portal's point
+ }
+ rep, err := g.run(RemoteOptions{Enter: EnterCmd})
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s\n%s", err, rep.State, notesOf(rep))
+ }
+ if len(g.sent) != 1 || g.sent[0] != "cmd enter 924" {
+ t.Fatalf("the order never went out at the door: %v\n%s", g.sent, notesOf(rep))
+ }
+}
+
+// Without `cmd enter` nothing changes: the lead bot is taken in by the area trigger (the fake's stand-in for a bot walking into
+// it) on the ground below the portal's point, and no enter order is sent.
+func TestRemoteEnterWalkStillTakesATriggerAboveTheGround(t *testing.T) {
+ g := newEnterOrders(t, enterAck(3))
+ g.plan.Portal = &Portal{Map: 0, Point: Point{304, 0, 9}}
+ g.portal = &Point{304, 0, 9}
+ base := g.fakeRemote.onTick
+ g.fakeRemote.onTick = func(fr *fakeRemote) {
+ base(fr)
+ fr.lead().Z = 0
+ }
+ rep, err := g.run(RemoteOptions{Enter: EnterWalk})
+ if err != nil || rep.State != "done" || rep.Remote.Enter.Method != EnterWalk {
+ t.Fatalf("%v %s %+v\n%s", err, rep.State, rep.Remote.Enter, notesOf(rep))
+ }
+ if len(g.sent) != 0 || g.idx("order cmd enter") >= 0 {
+ t.Fatalf("an enter order went out without enterCmd: %v\n%s", g.sent, g.log)
+ }
+ if g.idx("portal") < 0 {
+ t.Fatalf("the trigger never took the lead bot in: %v\n%s", g.log, notesOf(rep))
+ }
+}
diff --git a/internal/dungeon/remote_enter.go b/internal/dungeon/remote_enter.go
index 56290de6..95f89684 100644
--- a/internal/dungeon/remote_enter.go
+++ b/internal/dungeon/remote_enter.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "math"
"slices"
"strings"
"time"
@@ -453,7 +454,13 @@ func (d *RemoteDriver) walkToPortal(ctx context.Context, pt *Portal, began time.
return fmt.Errorf("enter: the lead bot %s is on map %d; the portal is on map %d", l.Name, l.Map, pt.Map)
}
here := partyPoint(l)
- if dist3(here, pt.Point) <= float64(d.opt.MoveArrive) {
+ // With `cmd enter` the lead bot is at the door once it stands within cmdEnterGather yards of the portal on the ground plane and no
+ // more than cmdEnterDoorDZ above or below it: the order takes the party near the door, not onto the portal's point, and a door in
+ // a cave or on a ledge stands above or below the walkable ground, so the 3D MoveArrive check would never pass and the walk would
+ // end at its ceiling without an order. Without `cmd enter` the lead bot must reach the point itself: the area trigger takes it in.
+ atDoor := dist3(here, pt.Point) <= float64(d.opt.MoveArrive) ||
+ d.enterCmd && hdist(here, pt.Point) <= cmdEnterGather && math.Abs(float64(here.Z-pt.Point.Z)) <= cmdEnterDoorDZ
+ if atDoor {
if d.enterCmd {
return d.cmdEnterAtDoor(ctx, began)
}
diff --git a/internal/dungeon/remote_enter_test.go b/internal/dungeon/remote_enter_test.go
index a2400ab1..c1801d76 100644
--- a/internal/dungeon/remote_enter_test.go
+++ b/internal/dungeon/remote_enter_test.go
@@ -2,6 +2,7 @@ package dungeon
import (
"context"
+ "errors"
"fmt"
"slices"
"strings"
@@ -784,3 +785,5 @@ func TestRemoteFailedRunKeepsTheBinding(t *testing.T) {
}
}
}
+
+func (*goFake) GPS(context.Context) (string, error) { return "", errors.New("no .gps in this fake") }
diff --git a/internal/dungeon/remote_gaps_test.go b/internal/dungeon/remote_gaps_test.go
index bdf80c27..cde3ee5b 100644
--- a/internal/dungeon/remote_gaps_test.go
+++ b/internal/dungeon/remote_gaps_test.go
@@ -393,6 +393,196 @@ 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 ""), 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 {
+ 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)
+ }
+ 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) {
g := kitFake(t)
g.replies["maintenance Tanky"] = []string{"Owned-bot maintenance is disabled (AiPlayerbot.OwnedBotMaintenanceEnabled = 0)."}
diff --git a/internal/dungeon/remote_gossip.go b/internal/dungeon/remote_gossip.go
index e78b62e1..a02069a9 100644
--- a/internal/dungeon/remote_gossip.go
+++ b/internal/dungeon/remote_gossip.go
@@ -80,11 +80,12 @@ func (d *RemoteDriver) gossipBot(by string) (tbm.PartyRec, error) {
return m, nil
}
-// gossipOrder is the order of a bot's talk: `cmd gossip `.
+// gossipOrder is the order of a bot's talk: `cmd gossip `. The order counts the menu from 1 (TortoiseBots
+// GossipOrderPolicy.h refuses 0), while the plan's option is the menu's own index from 0, so the order carries option + 1.
func gossipOrder(g *Gossip, bot string) string {
opt := g.Text
if g.Option != nil {
- opt = strconv.FormatUint(uint64(*g.Option), 10)
+ opt = strconv.FormatUint(uint64(*g.Option)+1, 10)
}
return fmt.Sprintf("cmd gossip %d %s %s", g.NPC, bot, opt)
}
diff --git a/internal/dungeon/remote_objects.go b/internal/dungeon/remote_objects.go
index d812fde7..888b2c72 100644
--- a/internal/dungeon/remote_objects.go
+++ b/internal/dungeon/remote_objects.go
@@ -996,7 +996,13 @@ func behaviorWhy(r tbm.Reply, err error) string {
// lootOnAtStart turns corpse looting on for every bound bot when a remote run starts. A run that ended between a boss's `loot off` and
// its deferred `loot on` (a crash, say) leaves the bots loot-off, so every run sets it back. A refusal is noted; the run goes on.
+// An observed run may keep the operator's setting instead (observe.keep_loot_setting): it sends nothing and notes one line. The setting
+// is observe only: a driven run always sends the undo, because its own legs turn looting off and on.
func (d *RemoteDriver) lootOnAtStart(ctx context.Context) {
+ if o := d.opt.Observe; o != nil && o.KeepLootSetting {
+ d.rep.note("loot setting kept as the operator set it")
+ return
+ }
og, ok := d.orderGame()
if !ok {
return
diff --git a/internal/dungeon/remote_portalgm_test.go b/internal/dungeon/remote_portalgm_test.go
index c180e90a..4f9f3483 100644
--- a/internal/dungeon/remote_portalgm_test.go
+++ b/internal/dungeon/remote_portalgm_test.go
@@ -392,3 +392,7 @@ func TestRemoteEnterGatherCountsMissingDeadAndFarBotsAsApart(t *testing.T) {
}
}
}
+
+func (*portalFake) GPS(context.Context) (string, error) {
+ return "", errors.New("no .gps in this fake")
+}
diff --git a/internal/dungeon/remote_prepare.go b/internal/dungeon/remote_prepare.go
index 5b4547a9..e460a047 100644
--- a/internal/dungeon/remote_prepare.go
+++ b/internal/dungeon/remote_prepare.go
@@ -23,7 +23,7 @@ func (d *RemoteDriver) prepare(ctx context.Context) error {
return errors.New("prepare: this game cannot log bots in, teleport or send GM lines")
}
d.rep.setCurrent("prepare")
- env := &prepEnv{g: pg, plan: d.plan, spec: d.opt.Prepare, bots: append([]string{d.opt.Lead}, d.opt.Bots...), remote: true, memory: d.opt.HireMemory,
+ env := &prepEnv{g: pg, plan: d.plan, spec: d.opt.Prepare, bots: append([]string{d.opt.Lead}, d.opt.Bots...), remote: true, maintain: d.preflightMaintains(), memory: d.opt.HireMemory,
now: d.clk.Now, sleep: d.clk.Sleep, note: d.rep.note, shortcut: d.prepShortcut,
progress: func(s string) { d.rep.setCurrent(s); d.wd.progress(s) }, command: d.g.Command, settle: d.opt.BindSettle,
tank: d.opt.Tank, healer: d.opt.Healer, carrier: d.opt.Carrier}
diff --git a/internal/dungeon/remote_trigger_test.go b/internal/dungeon/remote_trigger_test.go
index 452f8a67..162cba28 100644
--- a/internal/dungeon/remote_trigger_test.go
+++ b/internal/dungeon/remote_trigger_test.go
@@ -1523,3 +1523,7 @@ func TestRecoveryReleasesOnlyTheCorpseNearMobsAtOnce(t *testing.T) {
t.Fatalf("after the wait: revived %v", g.revived)
}
}
+
+func (*triggerGame) GPS(context.Context) (string, error) {
+ return "", errors.New("no .gps in this fake")
+}
diff --git a/internal/dungeon/report.go b/internal/dungeon/report.go
index da810f67..c4f273fe 100644
--- a/internal/dungeon/report.go
+++ b/internal/dungeon/report.go
@@ -29,6 +29,9 @@ type Report struct {
// LeaderGM says whether the run took the leader's account for a GM (and so may have sent GM commands). A run with
// leader_gm false sent none.
LeaderGM bool `json:"leader_gm"`
+ // Instance is the numeric instance id of the lead bot's dungeon, read from the feed (remote and observe runs); absent until a
+ // frame shows one.
+ Instance uint32 `json:"instance,omitempty"`
// AccountLevel is the leader's security level (0 player, 3 developer) when the probe read it from ".account"; absent otherwise.
AccountLevel *int `json:"account_level,omitempty"`
// GMCaps are the GM commands the leader's account level let the run use (visible, teleport, revive, buffs).
diff --git a/internal/dungeon/restgate.go b/internal/dungeon/restgate.go
index aba911bf..40077c93 100644
--- a/internal/dungeon/restgate.go
+++ b/internal/dungeon/restgate.go
@@ -134,7 +134,8 @@ type RestGate struct {
Leg int `json:"leg"`
Seconds float64 `json:"seconds"`
// Result is "rested" (everyone at the targets), "stalled" (passed with casters whose mana does not rise), "gave_up" (the cap
- // came with someone short; the run went on) or "failed" (the gate ended the run).
+ // came with someone short; the run went on), "failed" (the gate ended the run) or "run_leader" (the run leader's own rest phase,
+ // observe mode).
Result string `json:"result"`
MaxS int `json:"max_s"` // the cap this gate had
// Short is who was short when the gate ended ("Name mana 29%", "Name health 60%"); empty for a rested gate.
diff --git a/internal/dungeon/runleader.go b/internal/dungeon/runleader.go
index 418ed571..200cccfa 100644
--- a/internal/dungeon/runleader.go
+++ b/internal/dungeon/runleader.go
@@ -45,6 +45,9 @@ func (p *Plan) runLeaderWalk(from int, move func(RunLeaderPoint), fight func(leg
move(RunLeaderPoint{li, fmt.Sprintf("%s[%d].use_object.stand", kind, si), *st.UseObject.Stand})
case st.AreaTrigger != nil && st.AreaTrigger.By != TriggerAgent:
move(RunLeaderPoint{li, fmt.Sprintf("%s[%d].area_trigger.stand", kind, si), st.AreaTrigger.Stand})
+ case st.WaitFor != nil && st.WaitFor.Hold != nil:
+ // plan-export writes such a wait as its hold row: the run leader's party stands on the hold's spot.
+ move(RunLeaderPoint{li, fmt.Sprintf("%s[%d].wait_for.hold spot", kind, si), st.WaitFor.Hold.Spot})
}
}
}
diff --git a/internal/dungeon/runtrace.go b/internal/dungeon/runtrace.go
index 5295f033..70bb68e3 100644
--- a/internal/dungeon/runtrace.go
+++ b/internal/dungeon/runtrace.go
@@ -393,7 +393,13 @@ func (d *RemoteDriver) traceBegin(fromLeg int) {
}
// The feed has facing and the bot's target but no movement flag or class, and it has threat lists.
caps := []string{trace.CapFacing, trace.CapThreat, trace.CapOrders, trace.CapWalks, trace.CapPartyList}
- hdr := trace.Header{Map: d.plan.Map, Plan: d.plan.Name, FromLeg: fromLeg, Lead: d.opt.Lead, Tank: d.opt.Tank, Options: opts, Caps: caps}
+ // An observed run is driven by the server's run leader, which is the lead bot: the header names it as the tank, or the threat check
+ // would have to guess it from the frames.
+ tank := d.opt.Tank
+ if d.observing() {
+ tank = d.opt.Lead
+ }
+ hdr := trace.Header{Map: d.plan.Map, Plan: d.plan.Name, FromLeg: fromLeg, Lead: d.opt.Lead, Tank: tank, Options: opts, Caps: caps}
hdr.Meta = runMetaHeader(d.cfg.Trace.Meta, d.opt.RunMeta)
d.tr.Begin(hdr)
if !d.tr.On() {
@@ -425,6 +431,14 @@ func (d *RemoteDriver) traceLook() {
d.tr.Party(trace.Member{G: guidText(p.GUID), Name: p.Name, Lead: p.GUID == d.fr.Lead && d.fr.Lead != 0, Role: d.roleOf(p.Name)})
}
}
+ if !d.trInstance && d.fr.Lead != 0 {
+ if lead, ok := d.memberByGUID(d.fr.Lead); ok && lead.Instance != 0 {
+ d.trInstance = true
+ inst := lead.Instance
+ d.rep.update(func(r *Report) { r.Instance = inst })
+ d.tr.Event("instance", map[string]any{"instance": inst, "map": lead.Map})
+ }
+ }
if d.tr.Due() {
d.tr.Frame(feedFrame(d.fr))
}
@@ -452,7 +466,7 @@ func feedFrame(fr tbm.ViewFrame) trace.Frame {
}
for _, p := range fr.Party {
b := trace.Bot{G: guidText(p.GUID), Alive: !p.Dead, Combat: p.InCombat(), State: p.State, HP: p.HP, MaxHP: p.MaxHP, Power: p.Power, MaxPow: p.MaxPower,
- PType: p.PowerType, X: p.X, Y: p.Y, Z: p.Z, Target: guidText(p.Target), Cast: p.Spell, Action: p.Action, Lead: p.GUID == fr.Lead && fr.Lead != 0}
+ PType: p.PowerType, X: p.X, Y: p.Y, Z: p.Z, Target: guidText(p.Target), Cast: p.Spell, Action: p.Action, Res: p.Result, Why: p.Reason, Lead: p.GUID == fr.Lead && fr.Lead != 0}
if h, ok := hostile[p.Target]; ok {
b.Facing = facingDegrees(p.O, p.X, p.Y, h.X, h.Y)
}
diff --git a/internal/dungeon/runtrace_test.go b/internal/dungeon/runtrace_test.go
index b91d398d..0d73d61c 100644
--- a/internal/dungeon/runtrace_test.go
+++ b/internal/dungeon/runtrace_test.go
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
+ "github.com/pjerra/turtle-agent/internal/tbm"
"github.com/pjerra/turtle-agent/internal/trace"
"github.com/pjerra/turtle-agent/internal/tracerun"
)
@@ -43,7 +44,7 @@ func TestTraceOfAGroupRun(t *testing.T) {
if rep.RunID != "r-test-1" || stamped == 0 || unstamped != 0 {
t.Fatalf("run id %q, %d stamped events, %d without the id", rep.RunID, stamped, unstamped)
}
- if rep.Trace == nil || rep.Trace.Path != filepath.Join(dir, "r-test-1.jsonl") || rep.Trace.Error != "" || len(rep.Trace.Checks) != 10 || rep.Trace.Frames == 0 {
+ if rep.Trace == nil || rep.Trace.Path != filepath.Join(dir, "r-test-1.jsonl") || rep.Trace.Error != "" || len(rep.Trace.Checks) != 11 || rep.Trace.Frames == 0 {
t.Fatalf("trace info %+v", rep.Trace)
}
f, err := trace.Read(rep.Trace.Path)
@@ -80,7 +81,7 @@ func TestTraceOfAGroupRun(t *testing.T) {
if f.End == nil || f.End.Reason != "done" || f.End.Seconds <= 0 || f.End.Counter("pulls") != 3 || f.End.Counter("frames") != float64(len(f.Frames)) || f.End.Counter("party") != 3 || f.End.Counter("mobs") == 0 {
t.Fatalf("end %+v", f.End)
}
- if len(f.Oracles) != 10 || f.Oracles[0].ID == "" {
+ if len(f.Oracles) != 11 || f.Oracles[0].ID == "" {
t.Fatalf("oracles %+v", f.Oracles)
}
var s trace.Summary
@@ -88,7 +89,7 @@ func TestTraceOfAGroupRun(t *testing.T) {
if err != nil || json.Unmarshal(b, &s) != nil {
t.Fatalf("summary: %v %s", err, b)
}
- if s.Run != "r-test-1" || s.Src != "agent" || s.Status != "complete" || s.EndReason != "done" || s.Plan != "t" || len(s.Oracles) != 10 || s.TraceFile != "r-test-1.jsonl" || s.Verdict == "" || s.EndUnix == 0 || s.Scorer == "" {
+ if s.Run != "r-test-1" || s.Src != "agent" || s.Status != "complete" || s.EndReason != "done" || s.Plan != "t" || len(s.Oracles) != 11 || s.TraceFile != "r-test-1.jsonl" || s.Verdict == "" || s.EndUnix == 0 || s.Scorer == "" {
t.Fatalf("summary %+v", s)
}
for _, ev := range f.Events { // every event carries the envelope
@@ -222,7 +223,28 @@ func TestTraceOfARemoteRun(t *testing.T) {
if orders < 3 || answered == 0 || hostiles == 0 || n["walk_start"] == 0 || n["pull_start"] == 0 || n["pull_end"] == 0 || n["roster"] != 1 || threat == 0 {
t.Fatalf("orders %d answered %d hostiles %d events %v", orders, answered, hostiles, n)
}
- if f.End == nil || f.End.Counter("orders_sent") == 0 || len(f.Oracles) != 10 {
+ if f.End == nil || f.End.Counter("orders_sent") == 0 || len(f.Oracles) != 11 {
t.Fatalf("end %+v oracles %d", f.End, len(f.Oracles))
}
}
+
+// The feed frame keeps the verdict letter and the reason code of the bot's newest action (TB #924), and leaves them out when the module
+// sent none (an older module), so an older trace and a new one read the same.
+func TestFeedFrameKeepsTheActionVerdict(t *testing.T) {
+ fr := tbm.ViewFrame{Seq: 3, Party: []tbm.PartyRec{
+ {GUID: 1, Name: "Mage", Spell: 837, Action: "frostbolt", Result: "F", Reason: "oom", State: "c", HP: 1, MaxHP: 2},
+ {GUID: 2, Name: "Old", Action: "stay", State: "c", HP: 1, MaxHP: 2},
+ }}
+ out := feedFrame(fr)
+ if out.Bots[0].Res != "F" || out.Bots[0].Why != "oom" || out.Bots[0].Action != "frostbolt" || out.Bots[0].Cast != 837 {
+ t.Fatalf("%+v", out.Bots[0])
+ }
+ if out.Bots[1].Res != "" || out.Bots[1].Why != "" {
+ t.Fatalf("%+v", out.Bots[1])
+ }
+ line, err := json.Marshal(out.Bots[0])
+ old, _ := json.Marshal(out.Bots[1])
+ if err != nil || !strings.Contains(string(line), `"res":"F"`) || !strings.Contains(string(line), `"why":"oom"`) || strings.Contains(string(old), `"res"`) || strings.Contains(string(old), `"why"`) {
+ t.Fatalf("%s | %s", line, old)
+ }
+}
diff --git a/internal/dungeon/start.go b/internal/dungeon/start.go
index 8048ed15..8bbde876 100644
--- a/internal/dungeon/start.go
+++ b/internal/dungeon/start.go
@@ -90,6 +90,9 @@ type StartHands interface {
Follower
// GoXYZ sends `.go xyz x y z map` for the agent's own character and waits for its teleport.
GoXYZ(ctx context.Context, mapID uint32, at Point) error
+ // GPS sends the plain GM line `.gps` for the agent's own character and returns the lines the core answered with, joined by newlines
+ // (empty and an error when no reply came).
+ GPS(ctx context.Context) (string, error)
}
// LegStart is where a run from leg i begins: the entrance for leg 0, else where the leg before ended (its boss, else its To).
@@ -678,7 +681,7 @@ func (d *RemoteDriver) commanderTo(ctx context.Context, h StartHands, at Point)
if p, mm, ok := h.AgentPlace(); !ok || mm != d.plan.Map || hdist(p, at) > startNear {
return fmt.Errorf("the commander is not at the start point after .go xyz (at %.0f %.0f on map %d)", p.X, p.Y, mm)
}
- return nil
+ return d.checkStartZ(ctx, h, at)
}
// bringLead summons the lead bot to the commander with `.bot summon `, a player command of the commander's own account (the
diff --git a/internal/dungeon/start_gps.go b/internal/dungeon/start_gps.go
new file mode 100644
index 00000000..6ee4544c
--- /dev/null
+++ b/internal/dungeon/start_gps.go
@@ -0,0 +1,113 @@
+package dungeon
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "regexp"
+ "strconv"
+)
+
+// The z check of the start (issue #292): `.go xyz X Y Z` on a point whose z is a few yards off puts the commander under the ground or in
+// the air, and the lead bot and the party are then brought to a commander nobody can reach. After the move `.gps` is read, the commander is
+// put on the floor once if it stands off it, and the start is refused when it still does. The read is best effort: a server that answers
+// no `.gps` (or in another wording) or a commander that may not send it never blocks the start.
+const (
+ startZTol = 0.5 // yards: the commander stands on the floor within this of FloorZ
+ startFloorLift = 0.3 // yards above FloorZ the one re-send aims at
+ startLayerTol = 3.0 // yards: FloorZ below the ground is the target's own layer within this of the target z
+ // the core prints INVALID_HEIGHT_VALUE (-200000, GridMap.h:36) where it has no height data; anything below this is that
+ gpsNoHeight = -90000.0
+)
+
+// gpsXY reads the "X: Y: Z: Orientation" line of the core's `.gps` reply (mangos_string 101: "X: %f Y: %f Z: %f Orientation: %f"),
+// the unit's own position. The line starts the line: "ZoneX:" and "ZoneY:" are not it.
+var gpsXY = regexp.MustCompile(`(?m)^\s*X:\s*(-?\d+(?:\.\d+)?)\s+Y:\s*(-?\d+(?:\.\d+)?)`)
+
+// gpsZ reads "GroundZ: FloorZ: " of the same reply ("GroundZ: %f FloorZ: %f Have height data (Map: %u VMap: %u)"). The core prints
+// with '.' as the decimal point whatever the locale.
+var gpsZ = regexp.MustCompile(`GroundZ:\s*(-?\d+(?:\.\d+)?)\s+FloorZ:\s*(-?\d+(?:\.\d+)?)`)
+
+// GPSReply is what a `.gps` reply says: the position it is about (X, Y) and the GroundZ and FloorZ under it.
+type GPSReply struct {
+ X, Y, GroundZ, FloorZ float64
+}
+
+// ParseGPS reads the X/Y line and GroundZ (the highest surface over the commander) and FloorZ (the surface under it) out of a `.gps` reply,
+// which may carry other lines. ok is false when any of them is not in the text or when the core had no height data there (it prints -200000).
+func ParseGPS(text string) (r GPSReply, ok bool) {
+ xy := gpsXY.FindStringSubmatch(text)
+ z := gpsZ.FindStringSubmatch(text)
+ if xy == nil || z == nil {
+ return GPSReply{}, false
+ }
+ x, err1 := strconv.ParseFloat(xy[1], 64)
+ y, err2 := strconv.ParseFloat(xy[2], 64)
+ g, err3 := strconv.ParseFloat(z[1], 64)
+ f, err4 := strconv.ParseFloat(z[2], 64)
+ if err1 != nil || err2 != nil || err3 != nil || err4 != nil || g < gpsNoHeight || f < gpsNoHeight {
+ return GPSReply{}, false
+ }
+ return GPSReply{X: x, Y: y, GroundZ: g, FloorZ: f}, true
+}
+
+// readGPS sends `.gps` (booked as a GM start line) and parses the reply. ok is false (with a nil error, and one note per driver) when the
+// check cannot be made: the line failed, the reply has no X/Y, GroundZ and FloorZ, or it is about another unit (its X/Y is more than startNear
+// from the commander, e.g. a mob the commander has selected). The error is the context's.
+func (d *RemoteDriver) readGPS(ctx context.Context, h StartHands) (reply GPSReply, ok bool, err error) {
+ d.startGM(".gps")
+ text, gerr := h.GPS(ctx)
+ if err := ctx.Err(); err != nil {
+ return GPSReply{}, false, err
+ }
+ if gerr == nil {
+ reply, ok = ParseGPS(text)
+ if !ok {
+ gerr = fmt.Errorf("no X/Y, GroundZ or FloorZ in the reply %q", text)
+ } else if here, _, _ := h.AgentPlace(); hdist(here, Point{X: float32(reply.X), Y: float32(reply.Y)}) > startNear {
+ gerr = fmt.Errorf("the reply is about (%.0f, %.0f), not the commander at (%.0f, %.0f)", reply.X, reply.Y, here.X, here.Y)
+ ok = false
+ } else {
+ return reply, true, nil
+ }
+ }
+ if !d.gpsWarned {
+ d.gpsWarned = true
+ d.startNote("warning: the commander's z could not be checked with .gps (" + gerr.Error() + "); the z check is best effort and the start goes on")
+ }
+ return GPSReply{}, false, nil
+}
+
+// checkStartZ is the z check after the `.go xyz` to at (see above).
+func (d *RemoteDriver) checkStartZ(ctx context.Context, h StartHands, at Point) error {
+ r, ok, err := d.readGPS(ctx, h)
+ if err != nil || !ok {
+ return err
+ }
+ ground, floor := r.GroundZ, r.FloorZ
+ here, _, _ := h.AgentPlace()
+ if math.Abs(float64(here.Z)-floor) > startZTol {
+ fix := Point{X: at.X, Y: at.Y, Z: float32(floor + startFloorLift)}
+ d.startNote(fmt.Sprintf("the commander stands at z %.1f, FloorZ is %.1f: .go xyz again at z %.1f", here.Z, floor, fix.Z))
+ if err := h.GoXYZ(ctx, d.plan.Map, fix); err != nil {
+ return fmt.Errorf(".go xyz %.1f %.1f %.1f (z re-send): %w", fix.X, fix.Y, fix.Z, err)
+ }
+ d.startGM(fmt.Sprintf(".go xyz %.2f %.2f %.2f %d", fix.X, fix.Y, fix.Z, d.plan.Map))
+ if r, ok, err = d.readGPS(ctx, h); err != nil || !ok {
+ return err
+ }
+ ground, floor = r.GroundZ, r.FloorZ
+ here, _, _ = h.AgentPlace()
+ }
+ offFloor := math.Abs(float64(here.Z)-floor) > startZTol
+ otherLayer := math.Abs(ground-floor) > startZTol && math.Abs(floor-float64(at.Z)) > startLayerTol
+ if offFloor || otherLayer {
+ why := "the commander does not stand on the floor"
+ if !offFloor {
+ why = "the floor under the commander is another layer than the target point"
+ }
+ return fmt.Errorf("%s after .go xyz: target z %.1f, GroundZ %.1f, FloorZ %.1f, commander z %.1f; the start is refused (the party would be brought to a commander that is under the ground or in the air)",
+ why, at.Z, ground, floor, here.Z)
+ }
+ return nil
+}
diff --git a/internal/dungeon/start_gps_test.go b/internal/dungeon/start_gps_test.go
new file mode 100644
index 00000000..98c3d7ab
--- /dev/null
+++ b/internal/dungeon/start_gps_test.go
@@ -0,0 +1,201 @@
+package dungeon
+
+import (
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "testing"
+)
+
+// gpsText is what the core prints for `.gps` (Commands.cpp HandleGPSCommand with mangos_string 101), as lines of the chat, for the
+// commander at (100, 0).
+func gpsText(ground, floor float64) string { return gpsTextAt(100, 0, ground, floor) }
+
+// gpsTextAt is the same reply about a unit at (x, y).
+func gpsTextAt(x, y, ground, floor float64) string {
+ return strings.Join([]string{
+ "You are OUTdoor",
+ "Map: 70 (Uldaman) Zone: 1337 (Uldaman) Area: 1337 (Uldaman)",
+ fmt.Sprintf("X: %f Y: %f Z: 0.000000 Orientation: 1.570796", x, y),
+ "grid[31,32]cell[5,6] InstanceID: 12",
+ " ZoneX: 10.500000 ZoneY: 20.250000",
+ fmt.Sprintf("GroundZ: %f FloorZ: %f Have height data (Map: 1 VMap: 1)", ground, floor),
+ }, "\n")
+}
+
+func TestParseGPS(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ text string
+ want GPSReply
+ ok bool
+ }{
+ {"real wording", gpsText(-120.5, -131.25), GPSReply{X: 100, Y: 0, GroundZ: -120.5, FloorZ: -131.25}, true},
+ {"x and y with decimals", gpsTextAt(-12.5, 3.25, 1, 2), GPSReply{X: -12.5, Y: 3.25, GroundZ: 1, FloorZ: 2}, true},
+ {"extra lines after", gpsText(1, 2) + "\nsomething else\n", GPSReply{X: 100, GroundZ: 1, FloorZ: 2}, true},
+ {"other player's chat before", "[World] hi\n" + gpsText(3, 3), GPSReply{X: 100, GroundZ: 3, FloorZ: 3}, true},
+ {"integers", "X: 5 Y: 6\nGroundZ: 5 FloorZ: 6", GPSReply{X: 5, Y: 6, GroundZ: 5, FloorZ: 6}, true},
+ {"no height data (-200000)", gpsText(-200000, -200000), GPSReply{}, false},
+ {"floor only invalid", gpsText(10, -200000), GPSReply{}, false},
+ {"no FloorZ", "X: 1 Y: 2\nGroundZ: 5.0", GPSReply{}, false},
+ {"no X and Y", "GroundZ: 12.000000 FloorZ: 11.750000 Have height data (Map: 1 VMap: 1)", GPSReply{}, false},
+ {"ZoneX and ZoneY are not X and Y", "ZoneX: 10.500000 ZoneY: 20.250000\nGroundZ: 1.0 FloorZ: 2.0", GPSReply{}, false},
+ {"empty", "", GPSReply{}, false},
+ {"unrelated", "There is no such command", GPSReply{}, false},
+ {"comma decimals are not read", "X: 1,5 Y: 2,5\nGroundZ: 5,5 FloorZ: 6,5", GPSReply{}, false},
+ } {
+ got, ok := ParseGPS(c.text)
+ if ok != c.ok || got != c.want {
+ t.Errorf("%s: got %+v %v, want %+v %v", c.name, got, ok, c.want, c.ok)
+ }
+ }
+}
+
+func gpsConst(ground, floor float64) func(*startFake) (string, error) {
+ return func(*startFake) (string, error) { return gpsText(ground, floor), nil }
+}
+
+// The commander ends 15 yd under the ground at the start point: `.go xyz` is sent once more at FloorZ+0.3, and when it still stands off
+// the start is refused with the numbers, before the party is brought.
+func TestStartRefusesACommanderUnderTheGround(t *testing.T) {
+ g := newStartFake(t)
+ g.addMob(300, Point{250, 5, 0})
+ g.sink = 15
+ g.gpsFn = gpsConst(0, 0)
+ rep, err := runFrom(t, g, twoLegPlan(), startOpts(), 1)
+ if err == nil || rep.State != "failed" {
+ t.Fatalf("%v %s\n%s", err, rep.State, notesOf(rep))
+ }
+ for _, want := range []string{"target z 0.0", "GroundZ 0.0", "FloorZ 0.0", "commander z -14.7"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("the refusal lacks %q: %v", want, err)
+ }
+ }
+ if len(g.goPts) != 2 || g.goPts[0] != (Point{100, 0, 0}) || g.goPts[1].X != 100 || g.goPts[1].Y != 0 || g.goPts[1].Z < 0.29 || g.goPts[1].Z > 0.31 {
+ t.Fatalf("go xyz points %v", g.goPts)
+ }
+ if g.gpsCalls != 2 || len(g.botLines) != 0 || len(g.summonsLog) != 0 {
+ t.Fatalf("gps %d, bot lines %v, summons %v", g.gpsCalls, g.botLines, g.summonsLog)
+ }
+}
+
+// A commander that stands on the floor needs no second move.
+func TestStartKeepsACommanderOnTheFloor(t *testing.T) {
+ g := newStartFake(t)
+ g.addMob(300, Point{250, 5, 0})
+ g.gpsFn = gpsConst(0, 0.2)
+ rep, err := runFrom(t, g, twoLegPlan(), startOpts(), 1)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s\n%s", err, rep.State, notesOf(rep))
+ }
+ if len(g.goPts) != 1 || g.gpsCalls != 1 {
+ t.Fatalf("go xyz %v, gps calls %d", g.goPts, g.gpsCalls)
+ }
+}
+
+// A commander that hovers above the floor is put down on it by the one re-send.
+func TestStartResendsOnceToTheFloor(t *testing.T) {
+ g := newStartFake(t)
+ g.addMob(300, Point{250, 5, 0})
+ g.gpsFn = gpsConst(5, 5)
+ rep, err := runFrom(t, g, twoLegPlan(), startOpts(), 1)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s\n%s", err, rep.State, notesOf(rep))
+ }
+ if len(g.goPts) != 2 || g.goPts[1].Z < 5.29 || g.goPts[1].Z > 5.31 || g.gpsCalls != 2 {
+ t.Fatalf("go xyz %v, gps calls %d", g.goPts, g.gpsCalls)
+ }
+}
+
+// The floor under the commander is another layer than the point (GroundZ differs, FloorZ is 40 yd from the target z): refused even when
+// the commander stands on it.
+func TestStartRefusesAFloorFarFromTheTarget(t *testing.T) {
+ g := newStartFake(t)
+ g.addMob(300, Point{250, 5, 0})
+ g.gpsFn = gpsConst(100, 40)
+ _, err := runFrom(t, g, twoLegPlan(), startOpts(), 1)
+ if err == nil || !strings.Contains(err.Error(), "FloorZ 40.0") || !strings.Contains(err.Error(), "GroundZ 100.0") {
+ t.Fatalf("%v", err)
+ }
+ // GroundZ = FloorZ far from the target is not that refusal: the point is what the plan says.
+ g = newStartFake(t)
+ g.addMob(300, Point{250, 5, 0})
+ g.gpsFn = gpsConst(40, 40)
+ if rep, err := runFrom(t, g, twoLegPlan(), startOpts(), 1); err != nil || rep.State != "done" {
+ t.Fatalf("%v %s", err, rep.State)
+ }
+}
+
+// A .gps that fails or cannot be read never blocks the start (older servers, commanders without GM): one note says the check is best effort.
+func TestStartGPSIsBestEffort(t *testing.T) {
+ for name, fn := range map[string]func(*startFake) (string, error){
+ "no reply": func(*startFake) (string, error) { return "", errors.New("timeout") },
+ "unparseable": func(*startFake) (string, error) { return "There is no such command", nil },
+ "no map data": gpsConst(-200000, -200000),
+ "empty reply": func(*startFake) (string, error) { return "", nil },
+ } {
+ g := newStartFake(t)
+ g.addMob(300, Point{250, 5, 0})
+ g.sink = 15
+ g.gpsFn = fn
+ rep, err := runFrom(t, g, twoLegPlan(), startOpts(), 1)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%s: %v %s\n%s", name, err, rep.State, notesOf(rep))
+ }
+ if n := strings.Count(notesOf(rep), "best effort"); n != 1 || len(g.goPts) != 1 {
+ t.Fatalf("%s: %d best-effort notes, go xyz %v\n%s", name, n, g.goPts, notesOf(rep))
+ }
+ }
+}
+
+// A reply about another unit (500 yd away, its floor 15 yd under the commander's z) is not the commander's: it is unreadable, so it
+// neither moves the commander again nor refuses the start, and one note says the check could not be made.
+func TestStartIgnoresAReplyAboutAnotherUnit(t *testing.T) {
+ g := newStartFake(t)
+ g.addMob(300, Point{250, 5, 0})
+ g.sink = 15
+ g.gpsFn = func(*startFake) (string, error) { return gpsTextAt(500, 500, 0, 0), nil }
+ rep, err := runFrom(t, g, twoLegPlan(), startOpts(), 1)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s\n%s", err, rep.State, notesOf(rep))
+ }
+ if len(g.goPts) != 1 || g.gpsCalls != 1 {
+ t.Fatalf("go xyz %v, gps calls %d: a reply about another unit must not move the commander", g.goPts, g.gpsCalls)
+ }
+ if n := strings.Count(notesOf(rep), "best effort"); n != 1 {
+ t.Fatalf("%d best-effort notes\n%s", n, notesOf(rep))
+ }
+}
+
+// Every `.gps` the start sends is booked like the `.go xyz` lines: in setup_shortcuts (GM start point), in the start's gm_commands and in
+// remote.gm_commands. It is not a gm_shortcut (the buffs and the other shortcuts).
+func TestStartBooksEachGPSLine(t *testing.T) {
+ g := newStartFake(t)
+ g.addMob(300, Point{250, 5, 0})
+ g.gpsFn = gpsConst(5, 5) // one re-send, so two `.gps` lines
+ rep, err := runFrom(t, g, twoLegPlan(), startOpts(), 1)
+ if err != nil || rep.State != "done" {
+ t.Fatalf("%v %s\n%s", err, rep.State, notesOf(rep))
+ }
+ var setup []string
+ for _, s := range rep.SetupShortcuts {
+ if strings.HasPrefix(s, ".gps") {
+ setup = append(setup, s)
+ }
+ }
+ if want := []string{".gps (GM start point)", ".gps (GM start point)"}; !slices.Equal(setup, want) {
+ t.Fatalf("setup_shortcuts %q, want %q", setup, want)
+ }
+ if rep.Start == nil || strings.Count(strings.Join(rep.Start.GMCommands, "\n"), ".gps") != 2 {
+ t.Fatalf("start gm_commands %v", rep.Start)
+ }
+ for _, s := range rep.Shortcuts {
+ if strings.Contains(s.Detail, ".gps") {
+ t.Fatalf("gm_shortcuts lists %q", s.Detail)
+ }
+ }
+ if rep.Remote == nil || rep.Remote.GMCommands != 5 {
+ t.Fatalf("remote gm_commands %+v, want .appear, two .go xyz and two .gps", rep.Remote)
+ }
+}
diff --git a/internal/dungeon/start_test.go b/internal/dungeon/start_test.go
index 6b5620e1..ddc7d453 100644
--- a/internal/dungeon/start_test.go
+++ b/internal/dungeon/start_test.go
@@ -2,6 +2,7 @@ package dungeon
import (
"context"
+ "errors"
"fmt"
"slices"
"strings"
@@ -45,6 +46,13 @@ type startFake struct {
// calls is every call of the fake that acts on the game, in order, beside the commands in orders: a refused start
// must leave both empty (no teleport, unbind, GM line, bot line or move).
calls []string
+
+ // .gps: gpsFn answers it (nil: the fake has no .gps reply and the call fails); sink puts the agent that far below the point of each
+ // `.go xyz`; goPts is every point `.go xyz` was sent.
+ gpsFn func(g *startFake) (string, error)
+ gpsCalls int
+ sink float32
+ goPts []Point
}
func (g *startFake) log(format string, a ...any) {
@@ -152,9 +160,18 @@ func (g *startFake) WalkSelf(_ context.Context, at Point) (WalkResult, error) {
func (g *startFake) GoXYZ(_ context.Context, mapID uint32, at Point) error {
g.log("go xyz %v %d", at, mapID)
g.gos = append(g.gos, fmt.Sprintf("%.0f %.0f %.0f %d", at.X, at.Y, at.Z, mapID))
- g.agent, g.agentMap = at, mapID
+ g.goPts = append(g.goPts, at)
+ g.agent, g.agentMap = Point{at.X, at.Y, at.Z - g.sink}, mapID
return nil
}
+func (g *startFake) GPS(context.Context) (string, error) {
+ g.log("gps")
+ g.gpsCalls++
+ if g.gpsFn == nil {
+ return "", errors.New("no .gps reply")
+ }
+ return g.gpsFn(g)
+}
func (g *startFake) BotLines(_ context.Context, command string, _ time.Duration, done func(string) bool) ([]string, bool, error) {
g.log("bot %s", command)
if command == "roster" {
@@ -276,10 +293,10 @@ func TestStartFromLegMovesTheCommanderAndSummonsTheParty(t *testing.T) {
if s == nil || s.Kind != StartLeg || s.Leg != 1 || s.Method != "gm" || s.Point != (Point{100, 0, 0}) || !s.LeadSummoned || fmt.Sprint(s.Summoned) != "[Healy]" {
t.Fatalf("start %+v", s)
}
- if fmt.Sprint(s.GMCommands) != "[.appear Tanky .go xyz 100.00 0.00 0.00 0]" || len(s.Skipped) != 1 || s.Skipped[0] != "leg 0 (first): skipped (from_leg)" {
+ if fmt.Sprint(s.GMCommands) != "[.appear Tanky .go xyz 100.00 0.00 0.00 0 .gps]" || len(s.Skipped) != 1 || s.Skipped[0] != "leg 0 (first): skipped (from_leg)" {
t.Fatalf("start %+v", s)
}
- if rep.Remote.GMCommands != 2 || rep.Remote.RunGMAssists != 0 {
+ if rep.Remote.GMCommands != 3 || rep.Remote.RunGMAssists != 0 {
t.Fatalf("gm %d assists %d", rep.Remote.GMCommands, rep.Remote.RunGMAssists)
}
if got := fmt.Sprint(rep.SetupShortcuts); !strings.Contains(got, ".appear Tanky (GM start point)") || !strings.Contains(got, ".go xyz 100.00 0.00 0.00 0 (GM start point)") {
diff --git a/internal/dungeon/uldaman_cc_test.go b/internal/dungeon/uldaman_cc_test.go
new file mode 100644
index 00000000..15df8f51
--- /dev/null
+++ b/internal/dungeon/uldaman_cc_test.go
@@ -0,0 +1,161 @@
+package dungeon
+
+import (
+ "fmt"
+ "github.com/pjerra/turtle-agent/internal/packet"
+ "github.com/pjerra/turtle-agent/internal/tbm"
+ "slices"
+ "testing"
+)
+
+// uldamanCreatureType is creature_template.type (1 beast, 4 elemental, 7 humanoid) of every entry the Uldaman plan's crowd-control
+// lists name, from the world database of tortoise-wow 5d79486 (sql/base/tw_world_creature_template.sql). Polymorph lands on beasts and humanoids
+// only; the plan's cc lists name nothing else, and no_cc names the elementals.
+var uldamanCreatureType = map[uint32]struct {
+ name string
+ typ int
+}{
+ 4853: {"Stonevault Geomancer", 7}, 4848: {"Shadowforge Darkcaster", 7}, 7321: {"Stonevault Flameweaver", 7},
+ 7030: {"Shadowforge Geologist", 7}, 7012: {"Earthen Sculptor", 7}, 7397: {"Earthen Stonecarver", 7},
+ 4852: {"Stonevault Oracle", 7},
+ 4857: {"Stone Keeper", 4}, 4860: {"Stone Steward", 4},
+}
+
+const (
+ creatureBeast, creatureHumanoid = 1, 7
+)
+
+// Uldaman's run leader used to send no Polymorph order in ten runs: the plan had no cc_priority, cc_always or kill_first (audit F4).
+// Every entry of those lists is a creature of one of the plan's pull groups, a beast or humanoid for the Polymorph; no_cc holds the ones it
+// cannot take.
+func TestUldamanCCListsAreCCAbleUnitsOfPullGroups(t *testing.T) {
+ p := loadUldaman(t)
+ inGroups := map[uint32]bool{}
+ for _, l := range p.Legs {
+ for _, g := range l.PullGroups {
+ for _, e := range g.Entries {
+ inGroups[e] = true
+ }
+ }
+ }
+ if len(p.CCPriority) == 0 || len(p.CCAlways) == 0 || len(p.KillFirst) == 0 || len(p.NoCC) == 0 {
+ t.Fatalf("cc_priority %v cc_always %v kill_first %v no_cc %v", p.CCPriority, p.CCAlways, p.KillFirst, p.NoCC)
+ }
+ for name, es := range map[string][]uint32{"cc_priority": p.CCPriority, "cc_always": p.CCAlways, "kill_first": p.KillFirst} {
+ for _, e := range es {
+ c, known := uldamanCreatureType[e]
+ if !known {
+ t.Errorf("%s %d: not in the creature table of this test", name, e)
+ continue
+ }
+ if !inGroups[e] {
+ t.Errorf("%s %d (%s): in no pull group of any leg", name, e, c.name)
+ }
+ if c.typ != creatureHumanoid && c.typ != creatureBeast {
+ t.Errorf("%s %d (%s): type %d takes no Polymorph", name, e, c.name, c.typ)
+ }
+ if slices.Contains(p.NoCC, e) {
+ t.Errorf("%s %d (%s): also in no_cc", name, e, c.name)
+ }
+ }
+ }
+ for _, e := range p.CCAlways {
+ if !slices.Contains(p.CCPriority, e) {
+ t.Errorf("cc_always %d is not in cc_priority", e)
+ }
+ if !slices.Contains(p.Fleers, e) { // the reason for sheeping it in any pack: it is a caster that runs at 15 % and drags the next group in
+ t.Errorf("cc_always %d is not one of the plan's fleers", e)
+ }
+ }
+ for _, e := range p.NoCC {
+ c := uldamanCreatureType[e]
+ if c.typ == creatureHumanoid || c.typ == creatureBeast || c.name == "" {
+ t.Errorf("no_cc %d (%s): type %d takes a Polymorph", e, c.name, c.typ)
+ }
+ if !inGroups[e] {
+ t.Errorf("no_cc %d (%s): in no pull group", e, c.name)
+ }
+ }
+ // The cc bot's pick: the Geomancers and Flameweavers are taken before the Earthen Sculptors; the healers are killed, never sheeped.
+ if p.CCPriority[0] != 4853 || slices.Contains(p.CCPriority, 4852) {
+ t.Errorf("cc_priority %v", p.CCPriority)
+ }
+}
+
+// The cc bot's pick on Uldaman's real plan: a big pack of a Geomancer and four Brawlers is sheeped on the Geomancer, never on the Brawlers; a pack of Stone Stewards (elementals) gives no pick; the healer Oracle is never sheeped.
+func TestUldamanPackCCPicksTheCasterAndNeverTheElemental(t *testing.T) {
+ d := ledgerDriver(t)
+ d.plan = loadPlan(t, uldamanPlan)
+ mk := func(guid uint64, entry uint32, x float32) tbm.HostileRec {
+ return tbm.HostileRec{GUID: packet.GUID(0xF130000000000000 | guid), Entry: entry, HPPct: 100, Flags: "-", X: x}
+ }
+ target := mk(1, 4855, 0)
+ pick, ok := d.ccPick(target, []tbm.HostileRec{target, mk(2, 4855, 30), mk(3, 4853, 5), mk(8, 4855, 40), mk(9, 4855, 40)}, false)
+ if !ok || pick.Entry != 4853 {
+ t.Fatalf("pick %+v %v", pick, ok)
+ }
+ stew := mk(4, 4860, 0)
+ if _, ok := d.ccPick(stew, []tbm.HostileRec{stew, mk(5, 4860, 9), mk(6, 4857, 9)}, false); ok {
+ t.Error("an elemental was picked")
+ }
+ if d.ccAllowed(mk(7, 4852, 0)) {
+ t.Error("the Oracle (kill_first) may be sheeped")
+ }
+}
+
+// No boss.first entry of any leg is cc_always: a cc_always entry gets pack and reactive CC below big_pack too, and the encounter kills its
+// `first` adds in the plan's order (Codex review of feat/uldaman-cc: Grimlok's Geomancer 4853).
+func TestUldamanNoBossFirstAddIsCCAlways(t *testing.T) {
+ p := loadUldaman(t)
+ n := 0
+ for _, l := range p.Legs {
+ if l.Boss == nil {
+ continue
+ }
+ for _, e := range l.Boss.First {
+ n++
+ if slices.Contains(p.CCAlways, e) {
+ t.Errorf("%s: boss.first %d is in cc_always", l.Name, e)
+ }
+ }
+ }
+ if n == 0 {
+ t.Fatal("no boss.first entry")
+ }
+}
+
+// Grimlok's fight (his `first` adds 4855, 4853, 4863 and the boss): in the boss fight none of them is picked for CC by the pack pick, whatever the
+// pack size, and the kill order stays boss.first then the boss. The same Geomancer in an ordinary pull (no boss fight) is still sheepable.
+func TestUldamanGrimlokFightSelectsNoFirstAddForCC(t *testing.T) {
+ d := ledgerDriver(t)
+ d.plan = loadPlan(t, uldamanPlan)
+ li := -1
+ for i, l := range d.plan.Legs {
+ if l.Name == "grimlok" {
+ li = i
+ }
+ }
+ if li < 0 || fmt.Sprint(d.plan.Legs[li].Boss.First) != "[4855 4853 4863]" {
+ t.Fatalf("grimlok leg %d", li)
+ }
+ d.curLeg = li
+ mk := func(guid uint64, entry uint32, x float32) tbm.HostileRec {
+ return tbm.HostileRec{GUID: packet.GUID(0xF130000000000000 | guid), Entry: entry, HPPct: 100, Flags: "-", X: x}
+ }
+ target := mk(1, 4855, 0)
+ pack := []tbm.HostileRec{target, mk(2, 4855, 20), mk(3, 4853, 30), mk(4, 4863, 25), mk(5, 4855, 10), mk(6, 4855, 12)}
+ d.bossFight = true
+ if pick, ok := d.ccPick(target, pack, false); ok {
+ t.Errorf("a boss.first add was picked for CC: %+v", pick)
+ }
+ if pick, ok := d.ccPick(target, pack, true); ok {
+ t.Errorf("always: picked %+v", pick)
+ }
+ d.bossFight = false
+ if pick, ok := d.ccPick(target, pack, false); !ok || pick.Entry != 4853 {
+ t.Errorf("ordinary pull: %+v %v", pick, ok)
+ }
+ if enc := append(slices.Clone(d.plan.Legs[li].Boss.First), d.plan.Legs[li].Boss.Entry); fmt.Sprint(enc) != "[4855 4853 4863 4854]" {
+ t.Errorf("kill order %v", enc)
+ }
+}
diff --git a/internal/dungeon/uldaman_pulls_test.go b/internal/dungeon/uldaman_pulls_test.go
new file mode 100644
index 00000000..bb11e58e
--- /dev/null
+++ b/internal/dungeon/uldaman_pulls_test.go
@@ -0,0 +1,376 @@
+package dungeon
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/navmesh"
+)
+
+// PLAN-ULDAMAN-GRIMLOK (2026-10-21, eight Uldaman runs): every wipe and near wipe at the Ancient Stone Keeper and in Grimlok's corridor was a
+// pull that grew from 2-4 to 5-11 mobs because a patrol, a neighbouring pack or a pack of another leg was still alive. These tests hold the plan
+// data that fixes the order: the Keeper is pulled with nothing alive near it, and Grimlok's corridor is cleared in an order where the
+// groups that woke each other are pulled one by one, from stands that keep clear of whatever is still alive.
+
+// The two patrols of the changed ground (creature_movement of the tortoise-wow 5d79486 world database: guid, point, x, y; the plan has no patrol
+// rows for them: the tsv carries no waypoints). The Stone Steward 27473 walks between its two points; the Brawler 27505 along three.
+var uldamanPatrols = map[string][]Point{
+ "27473": {{X: -42.417, Y: 234.082}, {X: -41.454, Y: 274.43}, {X: -42.417, Y: 234.082}},
+ "27505": {{X: 57.9032, Y: 373.95}, {X: 75.2625, Y: 395.951}, {X: 62.5435, Y: 409.664}, {X: 75.2625, Y: 395.951}, {X: 57.9032, Y: 373.95}},
+}
+
+// uldamanSpawn is a creature row of the world export.
+type uldamanSpawn struct {
+ guid string
+ entry uint32
+ at Point
+}
+
+func uldamanSpawns(t *testing.T) []uldamanSpawn {
+ t.Helper()
+ var out []uldamanSpawn
+ for _, m := range tsv(t, "creatures.tsv") {
+ e, _ := strconv.ParseUint(m["entry"], 10, 32)
+ out = append(out, uldamanSpawn{m["guid"], uint32(e), mustPoint(m)})
+ }
+ return out
+}
+
+// uldamanHostileEntries are the entries the plan fights in the legs from the Sentinel on: a pull group's or a boss's units.
+func uldamanHostileEntries(p *Plan) map[uint32]bool {
+ out := map[uint32]bool{}
+ for _, l := range p.Legs[5:] {
+ for _, g := range l.PullGroups {
+ for _, e := range g.Entries {
+ out[e] = true
+ }
+ }
+ if l.Boss != nil {
+ out[l.Boss.Entry] = true
+ for _, e := range l.Boss.First {
+ out[e] = true
+ }
+ }
+ }
+ return out
+}
+
+func uldamanLeg(t *testing.T, p *Plan, name string) int {
+ t.Helper()
+ for i, l := range p.Legs {
+ if l.Name == name {
+ return i
+ }
+ }
+ t.Fatalf("no leg %q", name)
+ return -1
+}
+
+// uldamanPulledBefore lists the positions of every pull group that is pulled (so dead) before group `group` of leg `leg`: all the groups of the
+// legs before it and the leg's own groups before that index (len(PullGroups) for the leg's boss).
+func uldamanPulledBefore(p *Plan, leg, group int) []Point {
+ var out []Point
+ for li := 0; li <= leg; li++ {
+ for gi, g := range p.Legs[li].PullGroups {
+ if li == leg && gi >= group {
+ break
+ }
+ out = append(out, g.Positions...)
+ }
+ }
+ return out
+}
+
+// uldamanDistance is how near a spawn comes to a point: its spawn, or for a patrol the nearest point of its walk.
+func uldamanDistance(s uldamanSpawn, at Point) float64 {
+ d := groundDist(s.at, at)
+ if path, ok := uldamanPatrols[s.guid]; ok {
+ for i := 0; i+1 < len(path); i++ {
+ d = math.Min(d, pointToSegYd(at, path[i], path[i+1]))
+ }
+ }
+ return d
+}
+
+// uldamanAliveWithin lists the spawns of the hostile entries within yd of `at` that no earlier pull group of the plan has killed by the time of
+// group `group` of leg `leg`. `skip` leaves a spawn out (the boss itself).
+func uldamanAliveWithin(p *Plan, spawns []uldamanSpawn, leg, group int, at Point, yd float64, skip func(uldamanSpawn) bool) []string {
+ hostile := uldamanHostileEntries(p)
+ dead := uldamanPulledBefore(p, leg, group)
+ var out []string
+ for _, s := range spawns {
+ if !hostile[s.entry] || (skip != nil && skip(s)) {
+ continue
+ }
+ pulled := false
+ for _, d := range dead {
+ pulled = pulled || groundDist(s.at, d) < 0.2
+ }
+ if pulled {
+ continue
+ }
+ if d := uldamanDistance(s, at); d < yd {
+ out = append(out, fmt.Sprintf("%s (entry %d at %.1f %.1f) %.1f yd", s.guid, s.entry, s.at.X, s.at.Y, d))
+ }
+ }
+ return out
+}
+
+// uldamanOldStoneKeeperOrder puts the Steward and the Sculptor trio back in the galgann leg and drops the southern pair: the order of the plan
+// every run before 2026-10-21 walked.
+func uldamanOldStoneKeeperOrder(t *testing.T, p *Plan) {
+ t.Helper()
+ sk, ga := uldamanLeg(t, p, "ancient_stone_keeper"), uldamanLeg(t, p, "galgann")
+ g := p.Legs[sk].PullGroups
+ n := len(g)
+ if n < 10 || fmt.Sprint(g[n-3].Entries, g[n-2].Entries, g[n-1].Entries) != "[4860] [7012 7012 7011] [7011 7011]" {
+ t.Fatalf("the Stone Keeper leg ends in %v", g[n-3:])
+ }
+ steward, sculptors := g[n-3], g[n-2]
+ steward.Approach, sculptors.Approach = nil, nil
+ p.Legs[sk].PullGroups = g[:n-3]
+ gg := p.Legs[ga].PullGroups
+ p.Legs[ga].PullGroups = append([]PullGroup{gg[0], steward, sculptors}, gg[1:]...)
+}
+
+// Neither the Stone Steward's patrol, nor the Sculptors, nor the southern Rocksmashers may be alive when the party pulls the Ancient Stone Keeper
+// (r4 slot1: wipe with the Steward, a Rocksmasher and two Sculptors in the fight; r6 slot5: seven mobs, two deaths). Every Earthen guard within 45 yd of
+// the Keeper, a patrol counted by the nearest point of its walk, is the target of a pull group before the boss.
+func TestUldamanStoneKeeperIsPulledWithNothingAliveNearIt(t *testing.T) {
+ spawns := uldamanSpawns(t)
+ keeper := func(s uldamanSpawn) bool { return s.entry == 7206 }
+ const nearYd = 45
+ check := func(p *Plan) []string {
+ sk := uldamanLeg(t, p, "ancient_stone_keeper")
+ boss := p.Legs[sk].Boss
+ if boss == nil || boss.Entry != 7206 {
+ t.Fatalf("leg %d has no Stone Keeper boss", sk)
+ }
+ return uldamanAliveWithin(p, spawns, sk, len(p.Legs[sk].PullGroups), boss.Position, nearYd, keeper)
+ }
+ if alive := check(loadUldaman(t)); len(alive) != 0 {
+ t.Errorf("alive within %d yd of the Stone Keeper when it is pulled:\n%s", nearYd, strings.Join(alive, "\n"))
+ }
+ // The order before: the Steward (patrol end 13.6 yd from the Keeper), the Sculptor trio and the two southern Rocksmashers are alive.
+ old := loadUldaman(t)
+ uldamanOldStoneKeeperOrder(t, old)
+ alive := strings.Join(check(old), "\n")
+ for _, guid := range []string{"27473", "28784", "28789", "28795", "28547"} {
+ if !strings.Contains(alive, guid+" ") {
+ t.Errorf("the old order should leave %s alive at the Stone Keeper:\n%s", guid, alive)
+ }
+ }
+ // And the Steward and the trio are not pulled twice: the galgann leg no longer lists them.
+ p := loadUldaman(t)
+ for _, g := range p.Legs[uldamanLeg(t, p, "galgann")].PullGroups {
+ if len(g.Entries) > 0 && (g.Entries[0] == 4860 || (len(g.Entries) == 3 && g.Entries[0] == 7012 && g.Positions[0].X < -40)) {
+ t.Errorf("the galgann leg still pulls %v at %v", g.Entries, g.Positions)
+ }
+ }
+}
+
+// uldamanGroupOf finds the pull group (leg name, index) whose positions hold the spawn.
+func uldamanGroupOf(p *Plan, s uldamanSpawn) string {
+ for _, l := range p.Legs {
+ for gi, g := range l.PullGroups {
+ for _, pos := range g.Positions {
+ if groundDist(pos, s.at) < 0.15 {
+ return fmt.Sprintf("%s/%d", l.Name, gi)
+ }
+ }
+ }
+ }
+ return ""
+}
+
+// Grimlok's corridor, pull by pull (PLAN-ULDAMAN-GRIMLOK #2): the Brawler 30105 that joined pull 0 in 8 of 8 runs is in it; the first three groups of the
+// keepers_door leg are pulled next, before the east groups; the patrol Brawler and the trio that always came with it are one pull; the pair is
+// the last pull before Grimlok.
+func TestUldamanGrimlokCorridorPullOrder(t *testing.T) {
+ p := loadUldaman(t)
+ byGuid := map[string]uldamanSpawn{}
+ for _, s := range uldamanSpawns(t) {
+ byGuid[s.guid] = s
+ }
+ for want, guids := range map[string][]string{
+ "grimlok/0": {"27527", "27526", "30105"},
+ "grimlok/1": {"27521", "27522"},
+ "grimlok/2": {"30106", "27525"},
+ "grimlok/3": {"27505", "30091", "30092", "30093"},
+ "grimlok/4": {"27512", "27513", "27514"},
+ "grimlok/5": {"30094", "30098", "30099"},
+ "grimlok/6": {"30101", "30100"},
+ } {
+ for _, g := range guids {
+ s, ok := byGuid[g]
+ if !ok {
+ t.Fatalf("no creature %s in the export", g)
+ }
+ if got := uldamanGroupOf(p, s); got != want {
+ t.Errorf("creature %s (entry %d at %.1f %.1f) is in %q, want %q", g, s.entry, s.at.X, s.at.Y, got, want)
+ }
+ }
+ }
+ gr := p.Legs[uldamanLeg(t, p, "grimlok")]
+ if n := len(gr.PullGroups); n != 7 {
+ t.Fatalf("grimlok has %d pull groups, want 7", n)
+ }
+ // Group 0 counts the third Brawler and keeps the Brawler at 55.4 379 (the patrol's trio) out of its radius; the merged group has the patrol's
+ // four units and a radius that holds the trio around it.
+ g0 := gr.PullGroups[0]
+ if fmt.Sprint(g0.Entries) != "[4853 4855 4855]" || g0.Radius != 16 {
+ t.Errorf("grimlok pull 0: %v radius %g", g0.Entries, g0.Radius)
+ }
+ c := PackCentre(g0)
+ if d := groundDist(c, byGuid["30092"].at); d <= float64(g0.Radius) {
+ t.Errorf("the trio's Brawler 30092 is %.1f yd from pull 0's centre, inside its radius %g", d, g0.Radius)
+ }
+ for _, g := range []string{"27527", "27526", "30105"} {
+ if d := groundDist(c, byGuid[g].at); d > float64(g0.Radius) {
+ t.Errorf("%s is %.1f yd from pull 0's centre, outside its radius %g", g, d, g0.Radius)
+ }
+ }
+ if m := gr.PullGroups[3]; fmt.Sprint(m.Entries) != "[4855 4855 4853 4855]" || m.Radius != 18 || len(m.Positions) != 4 {
+ t.Errorf("grimlok merged pull: %+v", m)
+ }
+ // The keepers_door leg keeps only what is south of the corridor (its old groups 3-7), and no spawn is in two legs.
+ kd := p.Legs[uldamanLeg(t, p, "keepers_door")]
+ if len(kd.PullGroups) != 5 || fmt.Sprint(kd.PullGroups[0].Entries) != "[4855 4855 4853]" {
+ t.Errorf("keepers_door pull groups: %d, first %v", len(kd.PullGroups), kd.PullGroups[0].Entries)
+ }
+ seen := map[Point]string{}
+ for _, l := range p.Legs {
+ for gi, g := range l.PullGroups {
+ for _, pos := range g.Positions {
+ key := Point{X: pos.X, Y: pos.Y}
+ if prev, dup := seen[key]; dup && !(l.Name == "galgann" && gi == 0) {
+ t.Errorf("%s/%d lists %v, already in %s", l.Name, gi, pos, prev)
+ }
+ seen[key] = fmt.Sprintf("%s/%d", l.Name, gi)
+ }
+ }
+ }
+}
+
+// uldamanGuardedStands are the approach points of the changed groups held to the clearance rule: every one stays 20 yd from every unit that is
+// still alive when the group is pulled (its own pack, the groups pulled later, the bosses and the units no group lists, a patrol by its walk) and
+// 23 yd from the Stone Keeper, where the Keeper's own pull would otherwise begin.
+func TestUldamanChangedStandsKeepClearOfWhatIsAlive(t *testing.T) {
+ spawns := uldamanSpawns(t)
+ check := func(p *Plan) []string {
+ var out []string
+ for li, l := range p.Legs {
+ if l.Name != "ancient_stone_keeper" && l.Name != "grimlok" {
+ continue
+ }
+ for gi, g := range l.PullGroups {
+ if !uldamanGuarded[Point{X: g.Positions[0].X, Y: g.Positions[0].Y}] || len(g.Approach) == 0 {
+ continue
+ }
+ stand := g.Approach[len(g.Approach)-1]
+ for _, a := range uldamanAliveWithin(p, spawns, li, gi, stand, 20, nil) {
+ out = append(out, fmt.Sprintf("%s/%d stand %.1f %.1f: %s", l.Name, gi, stand.X, stand.Y, a))
+ }
+ if l.Name == "ancient_stone_keeper" {
+ if d := groundDist(stand, l.Boss.Position); d < 23 {
+ out = append(out, fmt.Sprintf("%s/%d stand %.1f %.1f is %.1f yd from the Stone Keeper", l.Name, gi, stand.X, stand.Y, d))
+ }
+ }
+ }
+ }
+ return out
+ }
+ if bad := check(loadUldaman(t)); len(bad) != 0 {
+ t.Errorf("%s", strings.Join(bad, "\n"))
+ }
+ // The rule has teeth: the Steward's stand at the galgann leg's `to` (-39 243, where the old order started its walk up the passage) is 12.7 yd from
+ // a Sculptor and 22 from the Keeper.
+ p := loadUldaman(t)
+ sk := p.Legs[uldamanLeg(t, p, "ancient_stone_keeper")]
+ sk.PullGroups[len(sk.PullGroups)-3].Approach = []Point{{X: -39, Y: 243, Z: -48}}
+ if bad := check(p); len(bad) < 2 {
+ t.Errorf("a stand at -39 243 passes: %v", bad)
+ }
+}
+
+// The approach-clearance rule of PR #281 on the changed groups, with the two patrols of the ground (the plan has no patrol rows for them) laid on
+// their groups: none of the changed groups has a finding, a stand is 15 yd or more from every patrol walk.
+func TestUldamanChangedGroupsPassTheApproachClearanceRule(t *testing.T) {
+ p := loadUldaman(t)
+ byGuid := map[string]uldamanSpawn{}
+ for _, s := range uldamanSpawns(t) {
+ byGuid[s.guid] = s
+ }
+ for guid, path := range uldamanPatrols {
+ s := byGuid[guid]
+ var found bool
+ for li := range p.Legs {
+ for gi := range p.Legs[li].PullGroups {
+ g := &p.Legs[li].PullGroups[gi]
+ for _, pos := range g.Positions {
+ if groundDist(pos, s.at) < 0.15 && g.Patrol == nil {
+ g.Patrol = &Patrol{Entries: []uint32{s.entry}, Path: path}
+ found = true
+ }
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("no pull group holds the patrol %s", guid)
+ }
+ }
+ for _, x := range approachClearanceOf(p, uldamanGuarded) {
+ t.Errorf("%s", x)
+ }
+}
+
+// The pair at 38-40 439-445 is pulled from a stand with a line to both of them on the navmesh (the old stand at 59.7 408.2 had none to the Geomancer at
+// 37.6 438.8: the run leader's party walked north to find one, and the pull woke Grimlok's group, r6 slot5 step 93), and the stand keeps 29 yd from
+// Grimlok's group.
+func TestUldamanPairStandSeesBothOfThePair(t *testing.T) {
+ dir := mmapsDir()
+ if dir == "" {
+ t.Skip("no map 70 navmesh files: set TURTLE_AGENT_MMAPS to a folder holding 070.mmap and the 070*.mmtile tiles")
+ }
+ p := loadUldaman(t)
+ pl := navmesh.NewPlanner(dir)
+ defer pl.Close()
+ gr := p.Legs[uldamanLeg(t, p, "grimlok")]
+ pair := gr.PullGroups[len(gr.PullGroups)-1]
+ if fmt.Sprint(pair.Entries) != "[4853 4855]" || len(pair.Approach) != 1 {
+ t.Fatalf("the last grimlok group: %+v", pair)
+ }
+ clear := func(from Point) (blocked []Point) {
+ for _, at := range pair.Positions {
+ r, err := pl.Raycast(p.Map, vec(from), vec(at))
+ if errors.Is(err, navmesh.ErrNoEngine) {
+ t.Skip("built without cgo: no navmesh engine")
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !r.Known || !r.Clear {
+ blocked = append(blocked, at)
+ }
+ }
+ return blocked
+ }
+ stand := pair.Approach[0]
+ if b := clear(stand); len(b) != 0 {
+ t.Errorf("no navmesh line from the stand %v to %v", stand, b)
+ }
+ if b := clear(Point{X: 59.7, Y: 408.2, Z: -37.5}); len(b) == 0 {
+ t.Errorf("the old stand 59.7 408.2 has a line to the pair: this test no longer shows why the stand moved")
+ }
+ for _, s := range uldamanSpawns(t) {
+ if s.entry == 4854 || s.entry == 4863 || s.guid == "27530" || s.guid == "27531" {
+ if d := groundDist(stand, s.at); d < 29 {
+ t.Errorf("the stand %v is %.1f yd from %s (entry %d)", stand, d, s.guid, s.entry)
+ }
+ }
+ }
+}
diff --git a/internal/dungeon/zulfarrak_test.go b/internal/dungeon/zulfarrak_test.go
index e0e0486e..eda25a00 100644
--- a/internal/dungeon/zulfarrak_test.go
+++ b/internal/dungeon/zulfarrak_test.go
@@ -136,7 +136,7 @@ func TestZulfarrakPlansLoad(t *testing.T) {
if l.Boss != nil && (l.Boss.Entry == 7607 || slicesContains(l.Boss.First, 7607)) {
t.Errorf("%s/%s: Weegli (7607) must not be killed: he blows the End Door", pl.Name, l.Name)
}
- if l.Boss != nil && l.Boss.Entry >= 7604 && l.Boss.Entry <= 7608 && (pl != p || l.Name != "bly" || l.Boss.Entry != 7604 || len(l.Steps) < 2 || l.Steps[1].Gossip == nil || l.Steps[1].Gossip.NPC != 7604) {
+ if l.Boss != nil && l.Boss.Entry >= 7604 && l.Boss.Entry <= 7608 && (pl != p || l.Name != "bly" || l.Boss.Entry != 7604 || len(l.Steps) < 3 || l.Steps[2].Gossip == nil || l.Steps[2].Gossip.NPC != 7604) {
t.Errorf("%s/%s: the crew is a boss only in the bly leg, after Bly's gossip", pl.Name, l.Name)
}
if l.Boss != nil && l.Boss.Entry == 7271 && (pl != p || l.Name != "zumrah" || len(l.Steps) == 0 || l.Steps[len(l.Steps)-1].AreaTrigger == nil) {
@@ -162,7 +162,8 @@ func TestZulfarrakPlansLoad(t *testing.T) {
}
// The stairs event in order: the cage, the hold at the top, wave 3 at the foot of the stairs (Nekrum first, then Sezz'ziz), then Weegli
-// (gossip, gone, the End Door open) BEFORE Bly (gossip, hostile, the crew), then Ukorz with Ruuzlu first.
+// (seen at his place with the run leader's hold on the pyramid phase 8, the gossip by option number 0, the End Door open) BEFORE Bly
+// (Weegli gone, Bly seen, gossip, hostile, the crew), then Ukorz with Ruuzlu first.
func TestZulfarrakStairsEventOrder(t *testing.T) {
p := loadPlan(t, zulfarrakPlan)
var names []string
@@ -176,13 +177,25 @@ func TestZulfarrakStairsEventOrder(t *testing.T) {
t.Errorf("wave 3: Nekrum first, Sezz'ziz the boss: %+v", b)
}
w := zfLeg(t, p, "weegli_door")
- if len(w.Steps) != 4 || w.Steps[1].Gossip == nil || w.Steps[1].Gossip.NPC != 7607 || w.Steps[1].Gossip.By != "tank" ||
- w.Steps[2].WaitFor == nil || w.Steps[2].WaitFor.Unit == nil || w.Steps[2].WaitFor.Unit.State != "gone" ||
- w.Steps[3].WaitFor == nil || w.Steps[3].WaitFor.GameObject == nil || w.Steps[3].WaitFor.GameObject.Entry != 146084 {
+ if len(w.Steps) != 3 || w.Steps[0].WaitFor == nil || w.Steps[0].WaitFor.Unit == nil || w.Steps[0].WaitFor.Unit.State != "seen" ||
+ w.Steps[1].Gossip == nil || w.Steps[1].Gossip.NPC != 7607 || w.Steps[1].Gossip.By != "tank" ||
+ w.Steps[2].WaitFor == nil || w.Steps[2].WaitFor.GameObject == nil || w.Steps[2].WaitFor.GameObject.Entry != 146084 {
t.Errorf("weegli_door: %+v", w.Steps)
}
+ // Weegli's menu has one line (zulfarrak.cpp:497): option 0, by number so that the run leader can send it; the wait before it carries the
+ // run leader's hold on the pyramid phase (instance data 1) reading 8, PYRAMID_KILLED_ALL_TROLLS, the value that makes his menu offer it.
+ if g := w.Steps[1].Gossip; g.Option == nil || *g.Option != 0 || g.Text != "" {
+ t.Errorf("Weegli's gossip is option 0, not a text: %+v", g)
+ }
+ if h := w.Steps[0].WaitFor.Hold; h == nil || h.Data != 1 || h.Value == nil || *h.Value != 8 || h.TimeoutS != 600 {
+ t.Errorf("weegli_door's hold: %+v", h)
+ }
y := zfLeg(t, p, "bly")
- if y.Steps[1].Gossip == nil || y.Steps[1].Gossip.NPC != 7604 || y.Boss == nil || y.Boss.Entry != 7604 || fmt.Sprint(y.Boss.First) != "[7608 7606 7605]" {
+ if len(y.Steps) != 4 || y.Steps[0].WaitFor == nil || y.Steps[0].WaitFor.Unit == nil || y.Steps[0].WaitFor.Unit.State != "gone" ||
+ y.Steps[0].WaitFor.Unit.Entries[0] != 7607 {
+ t.Errorf("bly begins by waiting for Weegli to be gone (a second DestroyDoor plants a second barrel): %+v", y.Steps)
+ }
+ if y.Steps[2].Gossip == nil || y.Steps[2].Gossip.NPC != 7604 || y.Boss == nil || y.Boss.Entry != 7604 || fmt.Sprint(y.Boss.First) != "[7608 7606 7605]" {
t.Errorf("bly: %+v %+v", y.Steps, y.Boss)
}
if u := zfLeg(t, p, "ukorz").Boss; u == nil || u.Entry != 7267 || fmt.Sprint(u.First) != "[7797]" {
@@ -190,6 +203,139 @@ func TestZulfarrakStairsEventOrder(t *testing.T) {
}
}
+// The pool-stairs fix of zf-full-19z16 (RUN-LESSONS, STUCK #2): the last two packs of the west road are pulled from approach points on the
+// lower floor, west of the stair mouth. The stair base at 1629 1099 z 9.7 is an island on the 209 navmesh (a path to it ends 8.8 yd short),
+// and followers sent there to gather went up to z 31-46.8 before the approach rows. The last point of each approach is within the run
+// leader's reach of the pack (RunLeaderReachYd), and no point is on the upper level or near the island.
+func TestZulfarrakPoolStairsApproach(t *testing.T) {
+ p := loadPlan(t, zulfarrakPlan)
+ v := zfLeg(t, p, "velratha")
+ if len(v.PullGroups) != 6 {
+ t.Fatalf("%d pull groups", len(v.PullGroups))
+ }
+ stair := Point{1629.2, 1099, 9.7}
+ for _, tc := range []struct {
+ group int
+ points []Point // the approach rows, the last is where the party stands to pull
+ packAt Point
+ }{
+ // Group 4: south of the island Shadowhunter 81610 (leave_clear_test.go), then up the east side; the first point keeps a party that stands
+ // on the west lane after the lone Blood Drinker from taking the short way, which passes 0.8 yd from 81610.
+ {4, []Point{{1594, 1011, 10.9}, {1598, 1028, 9.6}, {1606, 1040, 10.3}}, Point{1576.2, 1079.1, 9}},
+ {5, []Point{{1588, 1080, 9.1}, {1584, 1088, 9.1}}, Point{1601.7, 1104.4, 9}},
+ } {
+ g := v.PullGroups[tc.group]
+ if fmt.Sprint(g.Approach) != fmt.Sprint(tc.points) {
+ t.Errorf("pull group %d approach %v, want %v", tc.group, g.Approach, tc.points)
+ continue
+ }
+ last := tc.points[len(tc.points)-1]
+ if d := dist3(last, tc.packAt); d > RunLeaderReachYd {
+ t.Errorf("pull group %d: the last approach point is %.1f yd from the pack (at most %d)", tc.group, d, RunLeaderReachYd)
+ }
+ for _, a := range g.Approach {
+ if a.Z > 15 {
+ t.Errorf("pull group %d: approach point %v is on the upper level", tc.group, a)
+ }
+ if d := dist3(a, stair); d < 20 {
+ t.Errorf("pull group %d: approach point %v is %.1f yd from the stair island", tc.group, a, d)
+ }
+ }
+ // Where the party stands to pull must not pull the pack by itself (cold review 2026-10-21: 1582 1070 stood 10.8 yd from the
+ // Witch Doctor, 1593.6 1089.9 16.6 yd from a Shadowhunter): at least approachClearYd from every member of the group, at its
+ // spawn and, for the patrol, at every waypoint less the follower's offset.
+ for _, m := range zfGroupMembers(t, g) {
+ if d := groundDist(last, m.at) - m.slack; d < approachClearYd {
+ t.Errorf("pull group %d: the last approach point %v is %.1f yd from %s", tc.group, last, d, m.what)
+ } else {
+ t.Logf("pull group %d: %v is %.1f yd from %s", tc.group, last, d, m.what)
+ }
+ }
+ }
+}
+
+// approachClearYd is how far the run leader's stand before a pull keeps from every member of the pack (a pack's aggro reach at level 46
+// against the party is under 20 yd).
+const approachClearYd = 20
+
+// zfPatrol81587 is the waypoint path of the Sul'lithuz Sandcrawler 81587 (tortoise-wow sql/base creature_movement, 2026-10-21), the
+// patrol leader of velratha pull group 4; its followers 81588 and 81601 walk at 3.46 and 5.34 yd from it (creature_groups dist).
+var zfPatrol81587 = []Point{{1586.05, 1087.71, 8.88}, {1569.59, 1052.09, 8.88}, {1563.27, 1014.31, 8.88}, {1574.27, 1065.17, 8.88}, {1597.99, 1104.79, 8.88}, {1622.52, 1131.7, 8.88}}
+
+const zfPatrolFollowYd = 5.34
+
+type zfMember struct {
+ what string
+ at Point
+ slack float64 // yards to take off the distance: a wanderer's wander radius, a follower's offset from its leader
+}
+
+// zfGroupMembers are the spawns of the world export that a pull group stands for (its entries, within 3 yd of one of its positions), each
+// at its spawn (less its wander radius), and a patrol leader's waypoints (less the followers' offset).
+func zfGroupMembers(t *testing.T, g PullGroup) []zfMember {
+ t.Helper()
+ var out []zfMember
+ for _, m := range zfTSV(t, "creatures.tsv") {
+ f := func(k string) float64 { v, _ := strconv.ParseFloat(m[k], 64); return v }
+ at := Point{float32(f("x")), float32(f("y")), float32(f("z"))}
+ mine := false
+ for i, pos := range g.Positions {
+ if strconv.Itoa(int(g.Entries[i])) == m["entry"] && groundDist(pos, at) < 3 {
+ mine = true
+ }
+ }
+ if !mine {
+ continue
+ }
+ out = append(out, zfMember{fmt.Sprintf("%s %s (spawn %s)", m["entry"], m["name"], m["guid"]), at, f("wander")})
+ if m["guid"] == "81587" {
+ for i, w := range zfPatrol81587 {
+ out = append(out, zfMember{fmt.Sprintf("the patrol of 81587 at waypoint %d (with its followers)", i+1), w, zfPatrolFollowYd})
+ }
+ }
+ }
+ if len(out) < len(g.Positions) {
+ t.Fatalf("only %d of the group's %d positions are spawns of the export", len(out), len(g.Positions))
+ }
+ return out
+}
+
+func groundDist(a, b Point) float64 { return math.Hypot(float64(a.X-b.X), float64(a.Y-b.Y)) }
+
+// The gong step rings the real Gong of Zul'Farrak (141832, the type-10 goober with lock 459 and event 2488), never the Turtle decoration copy
+// 20901 at the same place, with the Mallet used ON it (via_item) from within use range; the optional gate is the Mallet, and the leg waits for
+// Gahz'rilla before its boss fight.
+func TestZulfarrakGongStep(t *testing.T) {
+ p := loadPlan(t, zulfarrakPlan)
+ g := zfLeg(t, p, "gahzrilla")
+ if g.Optional == nil || g.Optional.Item != 9240 || g.Optional.Name != "Mallet of Zul'Farrak" {
+ t.Fatalf("optional %+v", g.Optional)
+ }
+ if len(g.Steps) != 2 {
+ t.Fatalf("%d steps", len(g.Steps))
+ }
+ u := g.Steps[0].UseObject
+ near := Point{1650.9, 1171.9, 10.9}
+ if u == nil || u.Entry != 141832 || !u.ViaItem || u.RequiresItem == nil || u.RequiresItem.Entry != 9240 || u.Near == nil || *u.Near != near {
+ t.Fatalf("gong step %+v", u)
+ }
+ if u.Stand == nil || dist3(*u.Stand, near) > UseRange {
+ t.Errorf("the stand %v is not within %.0f yd of the gong: the item's spell has a short range", u.Stand, UseRange)
+ }
+ w := g.Steps[1].WaitFor
+ if w == nil || w.Unit == nil || fmt.Sprint(w.Unit.Entries) != "[7273]" || w.Unit.State != UnitSeen || w.TimeoutS != 30 {
+ t.Errorf("the wait for Gahz'rilla %+v", w)
+ }
+ for _, s := range g.Steps {
+ if r := objRef(s); r != nil && r.Entry == 20901 {
+ t.Errorf("a step targets the decoration copy 20901, not the real gong 141832")
+ }
+ }
+ if g.Boss == nil || g.Boss.Entry != 7273 {
+ t.Errorf("boss %+v", g.Boss)
+ }
+}
+
// Every pack member and boss the plans name is a spawn of the export at that place, and the Troll Cage and the Gong are objects of it.
// What the instance script summons (Gahz'rilla, Nekrum, Sezz'ziz, the waves) has no row and is not looked for.
func TestZulfarrakPlansMatchTheWorldExport(t *testing.T) {
diff --git a/internal/minimap/extract.go b/internal/minimap/extract.go
index 60b2a9d0..e713c534 100644
--- a/internal/minimap/extract.go
+++ b/internal/minimap/extract.go
@@ -69,7 +69,23 @@ type Layer struct {
Kind string
Floor string // "" for the main picture, else the area suffix ("2f")
Meta Meta
- Img image.Image
+ // Img is the picture. Run clears it after writing the files, so a run over every instance holds only the metadata;
+ // ListOnly keeps the pictures.
+ Img image.Image
+}
+
+// Label is the layer's short name in the --list table: its kind, what a minimap is made of, and its floor. A minimap
+// with no "from" in its JSON is the old terrain-only picture.
+func (l Layer) Label() string {
+ s := l.Kind
+ if l.Kind == KindMinimap {
+ from := l.Meta.From
+ if from == "" {
+ from = fromTerrain
+ }
+ s += "(" + from + ")"
+ }
+ return s + l.Floor
}
// Result is what became of one map.
@@ -201,43 +217,62 @@ func Run(o Options) ([]Result, error) {
var out []Result
for _, r := range want {
res := Result{ID: r.ID, Name: r.Name, Dir: r.Dir, Type: r.InstanceType, Kind: KindNone}
+ reasons := map[string]string{} // the reason of every source the map has no image for, for .status.json
var notes []string
have := map[string][]Layer{}
- if tiles := trs[strings.ToLower(r.Dir)]; len(tiles) > 0 {
- img, meta, err := stitch(set, tiles)
- switch {
- case err != nil:
+ // The navmesh serves twice: its world box drops WMO placements that stand far outside the map (Frostmane
+ // Hollow's unrelated mini-hives), and its render is the navmesh layer.
+ var polys []NavPoly
+ var navTiles int
+ var navErr error
+ if len(o.MmapsDirs) > 0 {
+ polys, navTiles, navErr = LoadMapPolys(o.MmapsDirs, r.ID)
+ }
+ var nb *navBounds
+ if navErr == nil {
+ nb = navBoundsOf(polys)
+ }
+ fail := func(err error) {
+ if err != nil && res.Error == nil {
res.Error = err
- case isBlank(img):
- notes = append(notes, "minimap tiles are blank")
- default:
- have[KindMinimap] = []Layer{{Kind: KindMinimap, Meta: meta, Img: img}}
}
+ }
+ if l, _, ok, reason := minimapSource(set, trs, r.Dir, r.ID, nb, logf); ok {
+ have[KindMinimap] = []Layer{l}
} else {
- notes = append(notes, "no minimap tiles")
+ reasons[KindMinimap] = reason
+ notes = append(notes, reason)
}
if ls, err := worldMaps(set, wma, r.ID); err != nil {
- res.Error = err
+ fail(err)
+ reasons[KindWorldMap] = err.Error()
+ notes = append(notes, err.Error())
} else if len(ls) > 0 {
have[KindWorldMap] = ls
} else {
- notes = append(notes, "no world map picture")
+ reasons[KindWorldMap] = noWorldMapReason
+ notes = append(notes, noWorldMapReason)
}
- if len(o.MmapsDirs) > 0 {
- polys, n, perr := LoadMapPolys(o.MmapsDirs, r.ID)
- switch {
- case perr != nil:
- res.Error = perr
- case n == 0:
- notes = append(notes, "no mmaps")
- default:
- nimg, nmeta, rerr := RenderNavmesh(polys, 2, 4096)
- if rerr != nil {
- res.Error = rerr
- } else {
- nmeta.Tiles = n
- have[KindNavmesh] = []Layer{{Kind: KindNavmesh, Meta: nmeta, Img: nimg}}
- }
+ switch {
+ case len(o.MmapsDirs) == 0:
+ reasons[KindNavmesh] = noMmapsReason
+ notes = append(notes, noMmapsReason)
+ case navErr != nil:
+ fail(navErr)
+ reasons[KindNavmesh] = navErr.Error()
+ notes = append(notes, navErr.Error())
+ case navTiles == 0:
+ reasons[KindNavmesh] = "no navmesh picture: the mmaps folder has no tile for this map"
+ notes = append(notes, reasons[KindNavmesh])
+ default:
+ nimg, nmeta, rerr := RenderNavmesh(polys, 2, 4096)
+ if rerr != nil {
+ fail(rerr)
+ reasons[KindNavmesh] = rerr.Error()
+ notes = append(notes, rerr.Error())
+ } else {
+ nmeta.Tiles = navTiles
+ have[KindNavmesh] = []Layer{{Kind: KindNavmesh, Meta: nmeta, Img: nimg}}
}
}
for _, k := range prefer {
@@ -270,10 +305,17 @@ func Run(o Options) ([]Result, error) {
res.Error = err
}
}
+ if err := writeStatus(o.OutDir, r.ID, reasons); err != nil {
+ res.Error = err
+ }
+ // The files are the product; the pixels of a map this big must not all stay in memory until the end.
+ for i := range res.Layers {
+ res.Layers[i].Img = nil
+ }
}
var kinds []string
for _, l := range res.Layers {
- kinds = append(kinds, l.Kind+l.Floor)
+ kinds = append(kinds, l.Label())
}
logf("map %d %s [%s]: main %s, images %s %s", r.ID, r.Name, r.Dir, res.Kind, strings.Join(kinds, ","), res.Note)
out = append(out, res)
@@ -281,29 +323,94 @@ func Run(o Options) ([]Result, error) {
return out, nil
}
+// One-line reasons for the sources a map often lacks, in the wording the dashboard uses when no status file says more.
+const (
+ noWorldMapReason = "no world map picture: the client has no WorldMapArea row for this map"
+ noMmapsReason = "no navmesh picture: minimap-extract ran without --mmaps"
+)
+
+// statusFile is the dashboard's .status.json contract: a reason per source that has no picture.
+type statusFile map[string]statusReason
+
+type statusReason struct {
+ Reason string `json:"reason"`
+}
+
+// writeStatus writes .status.json when a source is missing and removes a stale one when every source exists.
+func writeStatus(dir string, id uint32, reasons map[string]string) error {
+ p := filepath.Join(dir, strconv.FormatUint(uint64(id), 10)+".status.json")
+ if len(reasons) == 0 {
+ if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ return nil
+ }
+ sf := statusFile{}
+ for _, k := range []string{KindMinimap, KindWorldMap, KindNavmesh} {
+ if r := oneLine(reasons[k]); r != "" {
+ sf[k] = statusReason{Reason: r}
+ }
+ }
+ j, err := json.MarshalIndent(sf, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(p, append(j, '\n'), 0o644)
+}
+
// isBlank reports whether an image has next to nothing on it: at least 98 % of the pixels one colour or transparent.
+// It keeps at most 16 candidate colours (a Misra-Gries summary finds every colour above 1/17 of the pixels, and 98 %
+// is far above that), so a large composed minimap costs no map of all its colours.
func isBlank(img image.Image) bool {
b := img.Bounds()
- counts := map[color.NRGBA]int{}
- n := 0
- for y := b.Min.Y; y < b.Max.Y; y++ {
- for x := b.Min.X; x < b.Max.X; x++ {
- c := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
- if c.A == 0 {
- continue
+ scan := func(f func(color.NRGBA) bool) {
+ for y := b.Min.Y; y < b.Max.Y; y++ {
+ for x := b.Min.X; x < b.Max.X; x++ {
+ c := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
+ if c.A == 0 {
+ continue
+ }
+ if !f(c) {
+ return
+ }
}
- counts[c]++
- n++
}
}
- if n == 0 {
+ cand := map[color.NRGBA]int{}
+ seen := 0
+ scan(func(c color.NRGBA) bool {
+ seen++
+ if _, ok := cand[c]; ok {
+ cand[c]++
+ } else if len(cand) < 16 {
+ cand[c] = 1
+ } else {
+ for k, n := range cand {
+ n--
+ if n <= 0 {
+ delete(cand, k)
+ } else {
+ cand[k] = n
+ }
+ }
+ }
+ return true
+ })
+ if seen == 0 {
return true
}
top := 0
- for _, v := range counts {
- top = max(top, v)
+ for c := range cand {
+ n := 0
+ scan(func(p color.NRGBA) bool {
+ if p == c {
+ n++
+ }
+ return true
+ })
+ top = max(top, n)
}
- return float64(top) >= 0.98*float64(n)
+ return float64(top) >= 0.98*float64(seen)
}
func baseNames(ps []string) []string {
diff --git a/internal/minimap/minimap_test.go b/internal/minimap/minimap_test.go
index fb16e7c9..123a3b71 100644
--- a/internal/minimap/minimap_test.go
+++ b/internal/minimap/minimap_test.go
@@ -72,9 +72,14 @@ func TestBoundsAndPixelTransform(t *testing.T) {
}
func TestParseTranslate(t *testing.T) {
- in := "dir: Azeroth\nAzeroth\\map32_48.blp\tabc123.blp\n\nZulFarrak\\map29_27.blp\tf00d.blp\r\nZULFARRAK\\map30_28.blp\t0bad.blp\nbroken line\nx\\notamap.blp\tz.blp\n"
+ in := "dir: Azeroth\nAzeroth\\map32_48.blp\tabc123.blp\n\nZulFarrak\\map29_27.blp\tf00d.blp\r\nZULFARRAK\\map30_28.blp\t0bad.blp\nbroken line\nx\\notamap.blp\tz.blp\n" +
+ "WMO\\World\\wmo\\Dungeon\\kz_uldaman_b_006_03_00.blp\tw1.blp\n" +
+ "wmo/world/wmo/dungeon/stratholme_b_012_04_01.blp\tw2.blp\n" +
+ "WMO\\World\\wmo\\Dungeon\\mini-hive_d_000_00_00.blp\tw3.blp\n" +
+ "WMO\\World\\wmo\\Dungeon\\bad_0_0.blp\tw4.blp\n" +
+ "WMO\\World\\wmo\\Dungeon\\sign_+03_00_00.blp\tw5.blp\n"
got := ParseTranslate([]byte(in))
- zf := got["zulfarrak"]
+ zf := got.Terrain["zulfarrak"]
if len(zf) != 2 {
t.Fatalf("zulfarrak tiles = %v", zf)
}
@@ -82,8 +87,27 @@ func TestParseTranslate(t *testing.T) {
if zf[Tile{Row: 27, Col: 29}] != "f00d.blp" || zf[Tile{Row: 28, Col: 30}] != "0bad.blp" {
t.Errorf("tiles = %v", zf)
}
- if got["azeroth"][Tile{Row: 48, Col: 32}] != "abc123.blp" {
- t.Errorf("azeroth = %v", got["azeroth"])
+ if got.Terrain["azeroth"][Tile{Row: 48, Col: 32}] != "abc123.blp" {
+ t.Errorf("azeroth = %v", got.Terrain["azeroth"])
+ }
+ if len(got.Terrain["wmo"]) != 0 {
+ t.Errorf("a WMO tile landed in the terrain tiles: %v", got.Terrain["wmo"])
+ }
+ // the base name keeps its underscores; the group, A and B are the last three fixed-width fields
+ if got.WMO["kz_uldaman_b"][WMOTile{Group: 6, A: 3, B: 0}] != "w1.blp" {
+ t.Errorf("kz_uldaman_b = %v", got.WMO["kz_uldaman_b"])
+ }
+ if got.WMO["stratholme_b"][WMOTile{Group: 12, A: 4, B: 1}] != "w2.blp" {
+ t.Errorf("stratholme_b = %v", got.WMO["stratholme_b"])
+ }
+ if got.WMO["mini-hive_d"][WMOTile{Group: 0, A: 0, B: 0}] != "w3.blp" {
+ t.Errorf("mini-hive_d = %v", got.WMO["mini-hive_d"])
+ }
+ if len(got.WMO["bad"]) != 0 {
+ t.Errorf("a tile without the fixed-width numbers is accepted: %v", got.WMO["bad"])
+ }
+ if len(got.WMO["sign"]) != 0 {
+ t.Errorf("a signed field is accepted: %v", got.WMO["sign"])
}
}
diff --git a/internal/minimap/testdata/wmo-fixture.png b/internal/minimap/testdata/wmo-fixture.png
new file mode 100644
index 00000000..7ef887f4
Binary files /dev/null and b/internal/minimap/testdata/wmo-fixture.png differ
diff --git a/internal/minimap/tiles.go b/internal/minimap/tiles.go
index 8c776735..e3d6ab6a 100644
--- a/internal/minimap/tiles.go
+++ b/internal/minimap/tiles.go
@@ -19,6 +19,18 @@ const TilePx = 256
// mmtiles ; the client names the minimap tile map _.blp.
type Tile struct{ Row, Col int }
+// WMOTile is one minimap tile of one group of a WMO: the group index from the WMO's MOGI, the column A along the group's
+// local x and the row B along its local y, counted up from the group bounding box minimum. The client names such a tile
+// WMO\...\ ___.blp.
+type WMOTile struct{ Group, A, B int }
+
+// Translate is md5translate.trs: the terrain tiles of every map folder and the WMO tiles of every WMO, each keyed by the
+// lower-case base name of the WMO file.
+type Translate struct {
+ Terrain map[string]map[Tile]string
+ WMO map[string]map[WMOTile]string
+}
+
// TileOf is the tile that holds a world position (x north, y west).
func TileOf(x, y float64) Tile {
return Tile{int(math.Floor(32 - x/TileSize)), int(math.Floor(32 - y/TileSize))}
@@ -38,6 +50,12 @@ type Meta struct {
West float64 `json:"west"` // world y of the left edge
East float64 `json:"east"` // world y of the right edge
Tiles int `json:"tiles,omitempty"`
+ // From says what a minimap picture is made of: "terrain" (the map's terrain tiles), "wmo" (the client's indoor
+ // minimap tiles of the WMO groups) or "terrain+wmo" (the WMO tiles drawn over the terrain). Empty for the other
+ // sources.
+ From string `json:"from,omitempty"`
+ // Note is a one-line known limit of the picture (a client-data gap), for humans; readers ignore unknown keys.
+ Note string `json:"note,omitempty"`
}
// Bounds fills the world edges for tiles rows r0..r1 and columns c0..c1 (inclusive) drawn at px pixels per tile.
@@ -61,11 +79,12 @@ func (m Meta) ToWorld(px, py float64) (x, y float64) {
return m.North - py/m.PxPerYard, m.West - px/m.PxPerYard
}
-// ParseTranslate reads textures\Minimap\md5translate.trs: lines "\map_.blp.blp" (a "dir:" line
-// starts each map's block in some builds; it is skipped). The result maps a map's folder (lower case) to its tiles' file
-// names in Textures\Minimap.
-func ParseTranslate(b []byte) map[string]map[Tile]string {
- out := map[string]map[Tile]string{}
+// ParseTranslate reads textures\Minimap\md5translate.trs: lines "\map_.blp.blp" for the terrain
+// tiles, and "\ ___.blp.blp" for the WMO minimap tiles (a "dir:" line starts each
+// map's block in some builds; it is skipped). Terrain tiles map a map's folder (lower case) to its tiles' file names in
+// Textures\Minimap; WMO tiles map a WMO's lower-case base file name to its tiles.
+func ParseTranslate(b []byte) Translate {
+ out := Translate{Terrain: map[string]map[Tile]string{}, WMO: map[string]map[WMOTile]string{}}
sc := bufio.NewScanner(bytes.NewReader(b))
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
@@ -75,21 +94,73 @@ func ParseTranslate(b []byte) map[string]map[Tile]string {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
+ if v == "" {
+ continue
+ }
i := strings.LastIndexAny(k, `\/`)
if i < 0 {
continue
}
dir, file := strings.ToLower(k[:i]), strings.ToLower(k[i+1:])
+ if lk := strings.ToLower(k); strings.HasPrefix(lk, `wmo\`) || strings.HasPrefix(lk, "wmo/") {
+ base, t, ok := parseWMOTile(file)
+ if !ok {
+ continue
+ }
+ if out.WMO[base] == nil {
+ out.WMO[base] = map[WMOTile]string{}
+ }
+ out.WMO[base][t] = v
+ continue
+ }
var c, r int // map_: A counts along world y, B along world x (checked against the plans' coordinates)
if _, err := fmt.Sscanf(file, "map%d_%d.blp", &c, &r); err != nil {
continue
}
- if out[dir] == nil {
- out[dir] = map[Tile]string{}
+ if out.Terrain[dir] == nil {
+ out.Terrain[dir] = map[Tile]string{}
}
- out[dir][Tile{r, c}] = v
+ out.Terrain[dir][Tile{r, c}] = v
}
return out
}
+// parseWMOTile splits ___.blp. The base may hold underscores itself, so the last three fields are the
+// numbers, each with its fixed width (3, 2 and 2 digits, as the client writes them).
+func parseWMOTile(file string) (string, WMOTile, bool) {
+ if !strings.HasSuffix(file, ".blp") {
+ return "", WMOTile{}, false
+ }
+ parts := strings.Split(strings.TrimSuffix(file, ".blp"), "_")
+ if len(parts) < 4 {
+ return "", WMOTile{}, false
+ }
+ n := len(parts)
+ if len(parts[n-3]) != 3 || len(parts[n-2]) != 2 || len(parts[n-1]) != 2 ||
+ !allDigits(parts[n-3]) || !allDigits(parts[n-2]) || !allDigits(parts[n-1]) {
+ return "", WMOTile{}, false
+ }
+ g, err1 := strconv.Atoi(parts[n-3])
+ a, err2 := strconv.Atoi(parts[n-2])
+ bb, err3 := strconv.Atoi(parts[n-1])
+ if err1 != nil || err2 != nil || err3 != nil {
+ return "", WMOTile{}, false
+ }
+ base := strings.Join(parts[:n-3], "_")
+ if base == "" || g < 0 || a < 0 || bb < 0 {
+ return "", WMOTile{}, false
+ }
+ return base, WMOTile{Group: g, A: a, B: bb}, true
+}
+
+// allDigits reports whether s is a non-empty run of ASCII digits (Atoi alone accepts a sign).
+func allDigits(s string) bool {
+ for i := range len(s) {
+ if s[i] < '0' || s[i] > '9' {
+ return false
+ }
+ }
+ return s != ""
+}
+
func itoa(n int) string { return strconv.Itoa(n) }
diff --git a/internal/minimap/wmo.go b/internal/minimap/wmo.go
new file mode 100644
index 00000000..4542fb47
--- /dev/null
+++ b/internal/minimap/wmo.go
@@ -0,0 +1,763 @@
+package minimap
+
+// The minimap the client draws indoors comes from tiles of the WMO's own groups, not from the terrain. md5translate lists
+// them as WMO\...\ ___.blp, and the root WMO's MOGI gives each group's bounding box. This file places
+// those groups (with the WDT or ADT MODF of each WMO) on one north-up picture and draws the tiles over the terrain minimap
+// on the five maps whose indoor part the plan puts over the terrain (W2a's Q-e). The transform was settled against the
+// server's navmesh (W2a): 2 px per yard, a tile index step of 128 yd, bottom-left anchored tiles, a 180-degree turn,
+// th = +rot.y, and all groups drawn by PZ+z0 rising.
+
+import (
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "math"
+ "sort"
+ "strings"
+
+ "github.com/pjerra/turtle-agent/internal/blp"
+ "github.com/pjerra/turtle-agent/internal/mpq"
+)
+
+const (
+ wmoPxPerYd = 2.0 // the client's indoor minimap scale
+ wmoTilePx = 256.0 // a tile index step in pixels at that scale (128 yd)
+ wmoTileYd = wmoTilePx / wmoPxPerYd
+ wmoMapCenter = 17066.666 // the ADT placement convention: 32 * 533.333
+ wmoMaxSide = 8192 // the longest side of a composed picture
+ wmoMaxTiles = 4096 // decoded tiles per map
+ wmoTileBudget = 256 << 20 // decoded tile pixels per map, in bytes (1024 tiles of 256x256)
+ wmoMaxTilePx = 512 // the client's tiles are 256 px at most
+ wmoMaxPlace = 4096 // placements per map
+ wmoMaxADTs = 64 // an instance has at most 36 ADTs; a world map's WDT is not scanned
+ wmoMaxGroups = 4096 // MOGI groups per WMO
+ // wmoMaxDrawables and wmoMaxWork bound the compositor: a group is one drawable, and every tile rectangle costs its
+ // output pixels. A bigger client file gets a reason in .status.json, not a hang.
+ wmoMaxDrawables = 16384
+ wmoMaxWork = 1 << 25 // sampled pixels per map (33.5 M; the largest real map, Dire Maul, samples 16.6 M)
+ // wmoNavMargin is how far (yards) a placement's box may lie outside the map's navmesh bounding box and still be
+ // drawn. Unrelated outdoor WMOs (Frostmane Hollow's Silithus mini-hives) are hundreds of yards away and have no
+ // navmesh; they are dropped.
+ wmoNavMargin = 200.0
+)
+
+const (
+ fromTerrain = "terrain"
+ fromWMO = "wmo"
+ fromTerrainWMO = "terrain+wmo"
+)
+
+// overlayMaps are the maps whose indoor WMO art is drawn over an existing terrain minimap (the plan's Q-e). Every other
+// map with a usable terrain minimap keeps that picture untouched.
+var overlayMaps = map[uint32]bool{33: true, 36: true, 289: true, 329: true, 800: true}
+
+// wmoSkipUIDs are placements that belong to another zone: Frostmane Hollow's two Silithus mini-hives stand on the
+// outdoor surface navmesh, so the navmesh-box rule keeps them, but their art has nothing to do with the dungeon.
+var wmoSkipUIDs = map[uint32]map[uint32]bool{
+ 822: {303286: true, 303232: true},
+}
+
+// wmoKnownLimits are one-line client-data limits of a composed picture, written into its JSON as "note" for humans (the
+// dashboards ignore the key).
+var wmoKnownLimits = map[uint32]string{
+ 189: "Herod's room has navmesh but no client minimap art; the plan point there is off the picture",
+ 819: "part of the WMO has no client minimap art (navmesh areas are drawn red); the entrance is off the picture",
+ 822: "the two Silithus mini-hives (uids 303286 and 303232) belong to another zone and are not drawn",
+}
+
+// navBounds is the world rectangle of a map's navmesh (x north, y west).
+type navBounds struct {
+ North, South, West, East float64
+}
+
+// navBoundsOf is the world bounding box of the polygons, or nil when there are none.
+func navBoundsOf(polys []NavPoly) *navBounds {
+ if len(polys) == 0 {
+ return nil
+ }
+ nb := navBounds{North: -1e18, South: 1e18, West: -1e18, East: 1e18}
+ for _, p := range polys {
+ for _, v := range p.V {
+ nb.North, nb.South = math.Max(nb.North, float64(v[0])), math.Min(nb.South, float64(v[0]))
+ nb.West, nb.East = math.Max(nb.West, float64(v[1])), math.Min(nb.East, float64(v[1]))
+ }
+ }
+ if nb.North <= nb.South || nb.West <= nb.East {
+ return nil
+ }
+ return &nb
+}
+
+// wmoPlacement is one MODF: which WMO stands where. pos and rot are the client's (x east, y up, z north for the ADT
+// convention).
+type wmoPlacement struct {
+ path string
+ uid uint32
+ pos, rot [3]float64
+}
+
+// wmoGroup is one MOGI entry: the bounding box of one WMO group and the minimap tiles md5translate lists for it.
+type wmoGroup struct {
+ index int
+ flags uint32
+ x0 float64
+ y0 float64
+ z0 float64
+ x1 float64
+ y1 float64
+ z1 float64
+ tiles map[[2]int]*image.NRGBA // (A, B) -> picture; B counts up from y0
+}
+
+// wmoModel is one WMO file parsed for its groups and decoded minimap tiles.
+type wmoModel struct {
+ base string
+ groups []*wmoGroup
+ err error
+ listed int // tile lines md5translate lists for the groups
+ decoded int
+ failed int
+}
+
+// wmoBudget bounds what one map composes.
+type wmoBudget struct {
+ tiles int
+ bytes int
+ capped bool
+}
+
+// wmoInfo is what a map's WMO composition measured, for the alignment tests and the reason of a missing picture.
+type wmoInfo struct {
+ ZMin, ZMax float64 // world z of the group boxes drawn (0 when none)
+ Tiles int // tile images drawn
+ Work int64 // output pixels the tile rectangles cover
+ Placements int // MODF entries found (after uid dedup)
+ Excluded int // placements dropped for lying outside the map navmesh
+ WDT bool // the placements come from the WDT (a WMO-only map)
+}
+
+// walkChunks calls f for every chunk of a WMO, WDT or ADT, in file order, and stops at a truncated tail.
+func walkChunks(d []byte, f func(id string, data []byte)) {
+ for len(d) >= 8 {
+ id := string([]byte{d[3], d[2], d[1], d[0]})
+ n := int(binary.LittleEndian.Uint32(d[4:]))
+ if n < 0 || n > len(d)-8 {
+ return
+ }
+ f(id, d[8:8+n])
+ d = d[8+n:]
+ }
+}
+
+func le32(b []byte, o int) uint32 { return binary.LittleEndian.Uint32(b[o:]) }
+
+func f32(b []byte, o int) float64 { return float64(math.Float32frombits(le32(b, o))) }
+
+// splitCStrings splits a MWMO chunk into its NUL-terminated paths.
+func splitCStrings(d []byte) []string {
+ var out []string
+ for _, s := range strings.Split(string(d), "\x00") {
+ if s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func nameAt(names []string, n uint32) string {
+ if int(n) >= len(names) {
+ return ""
+ }
+ return names[n]
+}
+
+// wmoPlacements reads the MODF entries of a map: the WDT's own when the map is WMO-only (MPHD flag 0x1 or MODF entries),
+// else every ADT listed in the WDT MAIN. Duplicates (the same uniqueId in several ADTs) are removed. The bool says the
+// WDT was the source.
+func wmoPlacements(set *mpq.Set, dir string, logf func(string, ...any)) ([]wmoPlacement, bool, error) {
+ wdt, err := set.Read(`World\Maps\` + dir + `\` + dir + `.wdt`)
+ if err != nil {
+ return nil, false, fmt.Errorf("WDT: %w", err)
+ }
+ var mwmo, modf, mainData []byte
+ flags := uint32(0)
+ walkChunks(wdt, func(id string, d []byte) {
+ switch id {
+ case "MPHD":
+ if len(d) >= 4 {
+ flags = le32(d, 0)
+ }
+ case "MWMO":
+ mwmo = append(mwmo, d...)
+ case "MODF":
+ modf = append(modf, d...)
+ case "MAIN":
+ mainData = append(mainData, d...)
+ }
+ })
+ names := splitCStrings(mwmo)
+ var out []wmoPlacement
+ seen := map[uint32]bool{}
+ add := func(p wmoPlacement) {
+ if p.path == "" || seen[p.uid] || len(out) >= wmoMaxPlace {
+ return
+ }
+ seen[p.uid] = true
+ out = append(out, p)
+ }
+ if len(modf) > 0 || flags&0x1 != 0 {
+ for o := 0; o+64 <= len(modf); o += 64 {
+ add(parseMODF(modf[o:o+64], func(n uint32) string { return nameAt(names, n) }))
+ }
+ if len(out) == 0 {
+ logf("map %s: the WDT holds no usable MODF entry", dir)
+ }
+ return out, true, nil
+ }
+ adts := 0
+ for i := range 64 * 64 {
+ if (i+1)*8 > len(mainData) {
+ break
+ }
+ if le32(mainData, i*8) == 0 {
+ continue
+ }
+ adts++
+ if adts > wmoMaxADTs {
+ return nil, false, fmt.Errorf("the WDT lists more than %d ADTs; the WMO scan is limited to instance maps", wmoMaxADTs)
+ }
+ a, err := set.Read(fmt.Sprintf(`World\Maps\%s\%s_%d_%d.adt`, dir, dir, i%64, i/64))
+ if err != nil {
+ continue
+ }
+ var mwmo, modf []byte
+ var mwid []uint32
+ walkChunks(a, func(id string, d []byte) {
+ switch id {
+ case "MWMO":
+ mwmo = append(mwmo, d...)
+ case "MWID":
+ for o := 0; o+4 <= len(d); o += 4 {
+ mwid = append(mwid, le32(d, o))
+ }
+ case "MODF":
+ modf = append(modf, d...)
+ }
+ })
+ names := splitCStrings(mwmo)
+ for o := 0; o+64 <= len(modf); o += 64 {
+ add(parseMODF(modf[o:o+64], func(n uint32) string {
+ if int(n) >= len(mwid) {
+ return ""
+ }
+ return nameAt(names, mwid[n])
+ }))
+ }
+ }
+ return out, false, nil
+}
+
+// parseMODF reads one 64-byte MODF entry: nameId u32 @0, uniqueId u32 @4, pos 3xf32 @8, rot 3xf32 @20.
+func parseMODF(e []byte, name func(uint32) string) wmoPlacement {
+ p := wmoPlacement{path: name(le32(e, 0)), uid: le32(e, 4)}
+ for k := range 3 {
+ p.pos[k] = f32(e, 8+4*k)
+ p.rot[k] = f32(e, 20+4*k)
+ }
+ for _, v := range []float64{p.pos[0], p.pos[1], p.pos[2], p.rot[0], p.rot[1], p.rot[2]} {
+ if math.IsNaN(v) || math.IsInf(v, 0) || math.Abs(v) > 1e6 {
+ return wmoPlacement{}
+ }
+ }
+ return p
+}
+
+// wmoBase is a WMO path's lower-case file name without the extension.
+func wmoBase(path string) string {
+ s := path[strings.LastIndexAny(path, `\/`)+1:]
+ return strings.TrimSuffix(strings.ToLower(s), ".wmo")
+}
+
+// loadWMO reads a WMO root's MOGI and decodes the minimap tiles md5translate lists for it. A missing WMO or a tile that
+// does not decode is logged and skipped; it never ends the run.
+func loadWMO(set *mpq.Set, trs Translate, path string, budget *wmoBudget, logf func(string, ...any)) *wmoModel {
+ base := wmoBase(path)
+ m := &wmoModel{base: base}
+ rb, err := set.Read(path)
+ if err != nil {
+ m.err = err
+ return m
+ }
+ var mogi []byte
+ walkChunks(rb, func(id string, d []byte) {
+ if id == "MOGI" {
+ mogi = append(mogi, d...)
+ }
+ })
+ n := min(len(mogi)/32, wmoMaxGroups)
+ // MOGI group ids are positional: keep a slot for every entry, including a rejected one, so a tile's t.Group still
+ // names its own box.
+ m.groups = make([]*wmoGroup, n)
+ for i := range n {
+ o := i * 32
+ g := &wmoGroup{index: i, flags: le32(mogi, o),
+ x0: f32(mogi, o+4), y0: f32(mogi, o+8), z0: f32(mogi, o+12),
+ x1: f32(mogi, o+16), y1: f32(mogi, o+20), z1: f32(mogi, o+24),
+ tiles: map[[2]int]*image.NRGBA{}}
+ if !saneBox(g) {
+ logf("WMO %s group %d: implausible bounding box; skipped", base, i)
+ continue
+ }
+ m.groups[i] = g
+ }
+ tiles := trs.WMO[base]
+ keys := make([]WMOTile, 0, len(tiles))
+ for t := range tiles {
+ keys = append(keys, t)
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ a, b := keys[i], keys[j]
+ if a.Group != b.Group {
+ return a.Group < b.Group
+ }
+ if a.A != b.A {
+ return a.A < b.A
+ }
+ return a.B < b.B
+ })
+ for _, t := range keys {
+ m.listed++
+ if t.Group < 0 || t.Group >= len(m.groups) || m.groups[t.Group] == nil {
+ m.failed++ // the tile's group was rejected; drop the tile with it
+ continue
+ }
+ if budget.tiles >= wmoMaxTiles || budget.bytes >= wmoTileBudget {
+ if !budget.capped {
+ logf("WMO minimap: the %d tile / %d MiB budget is reached; further tiles are skipped", wmoMaxTiles, wmoTileBudget>>20)
+ budget.capped = true
+ }
+ continue
+ }
+ data, err := set.Read(`Textures\Minimap\` + tiles[t])
+ if err != nil {
+ m.failed++
+ continue // listed but absent from the archives
+ }
+ img, err := decodeWMOTile(data)
+ if err != nil {
+ m.failed++
+ if m.failed <= 5 {
+ logf("WMO %s group %d tile %02d_%02d: %v", base, t.Group, t.A, t.B, err)
+ }
+ continue
+ }
+ budget.tiles++
+ budget.bytes += img.Bounds().Dx() * img.Bounds().Dy() * 4
+ m.decoded++
+ m.groups[t.Group].tiles[[2]int{t.A, t.B}] = img
+ }
+ return m
+}
+
+// saneBox rejects a group bounding box with non-finite or inverted edges.
+func saneBox(g *wmoGroup) bool {
+ for _, v := range []float64{g.x0, g.y0, g.z0, g.x1, g.y1, g.z1} {
+ if math.IsNaN(v) || math.IsInf(v, 0) || math.Abs(v) > 1e6 {
+ return false
+ }
+ }
+ return g.x1 >= g.x0 && g.y1 >= g.y0 && g.z1 >= g.z0
+}
+
+// decodeWMOTile bounds the tile before the decoder allocates its pixels.
+func decodeWMOTile(data []byte) (*image.NRGBA, error) {
+ if len(data) < 20 || string(data[:4]) != "BLP2" {
+ return nil, errors.New("not a BLP2 tile")
+ }
+ w, h := int(le32(data, 12)), int(le32(data, 16))
+ if w <= 0 || h <= 0 || w > wmoMaxTilePx || h > wmoMaxTilePx {
+ return nil, fmt.Errorf("tile size %dx%d is outside the client's range", w, h)
+ }
+ return blp.Decode(data)
+}
+
+// wmoDrawable is one group of one placement, ready to draw.
+type wmoDrawable struct {
+ g *wmoGroup
+ px, py, pz float64
+ th float64 // radians, the yaw
+ wx0, wx1, wy0, wy1 float64
+ zw, zw1 float64 // PZ + z0 (the draw order) and PZ + z1
+}
+
+// world transforms one group-local point (x, y) to world (north, west).
+func (d wmoDrawable) world(x, y float64) (float64, float64) {
+ cs, sn := math.Cos(d.th), math.Sin(d.th)
+ return d.px - (cs*x - sn*y), d.py - (sn*x + cs*y)
+}
+
+// local is the inverse of world.
+func (d wmoDrawable) local(X, Y float64) (float64, float64) {
+ cs, sn := math.Cos(d.th), math.Sin(d.th)
+ dx, dy := X-d.px, Y-d.py
+ return -(cs*dx + sn*dy), -(-sn*dx + cs*dy)
+}
+
+// wmoJob is one tile of one group in output pixels: the tile's local rectangle (clipped to the group box) and the output
+// rectangle it can touch. Drawing only these rectangles keeps the work proportional to the tiles, not to the group box
+// (an outdoor WMO can have a box hundreds of yards across around one small tile).
+type wmoJob struct {
+ d wmoDrawable
+ t *image.NRGBA
+ a, b int
+ x0, y0, x1, y1 float64
+ rect image.Rectangle
+}
+
+// jobs returns the tile rectangles of a drawable at the canvas scale. It returns no job for a tile outside the group box.
+func (d wmoDrawable) jobs(m Meta) []wmoJob {
+ out := make([]wmoJob, 0, len(d.g.tiles))
+ keys := make([][2]int, 0, len(d.g.tiles))
+ for k := range d.g.tiles {
+ keys = append(keys, k)
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ if keys[i][0] != keys[j][0] {
+ return keys[i][0] < keys[j][0]
+ }
+ return keys[i][1] < keys[j][1]
+ })
+ for _, k := range keys {
+ a, b := k[0], k[1]
+ x0 := math.Max(d.g.x0, d.g.x0+float64(a)*wmoTileYd)
+ x1 := math.Min(d.g.x1, d.g.x0+float64(a+1)*wmoTileYd)
+ y0 := math.Max(d.g.y0, d.g.y0+float64(b)*wmoTileYd)
+ y1 := math.Min(d.g.y1, d.g.y0+float64(b+1)*wmoTileYd)
+ if x1 <= x0 || y1 <= y0 {
+ continue
+ }
+ // The tile's own four world corners give the exact rectangle.
+ tn, ts, tw, te := -1e18, 1e18, -1e18, 1e18
+ for _, c := range [4][2]float64{{x0, y0}, {x0, y1}, {x1, y0}, {x1, y1}} {
+ X, Y := d.world(c[0], c[1])
+ tn, ts = math.Max(tn, X), math.Min(ts, X)
+ tw, te = math.Max(tw, Y), math.Min(te, Y)
+ }
+ pc0, pr0 := m.ToPixel(tn, tw)
+ pc1, pr1 := m.ToPixel(ts, te)
+ rect := image.Rect(int(math.Floor(pc0)), int(math.Floor(pr0)), int(math.Ceil(pc1)), int(math.Ceil(pr1)))
+ rect = rect.Intersect(image.Rect(0, 0, m.Width, m.Height))
+ if rect.Empty() {
+ continue
+ }
+ out = append(out, wmoJob{d: d, t: d.g.tiles[k], a: a, b: b, x0: x0, y0: y0, x1: x1, y1: y1, rect: rect})
+ }
+ return out
+}
+
+// draw paints one tile rectangle. The inverse of the world transform lands each output pixel in the tile's local grid;
+// the pixel is drawn only when it is opaque (alpha >= 128).
+func (j wmoJob) draw(img *image.NRGBA, m Meta) {
+ for r := j.rect.Min.Y; r < j.rect.Max.Y; r++ {
+ for c := j.rect.Min.X; c < j.rect.Max.X; c++ {
+ X, Y := m.ToWorld(float64(c)+.5, float64(r)+.5)
+ x, y := j.d.local(X, Y)
+ if x < j.x0 || x > j.x1 || y < j.y0 || y > j.y1 {
+ continue
+ }
+ U := (x - j.d.g.x0) * wmoPxPerYd
+ V := (y - j.d.g.y0) * wmoPxPerYd
+ u := int(U - float64(j.a)*wmoTilePx)
+ v := j.t.Bounds().Dy() - 1 - int(V-float64(j.b)*wmoTilePx)
+ if u < 0 || v < 0 || u >= j.t.Bounds().Dx() || v >= j.t.Bounds().Dy() {
+ continue
+ }
+ px := j.t.NRGBAAt(u, v)
+ if px.A < 128 {
+ continue
+ }
+ px.A = 255
+ img.SetNRGBA(c, r, px)
+ }
+ }
+}
+
+// placementNearNav reports whether a placement's box intersects the navmesh rectangle grown by wmoNavMargin. A placement
+// with no tiles counts as near (it draws nothing).
+func placementNearNav(m *wmoModel, px, py, th float64, nav navBounds) bool {
+ n, s, w, e := -1e18, 1e18, -1e18, 1e18
+ any := false
+ d := wmoDrawable{px: px, py: py, th: th}
+ for _, g := range m.groups {
+ if g == nil || len(g.tiles) == 0 {
+ continue
+ }
+ any = true
+ for _, c := range [4][2]float64{{g.x0, g.y0}, {g.x0, g.y1}, {g.x1, g.y0}, {g.x1, g.y1}} {
+ X, Y := d.world(c[0], c[1])
+ n, s = math.Max(n, X), math.Min(s, X)
+ w, e = math.Max(w, Y), math.Min(e, Y)
+ }
+ }
+ if !any {
+ return true
+ }
+ return n >= nav.South-wmoNavMargin && s <= nav.North+wmoNavMargin &&
+ w >= nav.East-wmoNavMargin && e <= nav.West+wmoNavMargin
+}
+
+// minimapSource builds a map's minimap picture: its terrain tiles, the minimap tiles of the WMOs placed on it drawn over
+// them on the five overlay maps, or the WMO tiles alone when there is no terrain picture. ok is false with a one-line
+// reason when it has neither.
+func minimapSource(set *mpq.Set, trs Translate, dir string, id uint32, nav *navBounds, logf func(string, ...any)) (Layer, wmoInfo, bool, string) {
+ var info wmoInfo
+ terrainTiles := trs.Terrain[strings.ToLower(dir)]
+ hasTerrain := len(terrainTiles) > 0
+ var terrain *Layer
+ terrainReason := ""
+ blank := false
+ if hasTerrain {
+ img, meta, err := stitch(set, terrainTiles)
+ switch {
+ case err != nil:
+ terrainReason = err.Error()
+ logf("map %s: terrain minimap: %v", dir, err)
+ case isBlank(img):
+ blank = true
+ logf("map %s: the terrain minimap tiles are blank; the WMO tiles alone are used", dir)
+ default:
+ if !overlayMaps[id] {
+ // This map's terrain minimap is already the picture: leave it exactly as the old extractor wrote it.
+ return Layer{Kind: KindMinimap, Meta: meta, Img: img}, info, true, ""
+ }
+ meta.From = fromTerrain
+ terrain = &Layer{Kind: KindMinimap, Meta: meta, Img: img}
+ }
+ }
+ layer, info, wmoReason := composeWMO(set, trs, dir, terrain, nav, wmoSkipUIDs[id], logf)
+ if layer != nil {
+ if terrain == nil && isBlank(layer.Img) {
+ wmoReason = "the composed WMO minimap is blank"
+ } else {
+ if note := wmoKnownLimits[id]; note != "" {
+ layer.Meta.Note = note
+ }
+ return *layer, info, true, ""
+ }
+ }
+ if terrain != nil {
+ if wmoReason != "" {
+ logf("map %s: no WMO minimap over the terrain: %s", dir, wmoReason)
+ }
+ if note := wmoKnownLimits[id]; note != "" {
+ terrain.Meta.Note = note
+ }
+ return *terrain, info, true, ""
+ }
+ var parts []string
+ if terrainReason != "" {
+ parts = append(parts, "terrain: "+terrainReason)
+ }
+ if wmoReason != "" {
+ switch {
+ case info.Placements == 0:
+ wmoReason = "no WMO placement on this map"
+ case info.WDT:
+ wmoReason = "indoor dungeon (A: WMO-only map): " + wmoReason
+ case blank:
+ wmoReason = "indoor dungeon (B: blank terrain tiles): " + wmoReason
+ case !hasTerrain:
+ wmoReason = "indoor dungeon (C: no terrain tiles): " + wmoReason
+ }
+ parts = append(parts, wmoReason)
+ }
+ if len(parts) == 0 {
+ parts = append(parts, "no minimap tiles")
+ }
+ return Layer{}, info, false, oneLine(strings.Join(parts, "; "))
+}
+
+// composeWMO draws every WMO group that has tiles, the terrain picture under them when under is not nil, and returns the
+// picture plus what it measured. A terrain picture is never changed: when nothing could be drawn the caller keeps it.
+func composeWMO(set *mpq.Set, trs Translate, dir string, under *Layer, nav *navBounds, skip map[uint32]bool, logf func(string, ...any)) (*Layer, wmoInfo, string) {
+ var info wmoInfo
+ places, wdt, err := wmoPlacements(set, dir, logf)
+ if err != nil {
+ return nil, info, err.Error()
+ }
+ info.Placements, info.WDT = len(places), wdt
+ if len(places) == 0 {
+ return nil, info, "the client places no WMO on this map"
+ }
+ budget := &wmoBudget{}
+ models := map[string]*wmoModel{}
+ var drawables []wmoDrawable
+ var problems []string
+ var bases []string
+ for _, p := range places {
+ key := strings.ToLower(p.path)
+ m := models[key]
+ if m == nil {
+ m = loadWMO(set, trs, p.path, budget, logf)
+ models[key] = m
+ }
+ if m.err != nil {
+ if len(problems) < 3 {
+ problems = append(problems, m.base+": "+m.err.Error())
+ }
+ continue
+ }
+ bases = append(bases, m.base)
+ px, py, pz := 0.0, 0.0, 0.0
+ if !wdt {
+ px, py, pz = wmoMapCenter-p.pos[2], wmoMapCenter-p.pos[0], p.pos[1]
+ } else if p.pos != [3]float64{} {
+ logf("WMO %s: the WDT MODF has position %v; the client uses (0,0,0) on a WMO-only map", m.base, p.pos)
+ }
+ d := wmoDrawable{px: px, py: py, pz: pz, th: p.rot[1] * math.Pi / 180}
+ if skip[p.uid] {
+ info.Excluded++
+ logf("WMO %s uid %d: placement belongs to another zone; not drawn", m.base, p.uid)
+ continue
+ }
+ if nav != nil && !placementNearNav(m, px, py, d.th, *nav) {
+ info.Excluded++
+ logf("WMO %s uid %d: its box is more than %.0f yd outside the map navmesh; not drawn", m.base, p.uid, wmoNavMargin)
+ continue
+ }
+ for _, g := range m.groups {
+ if g == nil || len(g.tiles) == 0 {
+ continue
+ }
+ it := d
+ it.g = g
+ it.zw, it.zw1 = pz+g.z0, pz+g.z1
+ it.wx0, it.wy0, it.wx1, it.wy1 = 1e18, 1e18, -1e18, -1e18
+ for _, c := range [4][2]float64{{g.x0, g.y0}, {g.x0, g.y1}, {g.x1, g.y0}, {g.x1, g.y1}} {
+ X, Y := it.world(c[0], c[1])
+ it.wx0, it.wx1 = math.Min(it.wx0, X), math.Max(it.wx1, X)
+ it.wy0, it.wy1 = math.Min(it.wy0, Y), math.Max(it.wy1, Y)
+ }
+ drawables = append(drawables, it)
+ if len(drawables) > wmoMaxDrawables {
+ return nil, info, fmt.Sprintf("too many WMO groups to draw (more than %d)", wmoMaxDrawables)
+ }
+ }
+ }
+ if len(drawables) == 0 {
+ switch {
+ case len(problems) > 0:
+ return nil, info, strings.Join(problems, "; ")
+ case len(bases) == 0:
+ return nil, info, "none of the placed WMO files is in the client"
+ default:
+ listed := 0
+ for _, m := range models {
+ if m.err == nil {
+ listed += m.listed
+ }
+ }
+ if len(bases) > 3 {
+ bases = bases[:3]
+ }
+ if listed == 0 {
+ return nil, info, "the client lists no WMO minimap tiles for " + strings.Join(bases, ", ")
+ }
+ return nil, info, fmt.Sprintf("none of the %d WMO minimap tiles decoded", listed)
+ }
+ }
+ sort.SliceStable(drawables, func(i, j int) bool { return drawables[i].zw < drawables[j].zw })
+
+ m := Meta{Source: KindMinimap, PxPerYard: wmoPxPerYd, From: fromWMO}
+ if under != nil {
+ m.From = fromTerrainWMO
+ }
+ N, S, W, E := -1e18, 1e18, -1e18, 1e18
+ info.ZMin, info.ZMax = math.Inf(1), math.Inf(-1)
+ for _, d := range drawables {
+ N, S = math.Max(N, d.wx1), math.Min(S, d.wx0)
+ W, E = math.Max(W, d.wy1), math.Min(E, d.wy0)
+ info.ZMin = math.Min(info.ZMin, d.zw)
+ info.ZMax = math.Max(info.ZMax, d.zw1)
+ }
+ if under != nil {
+ N, S = math.Max(N, under.Meta.North), math.Min(S, under.Meta.South)
+ W, E = math.Max(W, under.Meta.West), math.Min(E, under.Meta.East)
+ info.Tiles += under.Meta.Tiles
+ }
+ m.North, m.South, m.West, m.East = N, S, W, E
+ if m.West <= m.East || m.North <= m.South {
+ return nil, info, "the placed WMO groups have no extent"
+ }
+ ppy := wmoPxPerYd
+ if v := wmoMaxSide / (m.West - m.East); v < ppy {
+ ppy = v
+ }
+ if v := wmoMaxSide / (m.North - m.South); v < ppy {
+ ppy = v
+ }
+ if ppy <= 0 {
+ return nil, info, "the placed WMO groups are too wide for a picture"
+ }
+ m.PxPerYard = ppy
+ m.Width = min(wmoMaxSide, int(math.Ceil((m.West-m.East)*ppy)))
+ m.Height = min(wmoMaxSide, int(math.Ceil((m.North-m.South)*ppy)))
+ if m.Width <= 0 || m.Height <= 0 {
+ return nil, info, "the placed WMO groups are too narrow for a picture"
+ }
+ var jobs []wmoJob
+ for _, d := range drawables {
+ for _, j := range d.jobs(m) {
+ info.Work += int64(j.rect.Dx()) * int64(j.rect.Dy())
+ if info.Work > wmoMaxWork {
+ return nil, info, fmt.Sprintf("the WMO tiles would sample more than %d pixels", wmoMaxWork)
+ }
+ jobs = append(jobs, j)
+ }
+ }
+ img := image.NewNRGBA(image.Rect(0, 0, m.Width, m.Height))
+ if under != nil {
+ copyUnder(img, m, under)
+ }
+ for _, j := range jobs {
+ j.draw(img, m)
+ }
+ seen := map[*wmoGroup]bool{}
+ for _, d := range drawables {
+ if !seen[d.g] {
+ seen[d.g] = true
+ info.Tiles += len(d.g.tiles)
+ }
+ }
+ m.Tiles = info.Tiles
+ return &Layer{Kind: KindMinimap, Meta: m, Img: img}, info, ""
+}
+
+// copyUnder copies the terrain picture into the composed canvas, sampling it per output pixel (nearest neighbour).
+func copyUnder(dst *image.NRGBA, m Meta, under *Layer) {
+ um := under.Meta
+ b := under.Img.Bounds()
+ for r := 0; r < m.Height; r++ {
+ for c := 0; c < m.Width; c++ {
+ X, Y := m.ToWorld(float64(c)+.5, float64(r)+.5)
+ u, v := um.ToPixel(X, Y)
+ ui, vi := int(math.Floor(u)), int(math.Floor(v))
+ if !image.Pt(ui, vi).In(b) {
+ continue
+ }
+ p := color.NRGBAModel.Convert(under.Img.At(ui, vi)).(color.NRGBA)
+ dst.SetNRGBA(c, r, p)
+ }
+ }
+}
+
+// oneLine collapses whitespace and caps a reason at 160 characters, as the dashboard's status.json contract says.
+func oneLine(s string) string {
+ s = strings.Join(strings.Fields(s), " ")
+ if r := []rune(s); len(r) > 160 {
+ s = string(r[:157]) + "..."
+ }
+ return s
+}
diff --git a/internal/minimap/wmo_client_test.go b/internal/minimap/wmo_client_test.go
new file mode 100644
index 00000000..dfe25994
--- /dev/null
+++ b/internal/minimap/wmo_client_test.go
@@ -0,0 +1,546 @@
+package minimap
+
+// Client-gated checks of the WMO minimap transform against the server's navmesh and the plan's points. They need the
+// owner's client and are skipped otherwise:
+//
+// TURTLE_CLIENT_DATA the client Data folder
+// TURTLE_MINIMAP_DIR a minimap-extract output folder (its .navmesh.png for the WMO-only proof maps)
+// TURTLE_MMAPS_DIR the server's mmaps folder (the z-band navmesh of the ADT proof maps)
+// TURTLE_PLANS_DIR turtle-agent's dungeons folder (the entrances and bosses)
+// TURTLE_W2B_DIR optional: write the all-dungeon overlay pictures there
+//
+// The thresholds are W2a's numbers, with margin: 70 IoU >= 0.60, 429 IoU >= 0.70, 129 art-on-navmesh >= 0.80,
+// 533 art-on-navmesh >= 0.90; every proof map has a best shift of at most 2 yd and all its plan points on the art.
+
+import (
+ "encoding/json"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "math"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/mpq"
+)
+
+type mapPoint struct {
+ name string
+ x, y float64
+}
+
+type alignMetrics struct {
+ iou, artOnNav, navCovered float64
+ shiftX, shiftY int // the shift that wins by at least 2 % of the navmesh; 0,0 when none does
+ rawShiftX, rawShiftY int // the plain argmax, for the log
+ points, hits int
+ missed []string
+}
+
+type alignFixture struct {
+ client, minimapDir, mmapsDir, plansDir string
+}
+
+func alignDirs(t *testing.T) alignFixture {
+ t.Helper()
+ f := alignFixture{
+ client: os.Getenv("TURTLE_CLIENT_DATA"),
+ minimapDir: os.Getenv("TURTLE_MINIMAP_DIR"),
+ mmapsDir: os.Getenv("TURTLE_MMAPS_DIR"),
+ plansDir: os.Getenv("TURTLE_PLANS_DIR"),
+ }
+ if f.client == "" || f.minimapDir == "" || f.mmapsDir == "" || f.plansDir == "" {
+ fmt.Fprintf(os.Stderr, "NOT RUN %s: set TURTLE_CLIENT_DATA, TURTLE_MINIMAP_DIR, TURTLE_MMAPS_DIR and TURTLE_PLANS_DIR to run the client alignment tests\n", t.Name())
+ t.Skip("set TURTLE_CLIENT_DATA, TURTLE_MINIMAP_DIR, TURTLE_MMAPS_DIR and TURTLE_PLANS_DIR to run the client alignment test")
+ }
+ return f
+}
+
+func (f alignFixture) open(t *testing.T) (*mpq.Set, Translate) {
+ t.Helper()
+ set, err := mpq.OpenSet(f.client, ArchiveOrder(f.client))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { set.Close() })
+ trs := ParseTranslate(mustRead(t, set, translatePath))
+ return set, trs
+}
+
+// mapDir finds a map's Map.dbc folder.
+func (f alignFixture) mapDir(t *testing.T, set *mpq.Set, id uint32) string {
+ t.Helper()
+ rows, err := ParseMaps(mustRead(t, set, `DBFilesClient\Map.dbc`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, r := range rows {
+ if r.ID == id {
+ return r.Dir
+ }
+ }
+ t.Fatalf("map %d is not in Map.dbc", id)
+ return ""
+}
+
+// composeFull calls the production minimap source for one map with the map's navmesh box, and returns the polygons it
+// read for the z-band render.
+func (f alignFixture) composeFull(t *testing.T, set *mpq.Set, trs Translate, id uint32) (Layer, wmoInfo, []NavPoly) {
+ t.Helper()
+ polys, tiles, perr := LoadMapPolys([]string{f.mmapsDir}, id)
+ if perr != nil || tiles == 0 {
+ t.Fatalf("map %d: mmaps: %v (%d tiles)", id, perr, tiles)
+ }
+ l, info, ok, reason := minimapSource(set, trs, f.mapDir(t, set, id), id, navBoundsOf(polys), t.Logf)
+ if !ok {
+ t.Fatalf("map %d has no minimap: %s", id, reason)
+ }
+ if l.Meta.Source != KindMinimap || l.Meta.From != fromWMO {
+ t.Fatalf("map %d: source %q from %q, want minimap/wmo", id, l.Meta.Source, l.Meta.From)
+ }
+ return l, info, polys
+}
+
+// compose calls composeFull when the polygons are not needed.
+func (f alignFixture) compose(t *testing.T, set *mpq.Set, trs Translate, id uint32) (Layer, wmoInfo) {
+ t.Helper()
+ l, info, _ := f.composeFull(t, set, trs, id)
+ return l, info
+}
+
+// zBandNav renders the navmesh polygons of a map whose mean height lies within the WMO groups' z range, so the picture
+// is compared with the dungeon floors, not the outdoor terrain of an ADT map.
+func (f alignFixture) zBandNav(t *testing.T, id uint32, info wmoInfo, polys []NavPoly) (image.Image, Meta) {
+ t.Helper()
+ var keep []NavPoly
+ for _, p := range polys {
+ z := 0.0
+ for _, v := range p.V {
+ z += float64(v[2])
+ }
+ z /= float64(len(p.V))
+ if z >= info.ZMin-3 && z <= info.ZMax+3 {
+ keep = append(keep, p)
+ }
+ }
+ img, meta, err := RenderNavmesh(keep, 2, 8192)
+ if err != nil {
+ t.Fatalf("map %d: z-band navmesh %.1f..%.1f: %v", id, info.ZMin, info.ZMax, err)
+ }
+ return img, meta
+}
+
+func (f alignFixture) liveNav(t *testing.T, id uint32) (image.Image, Meta) {
+ t.Helper()
+ p := filepath.Join(f.minimapDir, strconv.FormatUint(uint64(id), 10)+".navmesh.png")
+ return loadMetaPNG(t, p)
+}
+
+func loadMetaPNG(t *testing.T, p string) (image.Image, Meta) {
+ t.Helper()
+ f, err := os.Open(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ img, err := png.Decode(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m Meta
+ b, err := os.ReadFile(strings.TrimSuffix(p, ".png") + ".json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(b, &m); err != nil {
+ t.Fatal(err)
+ }
+ return img, m
+}
+
+// planPoints reads every dungeon plan of a map.
+func planPoints(t *testing.T, dir string, id uint32) []mapPoint {
+ t.Helper()
+ fs, err := filepath.Glob(filepath.Join(dir, "*.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ sort.Strings(fs)
+ var pts []mapPoint
+ type p struct{ X, Y float64 }
+ for _, f := range fs {
+ var d struct {
+ Name string
+ Map int
+ Entrance *p
+ Legs []struct {
+ Name string
+ Boss *struct{ Position *p }
+ }
+ }
+ b, err := os.ReadFile(f)
+ if err != nil || json.Unmarshal(b, &d) != nil || d.Map != int(id) {
+ continue
+ }
+ if d.Entrance != nil {
+ pts = append(pts, mapPoint{d.Name + ":entrance", d.Entrance.X, d.Entrance.Y})
+ }
+ for _, l := range d.Legs {
+ if l.Boss != nil && l.Boss.Position != nil {
+ pts = append(pts, mapPoint{d.Name + ":" + l.Name, l.Boss.Position.X, l.Boss.Position.Y})
+ }
+ }
+ }
+ return pts
+}
+
+func opaquePixel(img image.Image, x, y int) bool {
+ if !image.Pt(x, y).In(img.Bounds()) {
+ return false
+ }
+ _, _, _, a := img.At(x, y).RGBA()
+ return a > 0x8000
+}
+
+// measure compares the picture with the navmesh on a 1-yd grid and finds the residual shift that covers the most
+// navmesh (the search W2a settled on; a 5-yd centroid offset does not work because the art shows walls and rims).
+func measure(art image.Image, am Meta, nav image.Image, nm Meta, pts []mapPoint) alignMetrics {
+ var r alignMetrics
+ N, S := math.Max(am.North, nm.North), math.Min(am.South, nm.South)
+ W, E := math.Max(am.West, nm.West), math.Min(am.East, nm.East)
+ var inter, uni, na, ma float64
+ for X := S; X < N; X++ {
+ for Y := E; Y < W; Y++ {
+ c, row := am.ToPixel(X+.5, Y+.5)
+ mm := opaquePixel(art, int(c), int(row))
+ c, row = nm.ToPixel(X+.5, Y+.5)
+ nn := opaquePixel(nav, int(c), int(row))
+ if mm {
+ ma++
+ }
+ if nn {
+ na++
+ }
+ if mm && nn {
+ inter++
+ }
+ if mm || nn {
+ uni++
+ }
+ }
+ }
+ if ma > 0 {
+ r.artOnNav = inter / ma
+ }
+ if na > 0 {
+ r.navCovered = inter / na
+ }
+ if uni > 0 {
+ r.iou = inter / uni
+ }
+ gw, gh := int(W-E)+1, int(N-S)+1
+ mg, ng := make([]bool, gw*gh), make([]bool, gw*gh)
+ for i := range gh {
+ for j := range gw {
+ X, Y := S+float64(i)+.5, E+float64(j)+.5
+ c, row := am.ToPixel(X, Y)
+ mg[i*gw+j] = opaquePixel(art, int(c), int(row))
+ c, row = nm.ToPixel(X, Y)
+ ng[i*gw+j] = opaquePixel(nav, int(c), int(row))
+ }
+ }
+ best, bdx, bdy := -1, 0, 0
+ zero := 0
+ for k := range mg {
+ if mg[k] && ng[k] {
+ zero++
+ }
+ }
+ for dx := -10; dx <= 10; dx++ {
+ for dy := -10; dy <= 10; dy++ {
+ n := 0
+ for i := max(0, -dx); i < gh && i+dx < gh; i++ {
+ for j := max(0, -dy); j < gw && j+dy < gw; j++ {
+ if ng[i*gw+j] && mg[(i+dx)*gw+j+dy] {
+ n++
+ }
+ }
+ }
+ if n > best {
+ best, bdx, bdy = n, dx, dy
+ }
+ }
+ }
+ r.rawShiftX, r.rawShiftY = bdx, bdy
+ // A shift is only meaningful when it wins clearly. On a map whose navmesh is much larger than the art (Naxxramas:
+ // the z band also holds the outdoor terrain), a 1-yd grid is self-similar and a shift can win by a fraction of a
+ // percent; report the zero shift then. A real misalignment wins by far more (W2a's wrong variants went to the
+ // search edge).
+ if best > zero+max(1, int(0.02*na)) {
+ r.shiftX, r.shiftY = bdx, bdy
+ }
+ // A plan point is on the art when an opaque pixel lies within 3 yd of it.
+ rad := max(1, int(math.Ceil(3*am.PxPerYard)))
+ for _, p := range pts {
+ r.points++
+ c, row := am.ToPixel(p.x, p.y)
+ found := false
+ for dy := -rad; dy <= rad && !found; dy++ {
+ for dx := -rad; dx <= rad; dx++ {
+ if opaquePixel(art, int(c)+dx, int(row)+dy) {
+ found = true
+ break
+ }
+ }
+ }
+ if found {
+ r.hits++
+ } else {
+ r.missed = append(r.missed, p.name)
+ }
+ }
+ return r
+}
+
+func (r alignMetrics) String() string {
+ s := fmt.Sprintf("shift=(%d,%d) rawShift=(%d,%d) iou=%.3f artOnNav=%.3f navCovered=%.3f points=%d/%d",
+ r.shiftX, r.shiftY, r.rawShiftX, r.rawShiftY, r.iou, r.artOnNav, r.navCovered, r.hits, r.points)
+ if len(r.missed) > 0 {
+ s += " missed=" + strings.Join(r.missed, ",")
+ }
+ return s
+}
+
+func (r alignMetrics) shiftWithin(yd int) bool {
+ return absInt(r.shiftX) <= yd && absInt(r.shiftY) <= yd
+}
+
+func absInt(n int) int {
+ if n < 0 {
+ return -n
+ }
+ return n
+}
+
+func TestWMOAlignmentProofMaps(t *testing.T) {
+ f := alignDirs(t)
+ set, trs := f.open(t)
+ plans := func(id uint32) []mapPoint { return planPoints(t, f.plansDir, id) }
+ for _, c := range []struct {
+ id uint32
+ zband bool
+ min float64
+ ic bool
+ pts int
+ }{
+ {70, false, 0.60, true, 9},
+ {429, false, 0.70, true, 17},
+ {129, true, 0.80, false, 5},
+ {533, true, 0.90, false, 16},
+ } {
+ t.Run(strconv.FormatUint(uint64(c.id), 10), func(t *testing.T) {
+ l, info, polys := f.composeFull(t, set, trs, c.id)
+ var nav image.Image
+ var nm Meta
+ if c.zband {
+ nav, nm = f.zBandNav(t, c.id, info, polys)
+ } else {
+ nav, nm = f.liveNav(t, c.id)
+ }
+ m := measure(l.Img, l.Meta, nav, nm, plans(c.id))
+ t.Logf("RESULT map=%d %s tiles=%d z=%.1f..%.1f", c.id, m, info.Tiles, info.ZMin, info.ZMax)
+ if !m.shiftWithin(2) {
+ t.Errorf("map %d: best shift %d,%d yd, want at most 2", c.id, m.shiftX, m.shiftY)
+ }
+ if c.ic {
+ if m.iou < c.min {
+ t.Errorf("map %d: IoU %.3f, want at least %.2f", c.id, m.iou, c.min)
+ }
+ } else if m.artOnNav < c.min {
+ t.Errorf("map %d: art on navmesh %.3f, want at least %.2f", c.id, m.artOnNav, c.min)
+ }
+ if m.points < c.pts || m.hits != m.points {
+ t.Errorf("map %d: plan points %d/%d, want %d/%d", c.id, m.hits, m.points, c.pts, c.pts)
+ }
+ })
+ }
+}
+
+// alignExpect is the recorded state of a known-exception map: the metric with its band, the plan points and the shift.
+// A value outside the band in either direction fails the test, so an improvement also asks for an updated table.
+type alignExpect struct {
+ metric string
+ value float64
+ band float64
+ hits int
+ total int
+ shiftX int
+ shiftY int
+ rawX int
+ rawY int
+ pinRaw bool // the raw shift is part of the exception (533)
+ reason string
+}
+
+// knownExceptions are the maps whose measured alignment cannot meet a spec threshold for a known reason. The test fails
+// on every deviation from these recorded values; the reason is also written into the picture JSON as "note" where the
+// picture itself has a client-data limit.
+var knownExceptions = map[uint32]alignExpect{
+ 189: {metric: "artOnNav", value: 0.624, band: 0.03, hits: 13, total: 14,
+ reason: "Herod's room has navmesh but no client art: artOnNav 0.624, 13/14 points (scarlet_armory:herod off the art)"},
+ 819: {metric: "artOnNav", value: 0.780, band: 0.03, hits: 6, total: 7,
+ reason: "part of the WMO has no navmesh (red areas where the client has no art): artOnNav 0.780, 6/7 points (entrance)"},
+ 822: {metric: "artOnNav", value: 0.696, band: 0.03, hits: 5, total: 5,
+ reason: "client art includes walls and rims with no navmesh (artOnNav 0.696); the two unrelated mini-hives are excluded, shift 0,0"},
+ 533: {metric: "artOnNav", value: 0.939, band: 0.03, hits: 16, total: 16, rawX: 2, rawY: -6, pinRaw: true,
+ reason: "the raw shift argmax is flat on a z-band navmesh 7x the art (artOnNav 0.939, 16/16); the filtered shift is 0,0"},
+}
+
+// exceptionProblem is the first measured value that left an exception's recorded state.
+func exceptionProblem(m alignMetrics, exp alignExpect) string {
+ metric := m.artOnNav
+ if exp.metric == "iou" {
+ metric = m.iou
+ }
+ switch {
+ case math.Abs(metric-exp.value) > exp.band:
+ return fmt.Sprintf("%s %.3f outside %.3f +/- %.2f", exp.metric, metric, exp.value, exp.band)
+ case m.hits != exp.hits || m.points != exp.total:
+ return fmt.Sprintf("points %d/%d, recorded %d/%d", m.hits, m.points, exp.hits, exp.total)
+ case m.shiftX != exp.shiftX || m.shiftY != exp.shiftY:
+ return fmt.Sprintf("shift (%d,%d), recorded (%d,%d)", m.shiftX, m.shiftY, exp.shiftX, exp.shiftY)
+ case exp.pinRaw && (m.rawShiftX != exp.rawX || m.rawShiftY != exp.rawY):
+ return fmt.Sprintf("raw shift (%d,%d), recorded (%d,%d)", m.rawShiftX, m.rawShiftY, exp.rawX, exp.rawY)
+ }
+ return ""
+}
+
+// dungeonIDs are the 21 instance maps that have no client minimap (plan table).
+var dungeonIDs = []uint32{34, 35, 43, 48, 70, 90, 109, 129, 189, 229, 230, 249, 349, 389, 409, 429, 533, 816, 818, 819, 822}
+
+// TestWMOAllDungeons composes every target dungeon, writes an overlay picture of the art over the z-band navmesh with
+// the plan's entrances and bosses marked, and logs the pass numbers. It is the W2b verification run.
+func TestWMOAllDungeons(t *testing.T) {
+ outDir := os.Getenv("TURTLE_W2B_DIR")
+ if outDir == "" {
+ fmt.Fprintln(os.Stderr, "NOT RUN TestWMOAllDungeons: set TURTLE_W2B_DIR to write the all-dungeon overlays")
+ t.Skip("set TURTLE_W2B_DIR to write the all-dungeon overlays")
+ }
+ f := alignDirs(t)
+ set, trs := f.open(t)
+ if err := os.MkdirAll(outDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for _, id := range dungeonIDs {
+ t.Run(strconv.FormatUint(uint64(id), 10), func(t *testing.T) {
+ l, info, polys := f.composeFull(t, set, trs, id)
+ nav, nm := f.zBandNav(t, id, info, polys)
+ pts := planPoints(t, f.plansDir, id)
+ m := measure(l.Img, l.Meta, nav, nm, pts)
+ name := strings.NewReplacer(" ", "_", "'", "", ":", "").Replace(strings.ToLower(strings.TrimSpace(mapName(t, set, id))))
+ if err := writeOverlay(filepath.Join(outDir, fmt.Sprintf("%d-%s.overlay.png", id, name)), l.Img, l.Meta, nav, nm, pts); err != nil {
+ t.Fatal(err)
+ }
+ pass, metric := true, "iou"
+ if info.WDT {
+ pass = m.iou >= 0.50
+ } else {
+ metric = "artOnNav"
+ pass = m.artOnNav >= 0.80
+ }
+ pass = pass && m.shiftWithin(2) && m.hits == m.points
+ raw3 := absInt(m.rawShiftX) > 3 || absInt(m.rawShiftY) > 3
+ t.Logf("W2B map=%d name=%q produced=y tiles=%d excluded=%d %s metric=%s pass=%v rawOver3=%v",
+ id, mapName(t, set, id), info.Tiles, info.Excluded, m, metric, pass, raw3)
+ if exp, ok := knownExceptions[id]; ok {
+ if problem := exceptionProblem(m, exp); problem != "" {
+ t.Errorf("map %d known exception left its recorded state: %s; update the table (%s)", id, problem, m)
+ } else {
+ t.Logf("KNOWN EXCEPTION map=%d: %s", id, exp.reason)
+ }
+ } else if !pass || raw3 {
+ t.Errorf("map %d alignment failed: %s (pass=%v, raw shift over 3 yd: %v)", id, m, pass, raw3)
+ }
+ })
+ }
+}
+
+// TestFrostmaneHivesExcluded checks the navmesh-box placement rule on the map that needed it: the two outdoor mini-hive
+// WMOs (uids 303286 and 303232) stand far from the dungeon and must not grow the picture.
+func TestFrostmaneHivesExcluded(t *testing.T) {
+ f := alignDirs(t)
+ set, trs := f.open(t)
+ l, info, polys := f.composeFull(t, set, trs, 822)
+ if info.Excluded < 2 {
+ t.Errorf("excluded = %d placements, want the two mini-hive WMOs", info.Excluded)
+ }
+ // The full picture was 2798 px wide with the hives; the dungeon alone is under 2000 px.
+ if l.Meta.Width > 2000 {
+ t.Errorf("canvas %d px wide, want the hives' ~350 yd of picture gone", l.Meta.Width)
+ }
+ nav, nm := f.zBandNav(t, 822, info, polys)
+ m := measure(l.Img, l.Meta, nav, nm, planPoints(t, f.plansDir, 822))
+ t.Logf("822: excluded=%d canvas=%dx%d %s", info.Excluded, l.Meta.Width, l.Meta.Height, m)
+}
+
+func mapName(t *testing.T, set *mpq.Set, id uint32) string {
+ t.Helper()
+ rows, err := ParseMaps(mustRead(t, set, `DBFilesClient\Map.dbc`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, r := range rows {
+ if r.ID == id {
+ return r.Name
+ }
+ }
+ return ""
+}
+
+// writeOverlay draws the art, tints it green where navmesh lies under it, paints navmesh that has no art red, and marks
+// every plan point with a yellow cross.
+func writeOverlay(path string, art image.Image, am Meta, nav image.Image, nm Meta, pts []mapPoint) error {
+ ov := image.NewNRGBA(image.Rect(0, 0, am.Width, am.Height))
+ for r := range am.Height {
+ for c := range am.Width {
+ X, Y := am.ToWorld(float64(c)+.5, float64(r)+.5)
+ nc, nr := nm.ToPixel(X, Y)
+ nn := opaquePixel(nav, int(nc), int(nr))
+ a := color.NRGBAModel.Convert(art.At(c, r)).(color.NRGBA)
+ switch {
+ case a.A > 0 && nn:
+ ov.SetNRGBA(c, r, color.NRGBA{uint8(int(a.R) * 6 / 10), uint8(min(255, int(a.G)*6/10+90)), uint8(int(a.B) * 6 / 10), 255})
+ case a.A > 0:
+ ov.SetNRGBA(c, r, a)
+ case nn:
+ ov.SetNRGBA(c, r, color.NRGBA{230, 30, 30, 255})
+ default:
+ ov.SetNRGBA(c, r, color.NRGBA{20, 20, 28, 255})
+ }
+ }
+ }
+ for _, p := range pts {
+ c, r := am.ToPixel(p.x, p.y)
+ for d := -10; d <= 10; d++ {
+ for _, q := range []image.Point{{int(c) + d, int(r)}, {int(c), int(r) + d}, {int(c) + d, int(r) + d}, {int(c) + d, int(r) - d}} {
+ if q.In(ov.Bounds()) {
+ ov.SetNRGBA(q.X, q.Y, color.NRGBA{255, 230, 0, 255})
+ }
+ }
+ }
+ }
+ fh, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ if err := png.Encode(fh, ov); err != nil {
+ fh.Close()
+ return err
+ }
+ return fh.Close()
+}
diff --git a/internal/minimap/wmo_review_test.go b/internal/minimap/wmo_review_test.go
new file mode 100644
index 00000000..a5b76db5
--- /dev/null
+++ b/internal/minimap/wmo_review_test.go
@@ -0,0 +1,401 @@
+package minimap
+
+// Fixtures for the review's edge cases: draw order, alpha threshold, uid dedup across ADTs, groups without tiles, the
+// compositor caps, tile-rectangle work, and the terrain underlay on one of the five overlay maps (Deadmines 36).
+
+import (
+ "encoding/binary"
+ "fmt"
+ "image"
+ "image/color"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/mpq"
+)
+
+func reviewWMO(boxes [][6]float32) []byte {
+ var mogi []byte
+ for _, b := range boxes {
+ e := make([]byte, 32)
+ for k, v := range b {
+ binary.LittleEndian.PutUint32(e[4+4*k:], math.Float32bits(v))
+ }
+ mogi = append(mogi, e...)
+ }
+ return fixChunk("MOGI", mogi)
+}
+
+func reviewWDT(flags uint32, names []string, modfs [][]byte, mainData []byte) []byte {
+ var out []byte
+ hdr := make([]byte, 4)
+ binary.LittleEndian.PutUint32(hdr, flags)
+ out = append(out, fixChunk("MPHD", hdr)...)
+ var mw []byte
+ for _, n := range names {
+ mw = append(mw, n...)
+ mw = append(mw, 0)
+ }
+ out = append(out, fixChunk("MWMO", mw)...)
+ var mf []byte
+ for _, m := range modfs {
+ mf = append(mf, m...)
+ }
+ if len(mf) > 0 {
+ out = append(out, fixChunk("MODF", mf)...)
+ }
+ if mainData != nil {
+ out = append(out, fixChunk("MAIN", mainData)...)
+ }
+ return out
+}
+
+func reviewADT(wmo string, modf []byte) []byte {
+ out := fixChunk("MWMO", append([]byte(wmo), 0))
+ out = append(out, fixChunk("MWID", []byte{0, 0, 0, 0})...)
+ return append(out, fixChunk("MODF", modf)...)
+}
+
+func solidBLP(w, h int, c color.NRGBA) []byte {
+ return fixBLP(w, h, func(int, int) color.NRGBA { return c })
+}
+
+// detailBLP is a solid tile with a 16 x 16 accent block, so isBlank (which calls 98 % one colour blank) keeps the
+// composed fixture picture.
+func detailBLP(w, h int, bg, accent color.NRGBA) []byte {
+ return fixBLP(w, h, func(x, y int) color.NRGBA {
+ if x < 16 && y < 16 {
+ return accent
+ }
+ return bg
+ })
+}
+
+// reviewClient writes the review fixtures. Maps 903..909 are WMO-only; 36 also has a terrain tile, and 36 is one of the
+// five maps whose WMO art is drawn over its terrain minimap.
+func reviewClient(t *testing.T) string {
+ t.Helper()
+ var files []mpq.File
+ var trs strings.Builder
+ add := func(name string, data []byte) { files = append(files, mpq.File{Name: name, Data: data}) }
+ tile := func(base string, g, a, b int, hash string) {
+ fmt.Fprintf(&trs, "WMO\\Test\\%s_%03d_%02d_%02d.blp\t%s\n", base, g, a, b, hash)
+ }
+ wdt := func(dir string, flags uint32, names []string, modfs [][]byte, mainData []byte) {
+ add(`World\Maps\`+dir+`\`+dir+`.wdt`, reviewWDT(flags, names, modfs, mainData))
+ }
+ wmo := func(base string, root []byte) { add(`World\wmo\Test\`+base+`.wmo`, root) }
+ blp := func(hash string, data []byte) { add(`Textures\Minimap\`+hash, data) }
+ // 903: two stacked groups, the higher box (z0=5) must paint over the lower.
+ wmo("order", reviewWMO([][6]float32{{0, 0, 0, 32, 32, 10}, {0, 0, 5, 32, 32, 15}}))
+ blp("order0.blp", detailBLP(64, 64, color.NRGBA{0, 0, 200, 255}, color.NRGBA{0, 200, 0, 255}))
+ blp("order1.blp", detailBLP(64, 64, color.NRGBA{200, 0, 0, 255}, color.NRGBA{0, 200, 0, 255}))
+ tile("order", 0, 0, 0, "order0.blp")
+ tile("order", 1, 0, 0, "order1.blp")
+ wdt("OrderTest", 1, []string{`World\wmo\Test\order.wmo`}, [][]byte{fixMODF(0, 1, [3]float32{}, [3]float32{})}, nil)
+ // 904: alpha 100 is transparent, 128 opaque.
+ wmo("alpha", reviewWMO([][6]float32{{0, 0, 0, 32, 32, 10}}))
+ blp("alpha0.blp", fixBLP(64, 64, func(x, y int) color.NRGBA {
+ switch {
+ case y == 63 && x == 0:
+ return color.NRGBA{200, 0, 0, 100}
+ case y == 63 && x == 1:
+ return color.NRGBA{0, 200, 0, 128}
+ case x >= 16 && x < 32 && y < 16:
+ return color.NRGBA{0, 0, 200, 255}
+ case x >= 32 && x < 48 && y < 16:
+ return color.NRGBA{200, 200, 0, 255}
+ }
+ return color.NRGBA{}
+ }))
+ tile("alpha", 0, 0, 0, "alpha0.blp")
+ wdt("AlphaTest", 1, []string{`World\wmo\Test\alpha.wmo`}, [][]byte{fixMODF(0, 1, [3]float32{}, [3]float32{})}, nil)
+ // 905: the same uniqueId in two ADTs; only the first placement is drawn.
+ wmo("dedup", reviewWMO([][6]float32{{0, 0, 0, 32, 32, 10}}))
+ blp("dedup0.blp", detailBLP(64, 64, color.NRGBA{0, 0, 200, 255}, color.NRGBA{0, 200, 0, 255}))
+ tile("dedup", 0, 0, 0, "dedup0.blp")
+ main := make([]byte, 64*64*8)
+ binary.LittleEndian.PutUint32(main, 1)
+ binary.LittleEndian.PutUint32(main[8:], 1)
+ wdt("DedupTest", 0, nil, nil, main)
+ add(`World\Maps\DedupTest\DedupTest_0_0.adt`, reviewADT(`World\wmo\Test\dedup.wmo`,
+ fixMODF(0, 777, [3]float32{0, 0, 0}, [3]float32{})))
+ add(`World\Maps\DedupTest\DedupTest_1_0.adt`, reviewADT(`World\wmo\Test\dedup.wmo`,
+ fixMODF(0, 777, [3]float32{0, 0, 1000}, [3]float32{})))
+ // 906: a group with no tiles must not grow the canvas.
+ wmo("notiles", reviewWMO([][6]float32{{0, 0, 0, 32, 32, 10}, {1000, 1000, 0, 1064, 1064, 10}}))
+ blp("notiles0.blp", detailBLP(64, 64, color.NRGBA{0, 0, 200, 255}, color.NRGBA{0, 200, 0, 255}))
+ tile("notiles", 0, 0, 0, "notiles0.blp")
+ wdt("NoTilesTest", 1, []string{`World\wmo\Test\notiles.wmo`}, [][]byte{fixMODF(0, 1, [3]float32{}, [3]float32{})}, nil)
+ // 907: 4096 placements x 5 groups exceed the drawable cap.
+ wmo("cap", reviewWMO([][6]float32{{0, 0, 0, 32, 32, 10}, {0, 0, 0, 32, 32, 10}, {0, 0, 0, 32, 32, 10}, {0, 0, 0, 32, 32, 10}, {0, 0, 0, 32, 32, 10}}))
+ blp("cap0.blp", solidBLP(64, 64, color.NRGBA{0, 0, 200, 255}))
+ var capModfs [][]byte
+ for i := range wmoMaxPlace {
+ capModfs = append(capModfs, fixMODF(0, uint32(i+1), [3]float32{}, [3]float32{}))
+ }
+ for g := range 5 {
+ tile("cap", g, 0, 0, "cap0.blp")
+ }
+ wdt("CapTest", 1, []string{`World\wmo\Test\cap.wmo`}, capModfs, nil)
+ // 908: 1024 tiles in a 32 x 32 group exceed the pixel-work cap.
+ wmo("work", reviewWMO([][6]float32{{0, 0, 0, 4096, 4096, 10}}))
+ blp("work0.blp", solidBLP(128, 128, color.NRGBA{0, 0, 200, 255}))
+ for a := range 32 {
+ for b := range 32 {
+ tile("work", 0, a, b, "work0.blp")
+ }
+ }
+ wdt("WorkTest", 1, []string{`World\wmo\Test\work.wmo`}, [][]byte{fixMODF(0, 1, [3]float32{}, [3]float32{})}, nil)
+ // 909: one small tile in a huge group box; the draw must cost the tile, not the box.
+ wmo("bigbox", reviewWMO([][6]float32{{0, 0, 0, 2048, 2048, 10}}))
+ blp("bigbox0.blp", detailBLP(64, 64, color.NRGBA{0, 0, 200, 255}, color.NRGBA{0, 200, 0, 255}))
+ tile("bigbox", 0, 0, 0, "bigbox0.blp")
+ wdt("BigBoxTest", 1, []string{`World\wmo\Test\bigbox.wmo`}, [][]byte{fixMODF(0, 1, [3]float32{}, [3]float32{})}, nil)
+ // 36: terrain minimap (left half red, right half blue) and an indoor WMO near the tile's north-west corner.
+ wmo("deadmines", reviewWMO([][6]float32{{0, 0, 0, 32, 64, 10}}))
+ blp("deadmines0.blp", solidBLP(64, 128, color.NRGBA{230, 140, 0, 255}))
+ tile("deadmines", 0, 0, 0, "deadmines0.blp")
+ blp("deadmines_terrain.blp", fixBLP(256, 256, func(x, y int) color.NRGBA {
+ if x < 128 {
+ return color.NRGBA{200, 0, 0, 255}
+ }
+ return color.NRGBA{0, 0, 200, 255}
+ }))
+ fmt.Fprintf(&trs, "Deadmines\\map32_32.blp\tdeadmines_terrain.blp\n")
+ wdt("Deadmines", 1, []string{`World\wmo\Test\deadmines.wmo`}, [][]byte{fixMODF(0, 1, [3]float32{}, [3]float32{})}, nil)
+ // 910: group 0 has an invalid (inverted) box, group 1 is valid. The group ids are positional: group 1's tile must
+ // stay on group 1's box and group 0's tile must be dropped with its rejected group.
+ wmo("pos", reviewWMO([][6]float32{{100, 0, 0, 50, 32, 10}, {0, 0, 0, 32, 32, 10}}))
+ blp("pos0.blp", solidBLP(64, 64, color.NRGBA{200, 0, 0, 255}))
+ blp("pos1.blp", detailBLP(64, 64, color.NRGBA{0, 0, 200, 255}, color.NRGBA{0, 200, 0, 255}))
+ tile("pos", 0, 0, 0, "pos0.blp")
+ tile("pos", 1, 0, 0, "pos1.blp")
+ wdt("PosTest", 1, []string{`World\wmo\Test\pos.wmo`}, [][]byte{fixMODF(0, 1, [3]float32{}, [3]float32{})}, nil)
+ // 209: a map with a terrain minimap that is not one of the five overlay maps. Its WMO art must not be drawn over it.
+ wmo("keep", reviewWMO([][6]float32{{0, 0, 0, 32, 32, 10}}))
+ blp("keep0.blp", solidBLP(64, 64, color.NRGBA{230, 140, 0, 255}))
+ tile("keep", 0, 0, 0, "keep0.blp")
+ blp("zf_terrain.blp", fixBLP(256, 256, func(x, y int) color.NRGBA {
+ if x < 128 {
+ return color.NRGBA{200, 0, 0, 255}
+ }
+ return color.NRGBA{0, 0, 200, 255}
+ }))
+ fmt.Fprintf(&trs, "ZulFarrak\\map32_32.blp\tzf_terrain.blp\n")
+ wdt("ZulFarrak", 1, []string{`World\wmo\Test\keep.wmo`}, [][]byte{fixMODF(0, 1, [3]float32{}, [3]float32{})}, nil)
+ // Map.dbc
+ strs := "\x00"
+ off := func(s string) uint32 {
+ i := strings.Index(strs, s)
+ if i < 0 {
+ panic("missing string " + s)
+ }
+ return uint32(i)
+ }
+ type mapRow struct {
+ id uint32
+ dir string
+ name string
+ }
+ rows := []mapRow{{36, "Deadmines", "Deadmines"}, {209, "ZulFarrak", "Zul'Farrak"}, {903, "OrderTest", "Order Test"}, {904, "AlphaTest", "Alpha Test"},
+ {905, "DedupTest", "Dedup Test"}, {906, "NoTilesTest", "No Tiles"}, {907, "CapTest", "Cap Test"},
+ {908, "WorkTest", "Work Test"}, {909, "BigBoxTest", "Big Box"}, {910, "PosTest", "Positional Groups"}}
+ for _, r := range rows {
+ strs += r.dir + "\x00" + r.name + "\x00"
+ }
+ var recs [][]uint32
+ for _, r := range rows {
+ rec := make([]uint32, 8)
+ rec[0], rec[1], rec[2], rec[4] = r.id, off(r.dir), InstanceParty, off(r.name)
+ recs = append(recs, rec)
+ }
+ add(`DBFilesClient\Map.dbc`, dbcFile(8, 32, recs, strs))
+ add(`Textures\Minimap\md5translate.trs`, []byte(trs.String()))
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "patch.MPQ"), mpq.Build(files), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+}
+
+// reviewCompose composes one fixture map (no navmesh bounds).
+func reviewCompose(t *testing.T, client string, id uint32) (Layer, wmoInfo, bool, string) {
+ t.Helper()
+ set, err := mpq.OpenSet(client, ArchiveOrder(client))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer set.Close()
+ rows, err := ParseMaps(mustRead(t, set, `DBFilesClient\Map.dbc`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := ""
+ for _, r := range rows {
+ if r.ID == id {
+ dir = r.Dir
+ }
+ }
+ trs := ParseTranslate(mustRead(t, set, translatePath))
+ return minimapSource(set, trs, dir, id, nil, t.Logf)
+}
+
+func TestWMODrawOrder(t *testing.T) {
+ l, _, ok, reason := reviewCompose(t, reviewClient(t), 903)
+ if !ok {
+ t.Fatalf("no minimap: %s", reason)
+ }
+ // Both groups cover the same 32 x 32 yd box; the one with z0=5 (red) is drawn over the one with z0=0 (blue).
+ if got := pixelAt(t, l, -16, -16); got != (color.NRGBA{200, 0, 0, 255}) {
+ t.Errorf("the lower group is on top: pixel = %v, want red", got)
+ }
+}
+
+func TestWMOAlphaThreshold(t *testing.T) {
+ l, _, ok, reason := reviewCompose(t, reviewClient(t), 904)
+ if !ok {
+ t.Fatalf("no minimap: %s", reason)
+ }
+ if got := pixelAt(t, l, -0.25, -0.25); got.A != 0 {
+ t.Errorf("alpha 100 is drawn: %v", got)
+ }
+ if got := pixelAt(t, l, -0.75, -0.25); got != (color.NRGBA{0, 200, 0, 255}) {
+ t.Errorf("alpha 128 is not drawn opaque: %v", got)
+ }
+}
+
+func TestWMOUIDDedup(t *testing.T) {
+ l, info, ok, reason := reviewCompose(t, reviewClient(t), 905)
+ if !ok {
+ t.Fatalf("no minimap: %s", reason)
+ }
+ if info.Placements != 1 {
+ t.Errorf("placements = %d, want the duplicate uid removed", info.Placements)
+ }
+ if l.Meta.Width != 64 || l.Meta.Height != 64 {
+ t.Errorf("canvas %dx%d, want 64x64 (the second placement is not drawn)", l.Meta.Width, l.Meta.Height)
+ }
+ // The first ADT places the WMO at world north/west 17066; the second (uid 777, pos z 1000) would be 1000 yd away.
+ if got := pixelAt(t, l, 17066.666-16, 17066.666-16); got.A == 0 {
+ t.Errorf("the first placement is not drawn: %v", got)
+ }
+ if c, r := l.Meta.ToPixel(16066.666-16, 17066.666-16); image.Pt(int(c), int(r)).In(l.Img.Bounds()) {
+ t.Errorf("the duplicate placement at (%.0f,%.0f) is inside the canvas", 16066.666-16, 17066.666-16)
+ }
+}
+
+func TestWMOGroupWithoutTiles(t *testing.T) {
+ l, _, ok, reason := reviewCompose(t, reviewClient(t), 906)
+ if !ok {
+ t.Fatalf("no minimap: %s", reason)
+ }
+ if l.Meta.Width != 64 || l.Meta.Height != 64 {
+ t.Errorf("canvas %dx%d, want 64x64: the group without tiles grew the bounds", l.Meta.Width, l.Meta.Height)
+ }
+ if c, r := l.Meta.ToPixel(-1000, -1000); image.Pt(int(c), int(r)).In(l.Img.Bounds()) {
+ t.Errorf("the group without tiles is inside the canvas")
+ }
+}
+
+func TestWMOTooManyDrawables(t *testing.T) {
+ _, _, ok, reason := reviewCompose(t, reviewClient(t), 907)
+ if ok {
+ t.Fatal("4096 placements of 5 groups composed without the drawable cap")
+ }
+ if !strings.Contains(reason, "too many WMO groups") {
+ t.Errorf("reason = %q, want the drawable cap", reason)
+ }
+}
+
+func TestWMOTooMuchWork(t *testing.T) {
+ _, _, ok, reason := reviewCompose(t, reviewClient(t), 908)
+ if ok {
+ t.Fatal("1024 tiles composed without the pixel-work cap")
+ }
+ if !strings.Contains(reason, "would sample more than") {
+ t.Errorf("reason = %q, want the pixel-work cap", reason)
+ }
+}
+
+func TestWMODrawStaysInTileRectangles(t *testing.T) {
+ _, info, ok, reason := reviewCompose(t, reviewClient(t), 909)
+ if !ok {
+ t.Fatalf("no minimap: %s", reason)
+ }
+ // One 128 x 128 yd tile at 2 px/yd is 65536 sampled pixels; the group box alone is 2048 yd, or 16.7 M samples.
+ if info.Work <= 0 || info.Work > 100_000 {
+ t.Errorf("work = %d pixels, want the tile rectangle only", info.Work)
+ }
+}
+
+func TestWMOInvalidGroupKeepsPositionalIDs(t *testing.T) {
+ l, info, ok, reason := reviewCompose(t, reviewClient(t), 910)
+ if !ok {
+ t.Fatalf("no minimap: %s", reason)
+ }
+ if info.Tiles != 1 {
+ t.Errorf("tiles = %d, want group 1's tile only (group 0 was rejected)", info.Tiles)
+ }
+ if l.Meta.Width != 64 || l.Meta.Height != 64 {
+ t.Errorf("canvas %dx%d, want group 1's 64x64 box", l.Meta.Width, l.Meta.Height)
+ }
+ // The rejected group 0's red tile must not attach to group 1 (the compacted-slice bug); the picture is group 1's.
+ if got := pixelAt(t, l, -16, -16); got != (color.NRGBA{0, 0, 200, 255}) {
+ t.Errorf("pixel = %v, want group 1's blue: a group id was resolved by compacted position", got)
+ }
+}
+
+func TestWMOOverlayOnlyOnFiveMaps(t *testing.T) {
+ l, _, ok, reason := reviewCompose(t, reviewClient(t), 209)
+ if !ok {
+ t.Fatalf("no minimap: %s", reason)
+ }
+ if l.Meta.From != "" {
+ t.Errorf("from = %q, want empty: map 209 keeps the old terrain minimap", l.Meta.From)
+ }
+ // The terrain tile alone is 256 x 256; the WMO box would have widened the canvas.
+ if l.Meta.Width != 256 || l.Meta.Height != 256 {
+ t.Errorf("canvas %dx%d, want the terrain tile only", l.Meta.Width, l.Meta.Height)
+ }
+ if got := pixelAt(t, l, -16, -16); got != (color.NRGBA{200, 0, 0, 255}) {
+ t.Errorf("pixel = %v, want the terrain red (the WMO's orange is not drawn)", got)
+ }
+}
+
+func TestWMOTerrainUnderlay(t *testing.T) {
+ l, _, ok, reason := reviewCompose(t, reviewClient(t), 36)
+ if !ok {
+ t.Fatalf("no minimap: %s", reason)
+ }
+ if l.Meta.From != fromTerrainWMO {
+ t.Errorf("from = %q, want %q", l.Meta.From, fromTerrainWMO)
+ }
+ // Where there is no indoor art the terrain picture must remain; where the WMO tile lies it must cover it.
+ if got := pixelAt(t, l, -300, -300); got != (color.NRGBA{0, 0, 200, 255}) {
+ t.Errorf("terrain pixel = %v, want the blue half of the terrain tile", got)
+ }
+ if got := pixelAt(t, l, -16, -16); got != (color.NRGBA{230, 140, 0, 255}) {
+ t.Errorf("WMO pixel = %v, want the orange tile over the terrain", got)
+ }
+}
+
+func TestWMOTooManyDrawablesStatus(t *testing.T) {
+ client, out := reviewClient(t), filepath.Join(t.TempDir(), "out")
+ if _, err := Run(Options{ClientDir: client, OutDir: out, Maps: []uint32{907}}); err != nil {
+ t.Fatal(err)
+ }
+ b, err := os.ReadFile(filepath.Join(out, "907.status.json"))
+ if err != nil {
+ t.Fatal("the capped map has no status file:", err)
+ }
+ if !strings.Contains(string(b), "too many WMO groups") {
+ t.Errorf("status = %s, want the drawable cap reason", b)
+ }
+ if _, err := os.Stat(filepath.Join(out, "907.minimap.png")); err == nil {
+ t.Error("the capped map still wrote a minimap picture")
+ }
+}
diff --git a/internal/minimap/wmo_test.go b/internal/minimap/wmo_test.go
new file mode 100644
index 00000000..b4d3c264
--- /dev/null
+++ b/internal/minimap/wmo_test.go
@@ -0,0 +1,378 @@
+package minimap
+
+import (
+ "encoding/binary"
+ "encoding/json"
+ "image"
+ "image/color"
+ "image/png"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/mpq"
+)
+
+// Fixture geometry (yards, group-local): group 0 is 32 x 64 yd with one 64 x 128 px tile; group 1 is 160 x 160 yd at
+// (100, 200) with 2 x 2 tiles, the top row only 64 px high. The WDT map puts the WMO at (0,0,0) with yaw 0, so world
+// = -local; the ADT map places it at pos (100,200,300) with yaw 90.
+const (
+ fixW = 64 // group 0's tile width
+ fixH = 128 // group 0's tile height
+)
+
+func fixChunk(id string, data []byte) []byte {
+ b := make([]byte, 8+len(data))
+ b[0], b[1], b[2], b[3] = id[3], id[2], id[1], id[0]
+ binary.LittleEndian.PutUint32(b[4:], uint32(len(data)))
+ copy(b[8:], data)
+ return b
+}
+
+func fixMODF(nameID, uid uint32, pos, rot [3]float32) []byte {
+ b := make([]byte, 64)
+ binary.LittleEndian.PutUint32(b[0:], nameID)
+ binary.LittleEndian.PutUint32(b[4:], uid)
+ for k := range 3 {
+ binary.LittleEndian.PutUint32(b[8+4*k:], math.Float32bits(pos[k]))
+ binary.LittleEndian.PutUint32(b[20+4*k:], math.Float32bits(rot[k]))
+ }
+ return b
+}
+
+// fixWMO is the root file: MOGI with the two group boxes (flags 0).
+func fixWMO() []byte {
+ var mogi []byte
+ for _, b := range [][6]float32{{0, 0, 0, 32, 64, 10}, {100, 200, 5, 260, 360, 20}} {
+ e := make([]byte, 32)
+ for k, v := range b {
+ binary.LittleEndian.PutUint32(e[4+4*k:], math.Float32bits(v))
+ }
+ mogi = append(mogi, e...)
+ }
+ return fixChunk("MOGI", mogi)
+}
+
+// fixBLP writes a raw BGRA BLP2 tile; paint returns each pixel.
+func fixBLP(w, h int, paint func(x, y int) color.NRGBA) []byte {
+ px := make([]byte, 0, w*h*4)
+ for y := range h {
+ for x := range w {
+ c := paint(x, y)
+ px = append(px, c.B, c.G, c.R, c.A)
+ }
+ }
+ b := make([]byte, 148+1024)
+ copy(b, "BLP2")
+ binary.LittleEndian.PutUint32(b[4:], 1)
+ b[8], b[9], b[10] = 3, 8, 0 // encoding 3 = raw BGRA
+ binary.LittleEndian.PutUint32(b[12:], uint32(w))
+ binary.LittleEndian.PutUint32(b[16:], uint32(h))
+ binary.LittleEndian.PutUint32(b[20:], uint32(len(b)))
+ binary.LittleEndian.PutUint32(b[84:], uint32(len(px)))
+ return append(b, px...)
+}
+
+func fixDot(w, h int, x, y int, c color.NRGBA) []byte {
+ return fixBLP(w, h, func(px, py int) color.NRGBA {
+ if px == x && py == y {
+ return c
+ }
+ return color.NRGBA{}
+ })
+}
+
+// fixCorners is group 0's tile: an 8 x 8 block in the bottom-left corner (blue) and one in the bottom-right (green),
+// both at the tile's bottom edge, so the v flip and the b direction are visible in the composed picture.
+func fixCorners(w, h int) []byte {
+ return fixBLP(w, h, func(x, y int) color.NRGBA {
+ if y < h-8 {
+ return color.NRGBA{}
+ }
+ switch {
+ case x < 8:
+ return color.NRGBA{0, 0, 200, 255}
+ case x >= w-8:
+ return color.NRGBA{0, 200, 0, 255}
+ }
+ return color.NRGBA{}
+ })
+}
+
+// wmoFixtureClient writes a client Data folder holding two dungeon maps: 901 (the WDT places the WMO at the origin) and
+// 902 (one ADT places it at pos (100,200,300), yaw 90). The WMO's minimap tiles are five BLP2 files.
+func wmoFixtureClient(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ const wmoPath = `World\wmo\Test\fixture.wmo`
+ names := []byte(wmoPath + "\x00")
+ wdt := append(fixChunk("MPHD", []byte{1, 0, 0, 0}), fixChunk("MWMO", names)...)
+ wdt = append(wdt, fixChunk("MODF", fixMODF(0, 7, [3]float32{}, [3]float32{}))...)
+ adtWDT := fixChunk("MPHD", []byte{0, 0, 0, 0})
+ main := make([]byte, 64*64*8)
+ binary.LittleEndian.PutUint32(main, 1)
+ adtWDT = append(adtWDT, fixChunk("MAIN", main)...)
+ adt := append(fixChunk("MWMO", names), fixChunk("MWID", []byte{0, 0, 0, 0})...)
+ adt = append(adt, fixChunk("MODF", fixMODF(0, 9, [3]float32{100, 200, 300}, [3]float32{0, 90, 0}))...)
+ trs := strings.Join([]string{
+ `WMO\Test\fixture_000_00_00.blp` + "\tg0.blp",
+ `WMO\Test\fixture_001_00_00.blp` + "\tg100.blp",
+ `WMO\Test\fixture_001_01_00.blp` + "\tg110.blp",
+ `WMO\Test\fixture_001_00_01.blp` + "\tg101.blp",
+ `WMO\Test\fixture_001_01_01.blp` + "\tg111.blp",
+ "",
+ }, "\n")
+ strs := "\x00WMOOnly\x00Wdt Dungeon\x00ADTMap\x00Adt Dungeon\x00"
+ off := func(s string) uint32 { return uint32(strings.Index(strs, s)) }
+ rec := func(id uint32, dir string, name string) []uint32 {
+ r := make([]uint32, 8)
+ r[0], r[1], r[2], r[4] = id, off(dir), InstanceParty, off(name)
+ return r
+ }
+ mapDBC := dbcFile(8, 32, [][]uint32{rec(901, "WMOOnly", "Wdt Dungeon"), rec(902, "ADTMap", "Adt Dungeon")}, strs)
+ files := []mpq.File{
+ {Name: `DBFilesClient\Map.dbc`, Data: mapDBC},
+ {Name: `Textures\Minimap\md5translate.trs`, Data: []byte(trs)},
+ {Name: `Textures\Minimap\g0.blp`, Data: fixCorners(fixW, fixH)},
+ {Name: `Textures\Minimap\g100.blp`, Data: fixDot(256, 256, 0, 255, color.NRGBA{0, 0, 200, 255})},
+ {Name: `Textures\Minimap\g110.blp`, Data: fixDot(256, 256, 0, 255, color.NRGBA{0, 200, 0, 255})},
+ {Name: `Textures\Minimap\g101.blp`, Data: fixDot(256, 64, 0, 63, color.NRGBA{255, 255, 0, 255})},
+ {Name: `Textures\Minimap\g111.blp`, Data: fixDot(256, 64, 0, 63, color.NRGBA{255, 0, 255, 255})},
+ {Name: `World\Maps\WMOOnly\WMOOnly.wdt`, Data: wdt},
+ {Name: `World\Maps\ADTMap\ADTMap.wdt`, Data: adtWDT},
+ {Name: `World\Maps\ADTMap\ADTMap_0_0.adt`, Data: adt},
+ {Name: wmoPath, Data: fixWMO()},
+ }
+ if err := os.WriteFile(filepath.Join(dir, "patch.MPQ"), mpq.Build(files), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+}
+
+// composed returns the minimap layer of a fixture map and its metadata.
+func composed(t *testing.T, client string, id uint32) (Layer, wmoInfo) {
+ t.Helper()
+ set, err := mpq.OpenSet(client, ArchiveOrder(client))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer set.Close()
+ rows, err := ParseMaps(mustRead(t, set, `DBFilesClient\Map.dbc`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := ""
+ for _, r := range rows {
+ if r.ID == id {
+ dir = r.Dir
+ }
+ }
+ trs := ParseTranslate(mustRead(t, set, translatePath))
+ l, info, ok, reason := minimapSource(set, trs, dir, id, nil, t.Logf)
+ if !ok {
+ t.Fatalf("no minimap: %s", reason)
+ }
+ return l, info
+}
+
+func mustRead(t *testing.T, set *mpq.Set, name string) []byte {
+ t.Helper()
+ b, err := set.Read(name)
+ if err != nil {
+ t.Fatalf("read %s: %v", name, err)
+ }
+ return b
+}
+
+func pixelAt(t *testing.T, l Layer, x, y float64) color.NRGBA {
+ t.Helper()
+ c, r := l.Meta.ToPixel(x, y)
+ img := l.Img.(*image.NRGBA)
+ if !image.Pt(int(c), int(r)).In(img.Bounds()) {
+ return color.NRGBA{}
+ }
+ return img.NRGBAAt(int(c), int(r))
+}
+
+func TestWMOOnlyTransform(t *testing.T) {
+ client := wmoFixtureClient(t)
+ l, info := composed(t, client, 901)
+ if l.Meta.From != fromWMO || l.Meta.Source != KindMinimap {
+ t.Fatalf("from %q source %q, want wmo/minimap", l.Meta.From, l.Meta.Source)
+ }
+ if info.Tiles != 5 || info.Placements != 1 || !info.WDT {
+ t.Fatalf("info = %+v", info)
+ }
+ // World = -local (the extra 180 degrees). Each pinned point samples the bottom-left pixel of one tile; the tile
+ // pins are the only opaque pixels in the fixture.
+ for _, c := range []struct {
+ name string
+ x, y float64
+ want color.NRGBA
+ }{
+ {"group 0 bottom-left", -1.75, -1.75, color.NRGBA{0, 0, 200, 255}},
+ {"group 0 bottom-right", -30.25, -1.75, color.NRGBA{0, 200, 0, 255}},
+ {"group 1 a0 b0", -100.25, -200.25, color.NRGBA{0, 0, 200, 255}},
+ {"group 1 a1 b0", -228.25, -200.25, color.NRGBA{0, 200, 0, 255}},
+ {"group 1 a0 b1 (partial top row)", -100.25, -328.25, color.NRGBA{255, 255, 0, 255}},
+ {"group 1 a1 b1 (partial top row)", -228.25, -328.25, color.NRGBA{255, 0, 255, 255}},
+ } {
+ if got := pixelAt(t, l, c.x, c.y); got != c.want {
+ t.Errorf("%s at (%v,%v) = %v, want %v", c.name, c.x, c.y, got, c.want)
+ }
+ }
+ // The top-left pixel of the 64 x 128 tile is transparent: without the v flip the bottom-left block would be there.
+ if got := pixelAt(t, l, -0.25, -63.75); got.A != 0 {
+ t.Errorf("the top-left pixel is painted %v; the tile is not bottom-left anchored", got)
+ }
+}
+
+func TestADTPlacementTransform(t *testing.T) {
+ client := wmoFixtureClient(t)
+ l, info := composed(t, client, 902)
+ if info.WDT || info.Placements != 1 {
+ t.Fatalf("info = %+v, want one ADT placement", info)
+ }
+ // PX = 17066.666 - pos[2] = 16766.666, PY = 17066.666 - pos[0] = 16966.666, yaw 90 with the extra 180:
+ // world X = PX + local y, world Y = PY - local x. The tile is 64 x 128 with an 8 x 8 block in each bottom corner.
+ const px, py = wmoMapCenter - 300, wmoMapCenter - 100
+ for _, c := range []struct {
+ name string
+ x, y float64
+ want color.NRGBA
+ }{
+ {"bottom-left block", px + 1.75, py - 1.75, color.NRGBA{0, 0, 200, 255}},
+ {"bottom-right block", px + 1.75, py - 30.25, color.NRGBA{0, 200, 0, 255}},
+ } {
+ if got := pixelAt(t, l, c.x, c.y); got != c.want {
+ t.Errorf("%s at (%v,%v) = %v, want %v", c.name, c.x, c.y, got, c.want)
+ }
+ }
+}
+
+func TestWMOComposedGolden(t *testing.T) {
+ client := wmoFixtureClient(t)
+ l, _ := composed(t, client, 901)
+ golden := filepath.Join("testdata", "wmo-fixture.png")
+ if os.Getenv("UPDATE_GOLDEN") != "" {
+ if err := os.MkdirAll("testdata", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ f, err := os.Create(golden)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := png.Encode(f, l.Img); err != nil {
+ f.Close()
+ t.Fatal(err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ want := readPNG(t, golden)
+ got := l.Img.(*image.NRGBA)
+ if got.Bounds() != want.Bounds() {
+ t.Fatalf("bounds %v, golden %v", got.Bounds(), want.Bounds())
+ }
+ diff := 0
+ for y := got.Bounds().Min.Y; y < got.Bounds().Max.Y; y++ {
+ for x := got.Bounds().Min.X; x < got.Bounds().Max.X; x++ {
+ c := color.NRGBAModel.Convert(want.At(x, y)).(color.NRGBA)
+ if c.A < 128 {
+ c = color.NRGBA{}
+ } else {
+ c.A = 255
+ }
+ if got.NRGBAAt(x, y) != c {
+ if diff == 0 {
+ t.Errorf("first difference at (%d,%d): %v, golden %v", x, y, got.NRGBAAt(x, y), c)
+ }
+ diff++
+ }
+ }
+ }
+ if diff > 0 {
+ t.Errorf("%d pixels differ from testdata/wmo-fixture.png", diff)
+ }
+}
+
+func TestStatusFile(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "7.status.json")
+ if err := os.WriteFile(p, []byte("{}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeStatus(dir, 7, nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(p); !os.IsNotExist(err) {
+ t.Errorf("a stale status file is not removed: %v", err)
+ }
+ reasons := map[string]string{
+ KindMinimap: "line one\nline two",
+ KindWorldMap: strings.Repeat("x", 300),
+ }
+ if err := writeStatus(dir, 7, reasons); err != nil {
+ t.Fatal(err)
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var sf statusFile
+ if err := json.Unmarshal(b, &sf); err != nil {
+ t.Fatal(err)
+ }
+ if got := sf[KindMinimap].Reason; got != "line one line two" {
+ t.Errorf("minimap reason = %q, want one line", got)
+ }
+ if got := sf[KindWorldMap].Reason; len([]rune(got)) != 160 || !strings.HasSuffix(got, "...") {
+ t.Errorf("worldmap reason %d runes %q, want 160 ending in ...", len([]rune(got)), got)
+ }
+ if _, ok := sf[KindNavmesh]; ok {
+ t.Error("an absent reason wrote a navmesh entry")
+ }
+ if len(b) > 64<<10 {
+ t.Errorf("status file %d bytes, want at most 64 KiB", len(b))
+ }
+}
+
+func TestRunWMOFixtureStatusAndJSON(t *testing.T) {
+ client, out := wmoFixtureClient(t), filepath.Join(t.TempDir(), "out")
+ res, err := Run(Options{ClientDir: client, OutDir: out, Maps: []uint32{901}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(res) != 1 || res[0].Kind != KindMinimap || res[0].Meta.From != fromWMO {
+ t.Fatalf("result = %+v", res)
+ }
+ var m Meta
+ b, err := os.ReadFile(filepath.Join(out, "901.minimap.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(b, &m); err != nil {
+ t.Fatal(err)
+ }
+ if m.From != fromWMO || m.Source != KindMinimap || m.Tiles != 5 {
+ t.Errorf("901.minimap.json = %+v", m)
+ }
+ sb, err := os.ReadFile(filepath.Join(out, "901.status.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var sf statusFile
+ if err := json.Unmarshal(sb, &sf); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := sf[KindMinimap]; ok {
+ t.Errorf("the produced minimap still has a reason: %+v", sf)
+ }
+ if sf[KindWorldMap].Reason == "" || sf[KindNavmesh].Reason == "" {
+ t.Errorf("missing sources have no reason: %+v", sf)
+ }
+}
diff --git a/internal/objects/fields.go b/internal/objects/fields.go
index 72cfdea3..5cae29df 100644
--- a/internal/objects/fields.go
+++ b/internal/objects/fields.go
@@ -64,6 +64,7 @@ const (
UnitFlagSpawning uint32 = 0x00000002
UnitFlagPlayerControlled uint32 = 0x00000008
UnitFlagImmuneToPlayer uint32 = 0x00000100
+ UnitFlagStunned uint32 = 0x00040000
UnitFlagInCombat uint32 = 0x00080000
UnitFlagNotSelectable uint32 = 0x02000000
)
diff --git a/internal/planexport/carrier_chests_test.go b/internal/planexport/carrier_chests_test.go
new file mode 100644
index 00000000..73e3251e
--- /dev/null
+++ b/internal/planexport/carrier_chests_test.go
@@ -0,0 +1,250 @@
+package planexport
+
+// CarrierChests (with DropCompanionsLoot) exports a companions_loot-false leg's loot_object steps as carrier rows: the stand move, the
+// loot row with FlagGoneIsDone, and — when the leg has leftover_entries — the sweep pull of those entries at the chest's point. The
+// shipped Zul'Farrak plan (the zumrah leg, 31 Shallow Graves 128403) is the carrier.
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
+)
+
+// zulfarrakWithoutBly builds the shipped plan with the bly leg skipped (as every export of it does: a boss that turns hostile after
+// his gossip).
+func zulfarrakWithoutBly(t *testing.T, carrier bool) *Result {
+ t.Helper()
+ p, err := dungeon.Load("../../dungeons/zulfarrak.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ opt := Options{Source: "test", DropCompanionsLoot: true, SkipLegs: []string{"bly"}, CarrierChests: carrier}
+ res, err := Build(p, opt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := Validate(res.Rows); err != nil {
+ t.Fatalf("the rows do not validate: %v", err)
+ }
+ return &res
+}
+
+// legRows returns the rows of the named leg.
+func zulfarrakLegRows(t *testing.T, r Rows, name string) []StepRow {
+ t.Helper()
+ n := -1
+ for _, l := range r.Legs {
+ if l.Name == name {
+ n = l.Leg
+ }
+ }
+ if n < 0 {
+ t.Fatalf("no leg row named %s in %+v", name, r.Legs)
+ }
+ var out []StepRow
+ for _, s := range r.Steps {
+ if s.Leg == n {
+ out = append(out, s)
+ }
+ }
+ if len(out) == 0 {
+ t.Fatalf("no step rows of leg %s", name)
+ }
+ return out
+}
+
+// TestCarrierChestsExportGravesAsRows: with CarrierChests the zumrah leg carries a move/loot/pull triple for every one of the plan's
+// 31 Shallow Grave steps, in the plan's order, at the plan's stand and near points, each triple before the trigger move and the boss.
+func TestCarrierChestsExportGravesAsRows(t *testing.T) {
+ p, err := dungeon.Load("../../dungeons/zulfarrak.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ res := zulfarrakWithoutBly(t, true)
+ rows := zulfarrakLegRows(t, res.Rows, "zumrah")
+ var graves []dungeon.LootObject
+ for _, l := range p.Legs {
+ if l.Name == "zumrah" {
+ for _, st := range l.Steps {
+ if st.LootObject != nil {
+ graves = append(graves, *st.LootObject)
+ }
+ }
+ }
+ }
+ if len(graves) != 31 {
+ t.Fatalf("the plan's zumrah leg has %d loot_object steps, want 31", len(graves))
+ }
+ // The zumrah row: via move, to move, then move loot pull per grave, then the trigger move, then the boss.
+ need := 2 + 3*len(graves) + 2
+ if len(rows) != need {
+ t.Fatalf("the zumrah leg has %d rows, want %d (via, to, 3 per grave, trigger, boss)", len(rows), need)
+ }
+ gi := 0
+ for k := 0; 2+3*k+2 < len(rows); k++ {
+ if gi >= len(graves) {
+ break
+ }
+ lo := graves[gi]
+ gi++
+ m, l, sw := rows[2+3*k], rows[3+3*k], rows[4+3*k]
+ if m.Kind != KindMove || !strings.HasPrefix(m.Comment, "stand") {
+ t.Fatalf("grave %d: first row is %s %q", gi, m.Kind, m.Comment)
+ }
+ if float32(lo.Stand.X) != m.X || float32(lo.Stand.Y) != m.Y || float32(lo.Stand.Z) != m.Z {
+ t.Fatalf("grave %d: stand is %.2f %.2f %.2f, want the plan's %v", gi, m.X, m.Y, m.Z, *lo.Stand)
+ }
+ if l.Kind != KindLoot || l.Entry != lo.Entry {
+ t.Fatalf("grave %d: second row is %s %d", gi, l.Kind, l.Entry)
+ }
+ if l.Flags != FlagGoneIsDone {
+ t.Fatalf("grave %d: loot row flags %d, want %d (gone is done)", gi, l.Flags, FlagGoneIsDone)
+ }
+ if l.Item != itemEntry(lo.ExpectItem) {
+ t.Fatalf("grave %d: loot item %d", gi, l.Item)
+ }
+ if float32(lo.Near.X) != l.X || float32(lo.Near.Y) != l.Y || float32(lo.Near.Z) != l.Z {
+ t.Fatalf("grave %d: loot point %.2f %.2f %.2f, want the plan's near %v", gi, l.X, l.Y, l.Z, *lo.Near)
+ }
+ if sw.Kind != KindPull || !strings.HasPrefix(sw.Comment, "sweep") {
+ t.Fatalf("grave %d: third row is %s %q", gi, sw.Kind, sw.Comment)
+ }
+ if sw.Entry != 0 {
+ t.Fatalf("grave %d: the sweep's entry column holds %d", gi, sw.Entry)
+ }
+ if fmt.Sprint(sw.Entries) != "[7286 7276]" {
+ t.Fatalf("grave %d: sweep entries %v", gi, sw.Entries)
+ }
+ if sw.Radius != carrierSweepRadiusYd {
+ t.Fatalf("grave %d: sweep radius %.1f", gi, sw.Radius)
+ }
+ if float32(lo.Near.X) != sw.X || float32(lo.Near.Y) != sw.Y {
+ t.Fatalf("grave %d: sweep point %.2f %.2f, want the grave at %.2f %.2f", gi, sw.X, sw.Y, lo.Near.X, lo.Near.Y)
+ }
+ }
+ if gi != len(graves) {
+ t.Fatalf("wrote %d grave triples, want %d", gi, len(graves))
+ }
+ trig := rows[len(rows)-2]
+ if trig.Kind != KindMove || !strings.Contains(trig.Comment, "area trigger 962") {
+ t.Fatalf("the row before the boss is %+v", trig)
+ }
+ boss := rows[len(rows)-1]
+ if boss.Kind != KindBoss || boss.Entry != 7271 {
+ t.Fatalf("the last row is %+v", boss)
+ }
+ // No 128308 grave anywhere (the trap graves only), and the plan's order is the rows' order.
+ for i, s := range rows {
+ if s.Entry == 128308 {
+ t.Fatalf("row %d loots the 128308 grave (%s)", i, s.Kind)
+ }
+ }
+ back, err := Parse(WriteSQL([]Rows{res.Rows}))
+ if err != nil || Equal([]Rows{res.Rows}, back) != nil {
+ t.Fatalf("%v", err)
+ }
+}
+
+// TestCarrierChestsOffKeepsOld: without the option the zumrah leg is as before — no chest of it in the rows, the steps-left-out
+// warning — and the whole plan has no FlagGoneIsDone row.
+func TestCarrierChestsOffKeepsOld(t *testing.T) {
+ res := zulfarrakWithoutBly(t, false)
+ rows := zulfarrakLegRows(t, res.Rows, "zumrah")
+ kinds := make([]string, len(rows))
+ for i, s := range rows {
+ kinds[i] = s.Kind
+ if s.Entry == 128403 {
+ t.Fatalf("row %d (%s) loots a grave without CarrierChests", i, s.Kind)
+ }
+ if s.Flags&FlagGoneIsDone != 0 {
+ t.Fatalf("row %d carries the gone-is-done flag without CarrierChests", i)
+ }
+ }
+ // via, to, the trigger move, the boss: the four rows the leg had.
+ if got := strings.Join(kinds, " "); !strings.HasPrefix(got, "move move ") || !strings.HasSuffix(got, " move boss") || len(kinds) > 4 {
+ t.Fatalf("the zumrah leg without CarrierChests: %s (%d rows)", got, len(kinds))
+ }
+ warn := false
+ for _, w := range res.Warnings {
+ warn = warn || strings.Contains(w, "loot_object steps left out")
+ }
+ if !warn {
+ t.Fatalf("no left-out warning: %v", res.Warnings)
+ }
+}
+
+// TestCarrierChestsNeedsDropCompanionsLoot: without DropCompanionsLoot the plan is refused all the same; a loader that knows no
+// gone-is-done flag (a loot/open row only) would refuse the flagged rows, so Validate refuses the flag on any other kind.
+func TestCarrierChestsNeedsDropCompanionsLoot(t *testing.T) {
+ p, err := dungeon.Load("../../dungeons/zulfarrak.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Build(p, Options{Source: "test", CarrierChests: true, SkipLegs: []string{"bly"}}); err == nil || !strings.Contains(err.Error(), "--drop-companions-loot") {
+ t.Fatalf("CarrierChests without DropCompanionsLoot: %v", err)
+ }
+ r := Rows{Plan: PlanRow{Plan: "zf"}, Steps: []StepRow{{Kind: KindMove}}}
+ r.Steps[0].Flags = FlagGoneIsDone
+ if err := Validate(r); err == nil || !strings.Contains(err.Error(), "gone-is-done flag is for a loot or open row") {
+ t.Fatalf("the flag on a move row: %v", err)
+ }
+ r.Steps[0] = StepRow{Kind: KindPull, Entries: []uint32{7286}, Flags: FlagGoneIsDone}
+ if err := Validate(r); err == nil || !strings.Contains(err.Error(), "gone-is-done flag is for a loot or open row") {
+ t.Fatalf("the flag on a pull row: %v", err)
+ }
+}
+
+// TestCarrierChestsWarnings: with CarrierChests the companions_loot warning names the setup (the trapped-chest rule a module would
+// add) instead of dropping, and the leg's leftover_entries are no longer warned unexpressed (every chest's sweep pull carries them).
+// The loot_object batch warning stays: one chest a row, no batches.
+func TestCarrierChestsWarnings(t *testing.T) {
+ res := zulfarrakWithoutBly(t, true)
+ text := strings.Join(res.Warnings, "\n")
+ if !strings.Contains(text, "not carried: turn the bots' looting off for this leg (setup), or a module with the trapped-chest rule") {
+ t.Fatalf("no setup warning: %v", res.Warnings)
+ }
+ for _, want := range []string{
+ "looting off for every bot",
+ "party loot method free-for-all",
+ "Executioner's Key 8444 on the lead bot",
+ "at least 16 free bag slots on the lead bot",
+ "a fresh instance",
+ "flag 256 needs a TortoiseBots module with the gone-is-done step flag",
+ "pauses on a despawned grave after a wipe (then a .bot run skip skips the row)",
+ } {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the setup warning lacks %q: %v", want, res.Warnings)
+ }
+ }
+ if strings.Contains(text, "dropped (--drop-companions-loot): the bots loot as they like") {
+ t.Fatalf("the old dropped text under CarrierChests: %v", res.Warnings)
+ }
+ if strings.Contains(text, "leftover_entries") {
+ t.Fatalf("the leftovers are swept by the chest rows, not unexpressed: %v", res.Warnings)
+ }
+ if !strings.Contains(text, "loot_object batch") {
+ t.Fatalf("the batch warning is kept: %v", res.Warnings)
+ }
+ // Without CarrierChests the old texts come back.
+ without := zulfarrakWithoutBly(t, false)
+ old := strings.Join(without.Warnings, "\n")
+ if !strings.Contains(old, "dropped (--drop-companions-loot): the bots loot as they like") || strings.Contains(old, "not carried: turn") {
+ t.Fatalf("the old texts under DropCompanionsLoot alone: %v", without.Warnings)
+ }
+}
+
+// TestCarrierChestsRefusesAStandlessChest: with CarrierChests a loot_object with no stand has no row form (the chest rows walk the
+// party to a stand before the loot) and is refused, naming the step's entry.
+func TestCarrierChestsRefusesAStandlessChest(t *testing.T) {
+ p := parsePlan(t, `{"version":1,"name":"fc","status":"live-checked","map":209,"entrance":{"x":0,"y":0,"z":0},"legs":[{"name":"grave","to":{"x":20,"y":0,"z":0},"companions_loot":false,"steps":[{"loot_object":{"entry":128403,"near":{"x":10,"y":0,"z":0}}}]}]}`)
+ _, err := Build(p, Options{Source: "test", DropCompanionsLoot: true, CarrierChests: true})
+ if err == nil || !strings.Contains(err.Error(), "leg 0 (grave): steps[0]: loot_object 128403 has no stand") {
+ t.Fatalf("a chest without a stand: %v", err)
+ }
+ // Without CarrierChests the step is left out as before, not refused.
+ if _, err := Build(p, Options{Source: "test", DropCompanionsLoot: true}); err != nil {
+ t.Fatalf("without CarrierChests: %v", err)
+ }
+}
diff --git a/internal/planexport/gossip_test.go b/internal/planexport/gossip_test.go
index 5f0d6780..8a0a6c94 100644
--- a/internal/planexport/gossip_test.go
+++ b/internal/planexport/gossip_test.go
@@ -1,17 +1,135 @@
package planexport
import (
+ "errors"
+ "fmt"
+ "slices"
"strings"
"testing"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
)
-// The run leader has no gossip step: a plan with one is refused with the reason (the same way as the other steps it cannot carry), not
-// exported without the step.
-func TestGossipStepIsRefused(t *testing.T) {
- p := parsePlan(t, `{"version":1,"name":"zf","status":"live-checked (test)","map":209,"entrance":{"x":0,"y":0,"z":0},"legs":[
- {"name":"end","to":{"x":10,"y":0,"z":0},"steps":[{"gossip":{"npc":7607,"near":{"x":12,"y":0,"z":0},"text":"blow","by":"tank"}}]}]}`)
- _, err := Build(p, Options{Source: "test"})
- if err == nil || !strings.Contains(err.Error(), "gossip (NPC 7607") || !strings.Contains(err.Error(), "cannot be exported") {
+// gossipPlan is a one-leg plan with one gossip step (the fields of its JSON object) followed by the End Door wait.
+func gossipPlan(t *testing.T, fields string) *dungeon.Plan {
+ t.Helper()
+ return parsePlan(t, `{"version":1,"name":"zf","status":"live-checked (test)","map":209,"entrance":{"x":0,"y":0,"z":0},"legs":[
+ {"name":"end","to":{"x":10,"y":0,"z":0},"steps":[{"gossip":{"npc":7607,"near":{"x":12,"y":1,"z":2},`+fields+`}},
+ {"wait_for":{"gameobject":{"entry":146084,"near":{"x":30,"y":0,"z":0},"state":"active"},"timeout_s":60}}]}]}`)
+}
+
+func gossipRow(t *testing.T, r Rows) StepRow {
+ t.Helper()
+ for _, s := range r.Steps {
+ if s.Kind == KindGossip {
+ return s
+ }
+ }
+ t.Fatalf("no gossip row in %v", kinds(r, 0))
+ return StepRow{}
+}
+
+// A gossip step with an option number and a bot talker is a gossip row: entry the NPC, x y z where it stands, param the option the way
+// `.bot cmd gossip` counts it (from 1; the plan's option is the menu's own index from 0, so +1), no flags, timeout 0 (the module's 60 s).
+func TestGossipStepIsARow(t *testing.T) {
+ res, err := Build(gossipPlan(t, `"option":0,"by":"tank","stand":{"x":12,"y":4,"z":2},"retry_s":30,"fail_text":"no menu"`), Options{Source: "test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Cut != "" {
+ t.Fatalf("cut: %s", res.Cut)
+ }
+ g := gossipRow(t, res.Rows)
+ if got := fmt.Sprintf("%d %g %g %g r%g i%d p%d f%d t%d %v", g.Entry, g.X, g.Y, g.Z, g.Radius, g.Item, g.Param, g.Flags, g.TimeoutS, g.Entries); got != "7607 12 1 2 r0 i0 p1 f0 t0 []" {
+ t.Errorf("row %s", got)
+ }
+ if !strings.Contains(g.Comment, "menu index 0 (cmd gossip option 1)") || !strings.Contains(g.Comment, "no menu") {
+ t.Errorf("comment %q", g.Comment)
+ }
+ if got := strings.Join(kinds(res.Rows, 0), " "); got != "move gossip wait_object" {
+ t.Errorf("kinds %s", got)
+ }
+ if w := strings.Join(res.Warnings, "\n"); !strings.Contains(w, "gossip stand/by/retry_s (leg 0 (end) steps[0]): not in the rows") {
+ t.Errorf("no warning for what the row does not carry: %s", w)
+ }
+ // The option 1 is param 2; "carrier" talks as well; the SQL reads back.
+ res, err = Build(gossipPlan(t, `"option":1,"by":"carrier"`), Options{Source: "test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if g := gossipRow(t, res.Rows); g.Param != 2 {
+ t.Errorf("param %d", g.Param)
+ }
+ back, err := Parse(WriteSQL([]Rows{res.Rows}))
+ if err != nil || Equal([]Rows{res.Rows}, back) != nil {
t.Fatalf("%v", err)
}
}
+
+// The highest row param is 15 (a menu holds 15 options), so the highest plan option is 14; 15 is an error of the plan, not a cut.
+func TestGossipOptionAboveTheMenuIsRefused(t *testing.T) {
+ if res, err := Build(gossipPlan(t, `"option":14,"by":"tank"`), Options{Source: "test"}); err != nil || gossipRow(t, res.Rows).Param != 15 {
+ t.Fatalf("option 14: %v", err)
+ }
+ _, err := Build(gossipPlan(t, `"option":15,"by":"tank"`), Options{Source: "test"})
+ if err == nil || errors.Is(err, ErrNoRunLeaderStep) || !strings.Contains(err.Error(), "holds at most 15") {
+ t.Fatalf("%v", err)
+ }
+}
+
+// A gossip the run leader's row cannot carry is a step it cannot do (the export is cut at its leg, as before): one that names its option by
+// text (the row has a number), and one the agent's own character talks (a bound party has none).
+func TestGossipByTextOrByAgentIsStillCut(t *testing.T) {
+ for _, tc := range []struct{ fields, want string }{
+ {`"text":"blow","by":"tank"`, "names its option by text"},
+ {`"option":0`, `by "agent"`},
+ {`"option":0,"by":"agent"`, `by "agent"`},
+ } {
+ _, err := Build(gossipPlan(t, tc.fields), Options{Source: "test"})
+ if err == nil || !errors.Is(err, ErrNoRunLeaderStep) || !strings.Contains(err.Error(), "gossip (NPC 7607") || !strings.Contains(err.Error(), tc.want) {
+ t.Errorf("%s: %v", tc.fields, err)
+ }
+ }
+}
+
+// Validate is the module loader's rule for a gossip row: an entry, a param of 1-15, no flag.
+func TestValidateGossipRow(t *testing.T) {
+ row := func(entry, param, flags uint32) Rows {
+ return Rows{Steps: []StepRow{{Plan: "p", Kind: KindGossip, Entry: entry, Param: param, Flags: flags}}}
+ }
+ for _, tc := range []struct {
+ entry, param, flags uint32
+ ok bool
+ }{{7607, 1, 0, true}, {7607, 15, 0, true}, {0, 1, 0, false}, {7607, 0, 0, false}, {7607, 16, 0, false}, {7607, 1, FlagAlliance, false}} {
+ if err := Validate(row(tc.entry, tc.param, tc.flags)); (err == nil) != tc.ok {
+ t.Errorf("%+v: %v", tc, err)
+ }
+ }
+}
+
+// SkipLegs leaves a named leg out with a warning, and the later legs close up; a name that is no leg is an error.
+func TestSkipLegsClosesUp(t *testing.T) {
+ const js = `{"version":1,"name":"skip","status":"live-checked (test)","map":209,"entrance":{"x":0,"y":0,"z":0},"legs":[
+ {"name":"a","to":{"x":1,"y":0,"z":0},"pull_groups":[{"entries":[1],"positions":[{"x":5,"y":0,"z":0}]}]},
+ {"name":"b","to":{"x":2,"y":0,"z":0},"steps":[{"wait_for":{"unit":{"entries":[7604],"state":"seen"},"timeout_s":60}}]},
+ {"name":"c","to":{"x":3,"y":0,"z":0},"pull_groups":[{"entries":[2],"positions":[{"x":6,"y":0,"z":0}]}]}]}`
+ // Without it the plan is cut before b; with it, a and c are the rows (legs 0 and 1).
+ if res, err := Build(parsePlan(t, js), Options{Source: "test"}); err != nil || len(res.Rows.Legs) != 1 || res.Cut == "" {
+ t.Fatalf("no skip: %v %+v", err, res.Rows.Legs)
+ }
+ res, err := Build(parsePlan(t, js), Options{Source: "test", SkipLegs: []string{"b"}})
+ if err != nil || res.Cut != "" {
+ t.Fatalf("%v %s", err, res.Cut)
+ }
+ if len(res.Rows.Legs) != 2 || res.Rows.Legs[0].Name != "a" || res.Rows.Legs[1].Name != "c" || res.Rows.Legs[1].Leg != 1 {
+ t.Errorf("legs %+v", res.Rows.Legs)
+ }
+ if !slices.ContainsFunc(res.Warnings, func(w string) bool {
+ return strings.Contains(w, "leg 1 (b): left out of the export by the exporter's skip-legs option")
+ }) {
+ t.Errorf("no warning: %v", res.Warnings)
+ }
+ if _, err := Build(parsePlan(t, js), Options{Source: "test", SkipLegs: []string{"bly"}}); err == nil || !strings.Contains(err.Error(), `skip-legs names "bly"`) {
+ t.Errorf("an unknown leg name: %v", err)
+ }
+}
diff --git a/internal/planexport/hold_value_test.go b/internal/planexport/hold_value_test.go
new file mode 100644
index 00000000..aa05adb7
--- /dev/null
+++ b/internal/planexport/hold_value_test.go
@@ -0,0 +1,126 @@
+package planexport
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+// A wait_for that carries a hold is exported as that hold row (the wait itself, a unit wait, has no run leader step), and a hold with a
+// value is done by that instance data value read as a script value: flag 8 alone (no encounter flag 32), param = the value. A pull group's
+// hold (no value: the plan refuses one there) keeps the encounter DONE (param 3, flags 40). Zul'Farrak's pyramid phase (instance data 1) is the case.
+func TestWaitHoldIsTheRowAndValueIsAScriptValue(t *testing.T) {
+ const js = `{"version":1,"name":"zf","status":"live-checked (test)","map":209,"entrance":{"x":0,"y":0,"z":0},"legs":[
+ {"name":"cage","to":{"x":10,"y":0,"z":0},
+ "steps":[{"use_object":{"entry":141073,"near":{"x":12,"y":0,"z":0},"requires_item":{"entry":8444,"name":"Key"}}},
+ {"wait_for":{"unit":{"entries":[7607],"near":{"x":20,"y":0,"z":0},"radius":15,"state":"seen"},"timeout_s":60,
+ "hold":{"instance_data":1,"value":3,"spot":{"x":20,"y":0,"z":0},"radius":30,"timeout_s":90,"entries":[7787,7788,7787]}}}]},
+ {"name":"top","to":{"x":20,"y":0,"z":0},
+ "steps":[{"wait_for":{"unit":{"entries":[7796],"state":"seen"},"timeout_s":600,
+ "hold":{"instance_data":1,"value":7,"spot":{"x":20,"y":0,"z":0},"radius":30,"timeout_s":600}}}]},
+ {"name":"group","to":{"x":40,"y":0,"z":0},
+ "pull_groups":[{"entries":[4858],"positions":[{"x":50,"y":0,"z":0}],"hold":{"instance_data":2,"spot":{"x":50,"y":0,"z":0},"radius":20,"timeout_s":300}}]}]}`
+ res, err := Build(parsePlan(t, js), Options{Source: "test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Cut != "" {
+ t.Fatalf("a unit wait with a hold must not cut the plan: %s", res.Cut)
+ }
+ r := res.Rows
+ if got := strings.Join(kinds(r, 0), " "); got != "move open hold" {
+ t.Fatalf("leg 0: %s", got)
+ }
+ // The move to the hold's own spot (leg 1's to) is left out: the hold walks there.
+ if got := strings.Join(kinds(r, 1), " "); got != "hold" {
+ t.Fatalf("leg 1: %s", got)
+ }
+ row := func(leg, seq int) string {
+ for _, s := range r.Steps {
+ if s.Leg == leg && s.Seq == seq {
+ return fmt.Sprintf("%s %d %g %g %g r%g p%d f%d t%d %v", s.Kind, s.Entry, s.X, s.Y, s.Z, s.Radius, s.Param, s.Flags, s.TimeoutS, s.Entries)
+ }
+ }
+ return "none"
+ }
+ for _, tc := range []struct {
+ leg, seq int
+ want string
+ }{
+ {0, 2, "hold 1 20 0 0 r30 p3 f8 t90 [7787 7788]"},
+ {1, 0, "hold 1 20 0 0 r30 p7 f8 t600 []"},
+ {2, 1, "hold 2 50 0 0 r20 p3 f40 t300 [4858]"},
+ } {
+ if got := row(tc.leg, tc.seq); got != tc.want {
+ t.Errorf("leg %d step %d: %s, want %s", tc.leg, tc.seq, got, tc.want)
+ }
+ }
+ for _, s := range r.Steps {
+ if s.Kind == KindHold && s.Flags == FlagDoneData && !strings.Contains(s.Comment, "(a script value, not an encounter state)") {
+ t.Errorf("a value hold's comment does not say so: %q", s.Comment)
+ }
+ }
+ if w := strings.Join(res.Warnings, "\n"); !strings.Contains(w, "wait_for with a hold (leg 0 (cage) steps[1]; leg 1 (top) steps[0]): not in the rows") {
+ t.Errorf("no warning that the waits' own conditions are the agent's only: %s", w)
+ }
+ back, err := Parse(WriteSQL([]Rows{r}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := Equal([]Rows{r}, back); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Validate takes a hold's param above 4 only as a script value (flag 8 without 32), the module loader's rule (NormalizePlanStep): an
+// encounter state is 0-4.
+func TestValidateHoldParamByFlags(t *testing.T) {
+ hold := func(param, flags uint32) Rows {
+ return Rows{Steps: []StepRow{{Plan: "p", Kind: KindHold, Entry: 1, Param: param, Flags: flags}}}
+ }
+ if err := Validate(hold(7, FlagDoneData)); err != nil {
+ t.Errorf("a script value 7: %v", err)
+ }
+ if err := Validate(hold(7, FlagDoneData|FlagEncounter)); err == nil || !strings.Contains(err.Error(), "no encounter state") {
+ t.Errorf("an encounter state 7 must be refused: %v", err)
+ }
+ if err := Validate(hold(3, FlagDoneData|FlagEncounter)); err != nil {
+ t.Errorf("DONE: %v", err)
+ }
+}
+
+// The shipped Zul'Farrak plan's stairs event in the rows: the Executioner, his key, the cage with the key, then the two holds on the stairs
+// top (pyramid phase 3, wave 1 under way; phase 7, wave 2 dead and wave 3 up), then Nekrum and Sezz'ziz at the foot and the wait until the
+// foot is clear. Instance data 1 is the pyramid phase (tortoise-wow zulfarrak.h EVENT_PYRAMID, instance_zulfarrak.cpp GetData).
+func TestZulfarrakStairsEventRows(t *testing.T) {
+ res, err := Build(zulfarrakThrough(t, "stairs_base"), Options{Source: "test", DropCompanionsLoot: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ leg := map[string]int{}
+ for _, l := range res.Rows.Legs {
+ leg[l.Name] = l.Leg
+ }
+ var got []string
+ for _, s := range res.Rows.Steps {
+ if s.Leg < leg["stairs"] || s.Kind == KindMove && (s.Comment == "path" || s.Comment == "via") {
+ continue
+ }
+ got = append(got, fmt.Sprintf("%d %s %d %g %g %g i%d p%d f%d t%d %v", s.Leg-leg["stairs"], s.Kind, s.Entry, s.X, s.Y, s.Z, s.Item, s.Param, s.Flags, s.TimeoutS, s.Entries))
+ }
+ want := []string{
+ "0 move 0 1887 1274 42 i0 p0 f0 t0 []",
+ "0 boss 7274 1886.8 1286.8 44.2 i0 p0 f0 t0 []",
+ "0 loot 7274 1886.8 1286.8 44.2 i8444 p0 f0 t0 []",
+ "0 move 0 1886.9 1295.4 48.3 i0 p0 f0 t0 []",
+ "0 open 141073 1886.9 1298.9 48.2 i8444 p0 f0 t0 []",
+ "0 hold 1 1887 1274 42 i0 p3 f8 t90 [7787 7788 7789 8876 8877]",
+ "1 hold 1 1887 1274 42 i0 p7 f8 t600 [7787 7788 7789 8876 8877]",
+ "2 move 0 1885 1232 12.6 i0 p0 f0 t0 []",
+ "2 boss 7275 1889.23 1207.72 8.87 i0 p0 f0 t0 [7796]",
+ "2 wait_clear 0 1886 1215 9 i0 p0 f0 t180 []",
+ }
+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("stairs rows:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+}
diff --git a/internal/planexport/planexport.go b/internal/planexport/planexport.go
index f2123de6..efebe49f 100644
--- a/internal/planexport/planexport.go
+++ b/internal/planexport/planexport.go
@@ -22,16 +22,22 @@ import (
const (
KindMove = "move"
KindMoveDirect = "move_direct"
- KindPull = "pull"
- KindBoss = "boss"
- KindLoot = "loot"
- KindOpen = "open"
- KindUseItem = "use_item"
- KindWaitObject = "wait_object"
- KindWaitClear = "wait_clear"
- KindRest = "rest"
- KindRitual = "ritual"
- KindHold = "hold"
+ // KindMoveHopExact is the direct step right after an exact via (FlagExact): a move_direct in every way for a module that knows
+ // exact waypoints, and a kind an older module does not know, so its loader skips the row (UnknownKind) and no direct order goes
+ // out over the hole from a foot it walks as a plain move (fail closed against a binary rollback).
+ KindMoveHopExact = "move_hop_exact"
+ KindPull = "pull"
+ KindBoss = "boss"
+ KindLoot = "loot"
+ KindOpen = "open"
+ KindUseItem = "use_item"
+ KindWaitObject = "wait_object"
+ KindWaitClear = "wait_clear"
+ KindRest = "rest"
+ KindRitual = "ritual"
+ KindHold = "hold"
+ // KindGossip is a gossip step: the lead bot talks to creature `entry` and picks option `param` (`.bot cmd gossip `).
+ KindGossip = "gossip"
)
// Flags of ai_dungeon_step.flags.
@@ -43,6 +49,16 @@ const (
// (DungeonPlanPolicy.h kPlanFlagDoneData, kPlanFlagEncounter).
FlagDoneData = 8
FlagEncounter = 32
+ // FlagGoneIsDone marks a loot or open row as done when its object is gone (the module's kPlanFlagGoneIsDone): a `not-found` answer
+ // for a loot of no item with this flag ends the step ("nothing of entry N left near the point: done") instead of pausing the run.
+ // A chest of spawntimesecs 7200 (Zul'Farrak's Shallow Grave, entry 128403) despawns once looted; the flag matters only for a run
+ // resumed after a wipe at one of these rows (a fresh run does not need it). A loader that knows no such flag drops it (unknown flags
+ // are ignored on a loot/open), so the rows still load.
+ FlagGoneIsDone = 256
+ // FlagExact marks a move row as an exact waypoint (the plan's Waypoint.Exact): the run leader counts it done only once the lead bot
+ // stands within 1.5 yd of it and has stood still there for 2 s, and only then sends the move_direct after it (TortoiseBots
+ // DungeonPlanPolicy.h kPlanFlagExact, DungeonRunPolicy.h ExactWalkTo). The loader refuses it on any other kind.
+ FlagExact = 64
// EncounterDone is the encounter state DONE: a hold's param.
EncounterDone = 3
)
@@ -52,6 +68,7 @@ const (
MaxRadius = 100 // yards
MaxTimeoutS = 600 // seconds
MaxEntries = 16 // creature entries of a pull or boss
+ MaxKind = 16 // ai_dungeon_step.kind is a varchar(16): a longer kind fails the INSERT (or is cut)
MaxPlanName = 32 // ai_dungeon_plan.plan
MaxLegName = 32 // ai_dungeon_leg.name
MaxPlanList = 255 // ai_dungeon_plan.cc_priority, cc_always, kill_first (varchar(255))
@@ -64,6 +81,9 @@ const (
MoveGapWarnYd = 60 // the module logs consecutive moves farther apart than this
MoveGapCutYd = 50 // the longest hop the exporter leaves when it cuts a leg into via points
DefaultBossRad = 30
+ // MaxGossipOption is the highest `param` of a gossip row: the number `.bot cmd gossip` takes counts the menu from 1 (a 1.12 menu holds 15
+ // options), while the plan's `option` is the menu's own index counted from 0 (the CMSG_GOSSIP_SELECT_OPTION value): the row's param is option + 1.
+ MaxGossipOption = 15
)
// PlanRow is one ai_dungeon_plan row.
@@ -123,6 +143,15 @@ type Options struct {
// column for it (TortoiseBots' run leader has no such setting), so the bots would loot what the leg wants left alone (Zul'Farrak's
// graveyard chests). Without this option such a plan is refused.
DropCompanionsLoot bool
+ // CarrierChests, with DropCompanionsLoot, exports a companions_loot-false leg's loot_object steps as chest rows the carrier alone
+ // takes: a move to its stand, a loot row (entry, near point, the expected item, FlagGoneIsDone), and, when the leg has
+ // LeftoverEntries, a pull row of those entries at the chest's near point (radius carrierSweepRadiusYd, "sweep"): the trap a grave
+ // (Zul'Farrak's Shallow Graves) is linked to raises its adds on the opener in the same server call, so the loot row can never be
+ // safe without the sweep behind it, and one chest a row (no batch) keeps them two at a time. The companions_loot setting itself is
+ // still not in the rows: the bots' looting has to be turned off for the leg in the setup, or a module's auto-loot rule has to skip a
+ // trapped chest. Without the option the loot_object steps are left out as before. Needs DropCompanionsLoot (the chest would not be
+ // in the rows without it anyway).
+ CarrierChests bool
// ApproachHops, with Cut, also cuts the hop to a pull, boss or hold row that is more than the run leader's reach (50 yd) from the
// anchor before it (the move, or the fight, the party stood at) into move rows along the planned path, the last of them at least
// 25 yd short of the target. Without it such a hop is left to the run leader's own navmesh hops (at most 8 of 40 yd, stopping 30 yd
@@ -132,6 +161,11 @@ type Options struct {
// run leader has no GM move. Only right where the gap is a drop the straight line crosses (Wailing Caverns' ramp to Lord Serpentis,
// 6 yd down over a 3.9 yd gap); the row's comment and a warning say it is not tried live.
GMCrossDirect bool
+ // SkipLegs names legs (by their name in the plan) that are left out of the rows, each with a warning, and the later legs close up, as for
+ // an optional leg. For a leg the run leader cannot do while the legs after it can: Zul'Farrak's Sergeant Bly, who turns hostile after his
+ // gossip (the run leader has no boss that turns hostile later), stands between the Weegli gossip that opens the End Door and the legs behind
+ // that door. An unknown name is an error (a misspelling would otherwise export the leg or cut the plan there).
+ SkipLegs []string
// Route, when set, is called for every hop between two anchors of the rows (move, pull, boss, hold) of a plan with avoid boxes: it
// returns the via points that keep the run leader's own navmesh path (the server's, which does not know the boxes) out of them,
// none when that path is already clear. An error means a hop that cannot be made safe: the export fails.
@@ -197,6 +231,11 @@ func Build(p *dungeon.Plan, opt Options) (Result, error) {
return Result{}, fmt.Errorf("plan %s: %s is %d characters as a comma list (ai_dungeon_plan.%s holds %d)", name, l.col, n, l.col, MaxPlanList)
}
}
+ for _, n := range opt.SkipLegs {
+ if !slices.ContainsFunc(p.Legs, func(l dungeon.Leg) bool { return l.Name == n }) {
+ return Result{}, fmt.Errorf("plan %s: skip-legs names %q, which is no leg of the plan", name, n)
+ }
+ }
b := &builder{opt: opt, plan: p, name: name}
b.rows.Plan = PlanRow{Plan: name, Map: p.Map, CCPriority: slices.Clone(p.CCPriority), CCAlways: slices.Clone(p.CCAlways), KillFirst: slices.Clone(p.KillFirst), Source: clip(source, MaxSource), Comment: clip(planComment(p), MaxComment)}
cut, cutLeg := "", 0
@@ -230,12 +269,35 @@ func Build(p *dungeon.Plan, opt Options) (Result, error) {
b.warnings = append(b.warnings, w.String())
}
}
+ upto := len(p.Legs)
+ if cut != "" {
+ upto = cutLeg
+ }
+ b.warnings = append(b.warnings, unexpressed(p, opt, upto)...)
if err := Validate(b.rows); err != nil {
return Result{}, err
}
return Result{Rows: b.rows, Warnings: b.warnings, Cut: cut}, nil
}
+// leaveComment is the leg row's comment for the leg's `leave` list. The rows have no column or step kind for a spawn the run leaves alone (a
+// new kind is skipped and logged by the loader, and flags 1, 2 and 4 are taken), so the list is free text in the leg's comment column: for
+// the reader, and for the module part that will read it (PLAN-ZF-LESSONS #3). Empty: no comment.
+func leaveComment(l dungeon.Leg) string {
+ if len(l.Leave) == 0 {
+ return ""
+ }
+ parts := make([]string, len(l.Leave))
+ for i, lv := range l.Leave {
+ spawn := ""
+ if lv.Spawn != 0 {
+ spawn = fmt.Sprintf(" spawn %d", lv.Spawn)
+ }
+ parts[i] = fmt.Sprintf("%d%s at %s %s: %s", lv.Entry, spawn, num(lv.At.X), num(lv.At.Y), lv.Why)
+ }
+ return strings.TrimSpace(clip("leave (not read by the run leader yet): "+strings.Join(parts, "; "), MaxComment))
+}
+
func planComment(p *dungeon.Plan) string {
c := "plan v1"
if p.Status != "" {
@@ -275,6 +337,10 @@ func (b *builder) move(p dungeon.Point, direct bool, comment string) {
func (b *builder) leg(li int) error {
l := b.plan.Legs[li]
+ if slices.Contains(b.opt.SkipLegs, l.Name) {
+ b.warnings = append(b.warnings, fmt.Sprintf("leg %d (%s): left out of the export by the exporter's skip-legs option (a leg the run leader cannot do, or does not need): the later legs close up", li, l.Name))
+ return nil
+ }
if l.Optional != nil {
// The run leader's bots carry no such item and its rows have no way to give one (give_items is the agent's GM setup), so the leg would
// end the run at its first use of the object: the leg is left out and the later legs close up.
@@ -285,10 +351,10 @@ func (b *builder) leg(li int) error {
if l.CompanionsLoot != nil && !*l.CompanionsLoot && !b.opt.DropCompanionsLoot {
return fmt.Errorf("companions_loot is false and the run leader's rows cannot carry it (TortoiseBots has no such column): its bots would loot what this leg wants left alone; export is refused (pass DropCompanionsLoot / --drop-companions-loot to export without it): %w", ErrCompanionsLoot)
}
- b.rows.Legs = append(b.rows.Legs, LegRow{Plan: b.name, Leg: b.outLeg, Name: clip(l.Name, MaxLegName)})
+ b.rows.Legs = append(b.rows.Legs, LegRow{Plan: b.name, Leg: b.outLeg, Name: clip(l.Name, MaxLegName), Comment: leaveComment(l)})
defer func() { b.outLeg++ }()
// The leg in the order the driver does it: the via waypoints, the To, the pull groups, the steps, the boss, the steps after it.
- for _, w := range l.Via {
+ for vi, w := range l.Via {
if w.GMCross && b.opt.GMCrossDirect {
b.warnings = append(b.warnings, fmt.Sprintf("leg %d (%s): via %.1f %.1f %.1f is a gm_cross; exported as a move_direct (a straight walk to the landing point), not tried live: the run leader has no GM move", li, l.Name, w.X, w.Y, w.Z))
b.move(w.Point, true, "via (gm_cross in the plan; move_direct, not tried live)")
@@ -297,6 +363,18 @@ func (b *builder) leg(li int) error {
if w.GMCross {
return fmt.Errorf("via %.1f %.1f %.1f is a gm_cross: a GM moves the party there in a remote run, and the TortoiseBots run leader has no such step", w.X, w.Y, w.Z)
}
+ if w.Exact {
+ // The plan allows exact only on a plain via whose next via is a direct step (dungeon.Parse refuses any other), so this row is
+ // always followed by its move_direct; Validate checks the rows keep it so.
+ b.add(StepRow{Kind: KindMove, X: w.X, Y: w.Y, Z: w.Z, Flags: FlagExact, Comment: "via (exact: the direct step's launch point)"})
+ b.last, b.haveLast = w.Point, true
+ continue
+ }
+ if w.Direct && vi > 0 && l.Via[vi-1].Exact {
+ b.add(StepRow{Kind: KindMoveHopExact, X: w.X, Y: w.Y, Z: w.Z, Comment: "via (direct from the exact point before it)"})
+ b.last, b.haveLast = w.Point, true
+ continue
+ }
b.move(w.Point, w.Direct, "via")
}
b.move(l.To, false, "to")
@@ -306,12 +384,25 @@ func (b *builder) leg(li int) error {
}
}
for si, st := range l.Steps {
- // A leg whose companions_loot is false (only exported with DropCompanionsLoot) has its chests opened by the carrier bot alone: with the
- // leg's looting setting dropped those steps would send bots at them, so they are left out, with a warning.
- if st.LootObject != nil && l.CompanionsLoot != nil && !*l.CompanionsLoot {
+ // A leg whose companions_loot is false: with DropCompanionsLoot alone the chest steps would send bots at chests the leg wants left
+ // alone unless the setup mutes them, so they are left out, with a warning. With CarrierChests each chest becomes rows the carrier
+ // alone runs: the stand move, the loot (flag 256, gone is done), and the leg's leftover entries as the sweep pull at its point
+ // (the chest's linked trap raises its adds before the order's ACK, so the sweep is part of the chest, and one chest a row keeps
+ // the adds two at a time).
+ if st.LootObject != nil && l.CompanionsLoot != nil && !*l.CompanionsLoot && !b.opt.CarrierChests {
b.skippedLoot++
continue
}
+ if st.LootObject != nil && l.CompanionsLoot != nil && !*l.CompanionsLoot && b.opt.CarrierChests {
+ if st.LootObject.Stand == nil {
+ // The chest rows walk the party to a stand before the loot (the run leader takes rows from where the party stands); a chest
+ // without one would be a two-row standless loot, which no row could make safe: the loot would be taken from wherever the
+ // last row left the party, possibly out of the search range or in the next pack.
+ return fmt.Errorf("steps[%d]: loot_object %d has no stand: give the chest a stand point (the carrier's rows walk the party there before the loot)", si, st.LootObject.Entry)
+ }
+ b.carrierChest(l, st.LootObject)
+ continue
+ }
if err := b.step(st); err != nil {
return fmt.Errorf("steps[%d]: %w", si, err)
}
@@ -407,9 +498,17 @@ func (b *builder) hold(h *dungeon.Hold, entries []uint32, comment string) {
b.seq--
}
}
- b.add(StepRow{Kind: KindHold, Entry: h.Data, X: h.Spot.X, Y: h.Spot.Y, Z: h.Spot.Z, Radius: h.Radius, Param: EncounterDone,
- Flags: FlagDoneData | FlagEncounter, TimeoutS: h.TimeoutS, Entries: entries,
- Comment: clip(fmt.Sprintf("%s: the event's mobs are fought on the spot until instance data %d reads %d (DONE)", comment, h.Data, EncounterDone), MaxComment)})
+ param, flags := uint32(EncounterDone), uint32(FlagDoneData|FlagEncounter)
+ done := fmt.Sprintf("%d (DONE)", EncounterDone)
+ if h.Value != nil {
+ // A script value (Zul'Farrak's pyramid phase), not an encounter state: flag 8 alone, so the run leader reads no FAIL or reset
+ // into it and its loader takes a param above 4 (DungeonPlanPolicy.h NormalizePlanStep).
+ param, flags = *h.Value, FlagDoneData
+ done = fmt.Sprintf("%d (a script value, not an encounter state)", *h.Value)
+ }
+ b.add(StepRow{Kind: KindHold, Entry: h.Data, X: h.Spot.X, Y: h.Spot.Y, Z: h.Spot.Z, Radius: h.Radius, Param: param,
+ Flags: flags, TimeoutS: h.TimeoutS, Entries: entries,
+ Comment: clip(fmt.Sprintf("%s: the event's mobs are fought on the spot until instance data %d reads %s", comment, h.Data, done), MaxComment)})
b.last, b.haveLast = h.Spot, true
}
@@ -443,6 +542,29 @@ func itemEntry(i *dungeon.ItemRef) uint32 {
return i.Entry
}
+// carrierSweepRadiusYd is the sweep pull's radius at a carrier chest: the trap's adds rise on the chest's point (Zul'Farrak's Shallow
+// Grave trap 128972 spawns at the grave itself), so the ones that engage at once are fought by the fight block and an idle stray of
+// this chest's pair is within a few yards of it; one grave's adds only, not the next grave's (the nearest stands 3.9 yd off).
+const carrierSweepRadiusYd = 15
+
+// carrierChest writes a companions_loot-false leg's chest (Options.CarrierChests): the move to its stand, the carrier's loot row with
+// FlagGoneIsDone (a looted chest of spawntimesecs 7200 despawns, so a resumed run takes the gone chest as done instead of pausing on
+// it), and — when the leg has leftover_entries — the sweep pull (the chest's linked trap raises its adds in the same server call the
+// loot ACK answers, and round 3's eleven leftovers aggroed the next walk; the loot row is never safe without the sweep). batch is
+// ignored: one chest a row keeps the adds two at a time (batches of 3+ wiped twice in round 4); the `loot_object batch` warning stays.
+func (b *builder) carrierChest(l dungeon.Leg, lo *dungeon.LootObject) {
+ if lo.Stand != nil {
+ b.move(*lo.Stand, false, "stand")
+ }
+ x, y, z := refPoint(lo.ObjectRef)
+ b.add(StepRow{Kind: KindLoot, Entry: lo.Entry, X: x, Y: y, Z: z, Item: itemEntry(lo.ExpectItem), Flags: FlagGoneIsDone,
+ Comment: clip(itemComment("loot", lo.ExpectItem), MaxComment)})
+ if len(l.LeftoverEntries) > 0 {
+ b.add(StepRow{Kind: KindPull, X: x, Y: y, Z: z, Radius: carrierSweepRadiusYd, Entries: uniq(l.LeftoverEntries),
+ Comment: clip("sweep: what the chest raised", MaxComment)})
+ }
+}
+
func (b *builder) step(st dungeon.Step) error {
switch {
case st.UseObject != nil:
@@ -479,7 +601,18 @@ func (b *builder) step(st dungeon.Step) error {
b.add(StepRow{Kind: KindRitual, Entry: r.Entry, X: r.Position.X, Y: r.Position.Y, Z: r.Position.Z, Param: uint32(r.Participants), Comment: fmt.Sprintf("ritual of %d", r.Participants)})
case st.Gossip != nil:
g := st.Gossip
- return noStep("gossip (NPC %d, %s): the run leader's plan has no gossip step yet, so a plan with one cannot be exported until it has", g.NPC, g.OptionText())
+ switch {
+ case g.Talker() == dungeon.GossipByAgent:
+ return noStep("gossip (NPC %d, %s): by \"agent\" is the agent's own character talking, which a bound party has none of (use by \"tank\" or \"carrier\": the lead bot talks)", g.NPC, g.OptionText())
+ case g.Option == nil:
+ return noStep("gossip (NPC %d, %s): names its option by text, and the run leader's row carries the option number (the menu's index, \"option\")", g.NPC, g.OptionText())
+ case *g.Option+1 > MaxGossipOption:
+ return fmt.Errorf("gossip (NPC %d, %s): the run leader's row counts the menu from 1 and holds at most %d (a menu holds %d options)", g.NPC, g.OptionText(), MaxGossipOption, MaxGossipOption)
+ }
+ // x y z: where the NPC stands (the run leader walks the lead bot there when it is farther than 30 yd, and the order searches within 40 yd
+ // of the bot). The row's param is the option the way `.bot cmd gossip` counts it, from 1: the plan's option (the menu index) + 1.
+ b.add(StepRow{Kind: KindGossip, Entry: g.NPC, X: g.Near.X, Y: g.Near.Y, Z: g.Near.Z, Param: *g.Option + 1,
+ Comment: clip(gossipComment(g), MaxComment)})
default:
return fmt.Errorf("an empty step")
}
@@ -488,6 +621,12 @@ func (b *builder) step(st dungeon.Step) error {
func (b *builder) wait(w *dungeon.WaitFor) error {
timeout := w.TimeoutS
+ if w.Hold != nil {
+ // The wait's run leader form (dungeon.Hold on a wait_for): the event's hold row in place of the wait, which the run leader may not
+ // be able to run at all (a unit wait). The agent's driver still runs the wait as written.
+ b.hold(w.Hold, uniq(w.Hold.Entries), "wait hold")
+ return nil
+ }
switch {
case w.GameObject != nil:
g := w.GameObject
@@ -529,6 +668,15 @@ func (b *builder) wait(w *dungeon.WaitFor) error {
return nil
}
+// gossipComment is the row's comment: the plan's option and the plan's failure text.
+func gossipComment(g *dungeon.Gossip) string {
+ c := fmt.Sprintf("talk to %d, menu index %d (cmd gossip option %d)", g.NPC, *g.Option, *g.Option+1)
+ if g.FailText != "" {
+ c += ": " + g.FailText
+ }
+ return c
+}
+
func waitComment(base string, w *dungeon.WaitFor) string {
if w.FailText != "" {
return base + ": " + w.FailText
@@ -559,12 +707,15 @@ func nameOr(a, b string) string {
// Validate checks rows the way the module's loader would, so that a row it would skip is a refusal here.
func Validate(r Rows) error {
- for _, s := range r.Steps {
+ for i, s := range r.Steps {
where := fmt.Sprintf("plan %s leg %d step %d (%s)", s.Plan, s.Leg, s.Seq, s.Kind)
bad := func(format string, a ...any) error { return fmt.Errorf("%s: %s", where, fmt.Sprintf(format, a...)) }
if s.Radius < 0 || s.Radius > MaxRadius {
return bad("radius %.1f is outside 0-%d", s.Radius, MaxRadius)
}
+ if len(s.Kind) > MaxKind {
+ return bad("the kind is longer than ai_dungeon_step.kind holds (%d)", MaxKind)
+ }
if s.TimeoutS < 0 || s.TimeoutS > MaxTimeoutS {
return bad("timeout_s %d is outside 0-%d", s.TimeoutS, MaxTimeoutS)
}
@@ -572,7 +723,7 @@ func Validate(r Rows) error {
return bad("%d entries (at most %d)", len(s.Entries), MaxEntries)
}
switch s.Kind {
- case KindMove, KindMoveDirect, KindRest:
+ case KindMove, KindMoveDirect, KindMoveHopExact, KindRest:
case KindPull:
if len(s.Entries) == 0 {
return bad("a pull needs entries")
@@ -581,11 +732,23 @@ func Validate(r Rows) error {
if s.Entry == 0 {
return bad("needs an entry")
}
+ case KindGossip:
+ if s.Entry == 0 {
+ return bad("needs an entry")
+ }
+ // The loader's rule (DungeonPlanPolicy.h NormalizePlanStep): the option counts from 1, `.bot cmd gossip` refuses 0; no flag is read.
+ if s.Param < 1 || s.Param > MaxGossipOption {
+ return bad("param %d is outside 1-%d (the option, counted from 1)", s.Param, MaxGossipOption)
+ }
+ if s.Flags != 0 {
+ return bad("a gossip carries no flags")
+ }
case KindHold:
if s.Entry == 0 || s.Flags&FlagDoneData == 0 {
return bad("a hold needs its instance data (entry) and done flag")
}
- if s.Param > 4 {
+ // An encounter state is 0-4; a plain script value (flag 8 without 32) may be any number (the loader's rule).
+ if s.Flags&FlagEncounter != 0 && s.Param > 4 {
return bad("param %d is no encounter state (0-4)", s.Param)
}
case KindWaitClear:
@@ -598,6 +761,30 @@ func Validate(r Rows) error {
if s.Kind == KindWaitObject && s.Param > 2 {
return bad("param %d is above 2", s.Param)
}
+ if s.Flags&FlagGoneIsDone != 0 {
+ // The exporter's own rule (ours, not the module's): the module reads the flag off a loot/open row only, so the exporter refuses
+ // it on any other kind; a module that knows no such step flag yet ignores unknown flags on a loot/open and pauses on a gone
+ // object, so nothing breaks either way.
+ if s.Kind != KindLoot && s.Kind != KindOpen {
+ return bad("the gone-is-done flag is for a loot or open row: the exporter refuses it on a %s (a module without the step flag ignores it there)", s.Kind)
+ }
+ }
+ if s.Flags&FlagExact != 0 {
+ if s.Kind != KindMove {
+ return bad("the exact flag is for a move row (the loader refuses it on a %s)", s.Kind)
+ }
+ if i+1 >= len(r.Steps) || r.Steps[i+1].Plan != s.Plan || r.Steps[i+1].Leg != s.Leg || r.Steps[i+1].Kind != KindMoveHopExact {
+ return bad("an exact move must be followed by the move_hop_exact it launches (a row came between them, or there is none)")
+ }
+ }
+ if s.Kind == KindMoveHopExact {
+ if i == 0 || r.Steps[i-1].Plan != s.Plan || r.Steps[i-1].Leg != s.Leg || r.Steps[i-1].Kind != KindMove || r.Steps[i-1].Flags&FlagExact == 0 {
+ return bad("a move_hop_exact must follow the exact move (flags %d) it is launched from", FlagExact)
+ }
+ if s.Flags != 0 {
+ return bad("a move_hop_exact carries no flags")
+ }
+ }
if len(s.Comment) > MaxComment {
return bad("comment longer than %d", MaxComment)
}
@@ -635,8 +822,10 @@ func (b *builder) cutLongHops() error {
cp := StepRow{Plan: s.Plan, Leg: s.Leg, Kind: s.Kind, X: s.X, Y: s.Y, Z: s.Z}
prev = &cp
}
- if s.Kind == KindMove || s.Kind == KindMoveDirect {
- if prev != nil {
+ if s.Kind == KindMove || s.Kind == KindMoveDirect || s.Kind == KindMoveHopExact {
+ // The hop after an exact move is indivisible: no cut point may come between the exact move and it (the run leader launches
+ // it from that move only; Validate refuses rows that separate them). It is still the anchor for the next hop.
+ if prev != nil && s.Kind != KindMoveHopExact {
a, c := dungeon.Point{X: prev.X, Y: prev.Y, Z: prev.Z}, dungeon.Point{X: s.X, Y: s.Y, Z: s.Z}
if d := dist3(a, c); d > MoveGapWarnYd {
if b.opt.Cut == nil {
@@ -680,13 +869,13 @@ func (b *builder) routeAroundAvoid() error {
added := map[int]int{}
for _, s := range b.rows.Steps {
switch s.Kind {
- case KindMove, KindMoveDirect, KindPull, KindBoss, KindHold:
+ case KindMove, KindMoveDirect, KindMoveHopExact, KindPull, KindBoss, KindHold:
default:
out = append(out, s)
continue
}
to := dungeon.Point{X: s.X, Y: s.Y, Z: s.Z}
- if s.Kind != KindMoveDirect {
+ if s.Kind != KindMoveDirect && s.Kind != KindMoveHopExact {
via, err := b.opt.Route(b.plan.Map, prev, to)
if err != nil {
return fmt.Errorf("leg %d step %d (%s): %w", s.Leg, s.Seq, s.Kind, err)
@@ -737,3 +926,99 @@ func clip(s string, n int) string {
}
return strings.ToValidUTF8(s[:n], "")
}
+
+// StepCount is the number of ai_dungeon_step rows: the step count the run leader lists for this plan (`.bot run list`).
+func (r Rows) StepCount() int { return len(r.Steps) }
+
+// LegNumber is the ai_dungeon_leg.leg number the export gives the plan's leg leg. An optional leg is left out and the legs after it
+// close up, so this is not the plan's own index. ok is false for a leg outside the plan and for an optional leg (which has no rows).
+func LegNumber(p *dungeon.Plan, leg int) (int, bool) {
+ if leg < 0 || leg >= len(p.Legs) || p.Legs[leg].Optional != nil {
+ return 0, false
+ }
+ row := 0
+ for i := 0; i < leg; i++ {
+ if p.Legs[i].Optional == nil {
+ row++
+ }
+ }
+ return row, true
+}
+
+// LegNumber is LegNumber(p, leg), and false also when these rows were cut before that leg (it has no rows at all).
+func (r Rows) LegNumber(p *dungeon.Plan, leg int) (int, bool) {
+ row, ok := LegNumber(p, leg)
+ if !ok || row >= len(r.Legs) {
+ return 0, false
+ }
+ return row, true
+}
+
+// PlanLeg is the plan leg index of the exported row leg rowLeg (false when no leg has that number: a cut plan, or a leg outside it).
+func PlanLeg(p *dungeon.Plan, rowLeg int) (int, bool) {
+ for i := range p.Legs {
+ if n, ok := LegNumber(p, i); ok && n == rowLeg {
+ return i, true
+ }
+ }
+ return 0, false
+}
+
+// StepPoint is the point of the rows' step step (1-based), and false when the rows have no such step. A pull or boss step's point is
+// where the party fights; a move's is where it walks. The observer's place check compares the party with it.
+func (r Rows) StepPoint(step int) (dungeon.Point, bool) {
+ if step < 1 || step > len(r.Steps) {
+ return dungeon.Point{}, false
+ }
+ s := r.Steps[step-1]
+ return dungeon.Point{X: s.X, Y: s.Y, Z: s.Z}, true
+}
+
+// GroupStep is the 1-based step number, as the run leader counts its own plan, of the plan leg's pull group group: its first row (the
+// group's approach move, else its pull or hold row). ok is false when the rows have no such group (an optional leg, a cut plan, a group
+// number the leg does not have).
+func (r Rows) GroupStep(p *dungeon.Plan, leg, group int) (int, bool) {
+ if leg < 0 || leg >= len(p.Legs) || group < 0 || group >= len(p.Legs[leg].PullGroups) {
+ return 0, false
+ }
+ rowLeg, ok := r.LegNumber(p, leg)
+ if !ok {
+ return 0, false
+ }
+ // The exporter puts this comment on the group's own rows (pull, hold and approach moves): "pull group " or "pull group ...".
+ prefix := "pull group " + strconv.Itoa(group)
+ marker := -1
+ for i, s := range r.Steps {
+ if s.Leg != rowLeg {
+ continue
+ }
+ if s.Comment == prefix || strings.HasPrefix(s.Comment, prefix+" ") {
+ marker = i
+ break
+ }
+ }
+ if marker < 0 {
+ return 0, false
+ }
+ // cutLongHops and routeAroundAvoid put the walk to the group in front of its first row: "approach path" (the hop to a pack farther
+ // than the run leader's reach), "path" (a long move cut into via points) and "path (keeps out of an avoid box)". The group starts
+ // after the previous anchor (a pull, boss or hold, or a move that is not one of those walk rows), so walk back over them.
+ i := marker
+ for i > 0 {
+ prev := r.Steps[i-1]
+ if prev.Leg != rowLeg || !walkRow(prev) {
+ break
+ }
+ i--
+ }
+ return i + 1, true
+}
+
+// walkRow says whether a row was put in only to walk: a cut hop or an avoid-box route, not a point of the plan.
+func walkRow(s StepRow) bool {
+ switch s.Comment {
+ case "approach path", "path", "path (keeps out of an avoid box)":
+ return s.Kind == KindMove
+ }
+ return false
+}
diff --git a/internal/planexport/planexport_test.go b/internal/planexport/planexport_test.go
index a6933eee..b8ea4542 100644
--- a/internal/planexport/planexport_test.go
+++ b/internal/planexport/planexport_test.go
@@ -286,6 +286,70 @@ func TestUldaman(t *testing.T) {
t.Logf("uldaman: %d legs, %d steps, %d rows, %d warnings", len(r.Legs), len(r.Steps), r.Count(), len(res.Warnings))
}
+// TestUldamanPullOrderExport: the rows of the 2026-10-21 pull order (PLAN-ULDAMAN-GRIMLOK) come out in the order the leg walks: the Stone Keeper leg pulls the
+// Steward, the Sculptor trio and the two southern Rocksmashers (each after its move row) before its boss row, the galgann leg no longer pulls the first two,
+// Grimlok's corridor is the seven pulls of the plan with the moved keepers_door groups after the first, and the keepers_door leg starts at its old group 3.
+func TestUldamanPullOrderExport(t *testing.T) {
+ p, err := dungeon.Load("../../dungeons/uldaman.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, err := Build(p, Options{Source: "turtle-agent test dungeons/uldaman.json"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ rows := func(leg int) (out []string) {
+ for _, s := range res.Rows.Steps {
+ if s.Leg != leg {
+ continue
+ }
+ switch s.Kind {
+ case KindPull:
+ out = append(out, fmt.Sprintf("pull %v %.1f,%.1f r%g", s.Entries, s.X, s.Y, s.Radius))
+ case KindMove:
+ out = append(out, fmt.Sprintf("move %.1f,%.1f", s.X, s.Y))
+ case KindBoss:
+ out = append(out, fmt.Sprintf("boss %d", s.Entry))
+ }
+ }
+ return out
+ }
+ // check wants the rows in a row somewhere in the leg's rows (the leg's `to` move row comes first).
+ check := func(leg int, want ...string) {
+ t.Helper()
+ got := strings.Join(rows(leg), "\n") + "\n"
+ if !strings.Contains(got, strings.Join(want, "\n")+"\n") {
+ t.Errorf("leg %d rows:\n%s\nwant in a row:\n%s", leg, got, strings.Join(want, "\n"))
+ }
+ }
+ // ancient_stone_keeper (leg 6): after the Rocksmasher at -43.6 237.3 come the Steward, the trio and the southern pair, then the boss approach and the boss.
+ check(6,
+ "pull [7011] -43.6,237.3 r10",
+ "move -64.0,241.0", "pull [4860] -39.6,272.8 r10",
+ "move -58.0,241.0", "pull [7012 7011] -43.0,263.9 r18.9",
+ "move -64.0,227.0", "pull [7011] -46.4,202.9 r13.4",
+ "move -73.3,240.2", "boss 7206")
+ for _, r := range rows(7) {
+ if strings.Contains(r, "[4860]") || strings.Contains(r, "-39.6,272.8") {
+ t.Errorf("galgann still pulls the Steward: %s", r)
+ }
+ }
+ // galgann (leg 7) starts at the Rocksmasher pack at -18.8 276.5 (its first group, the Keeper's Rocksmasher, is a repeat that has no row).
+ check(7, "pull [7012 7011] -18.8,276.5 r15.5")
+ // grimlok (leg 8): pull 0 with the third Brawler, the two moved groups from their west stands, the merged patrol and trio, the east trio, the three, the pair.
+ check(8,
+ "pull [4853 4855] 35.8,370.6 r16",
+ "move 28.0,360.0", "pull [4855 4853] 49.1,343.5 r12.8",
+ "move 33.0,354.0", "pull [4855] 39.5,327.5 r15.1",
+ "move 19.1,366.4", "pull [4855 4853] 58.6,374.4 r18",
+ "move 50.8,379.3", "pull [4855 4853] 83.2,401.9 r15.3",
+ "pull [4855 4853] 51.7,420.1 r16",
+ "move 48.0,419.0", "pull [4853 4855] 38.8,442.0 r13.5",
+ "boss 4854")
+ // keepers_door (leg 9) begins with its old pull group 3 from the stand at 47.6 357.3.
+ check(9, "move 47.6,357.3", "pull [4855 4853] 44.3,308.4 r15.9")
+}
+
// TestZulfarrakExportCarriesTheUnitLists: the plan row has cc_priority, cc_always and kill_first (TortoiseBots #1001's columns on
// ai_dungeon_plan), as comma lists like `entries`, in the column order of that migration, and they read back.
func TestZulfarrakExportCarriesTheUnitLists(t *testing.T) {
@@ -352,8 +416,7 @@ func TestAreaTriggerBecomesAMoveToTheStand(t *testing.T) {
}
}
-// zulfarrakThroughZumrah is the shipped Zul'Farrak plan cut after the zumrah leg: the legs after it (the stairs event, Bly, the End Door,
-// Ukorz) use the gossip step and unit waits, which the run leader has no step for.
+// zulfarrakThroughZumrah is the shipped Zul'Farrak plan cut after the zumrah leg (the tests of the legs up to Zum'rah).
func zulfarrakThroughZumrah(t *testing.T) *dungeon.Plan {
t.Helper()
p, err := dungeon.Load("../../dungeons/zulfarrak.json")
@@ -370,9 +433,11 @@ func zulfarrakThroughZumrah(t *testing.T) *dungeon.Plan {
return nil
}
-// The whole shipped Zul'Farrak plan is exported up to the zumrah leg: the legs after it (the stairs event) use a unit wait and the gossip
-// step, which the run leader has no step for, so the export is cut before the first of them, with a note that names the leg and the step.
-func TestZulfarrakIsCutAfterZumrah(t *testing.T) {
+// The whole shipped Zul'Farrak plan is exported up to the weegli_door leg: the two waits of the stairs event carry their holds (instance
+// data 1, the pyramid phase), Weegli's wait carries the hold on phase 8, and his gossip is a gossip row by option number. The bly leg begins with
+// a unit wait without a hold (Weegli gone), which the run leader has no step for, so the export is cut before it, with a note that names the leg
+// and the step (the plan's gahzrilla leg is optional and takes no row number, so bly is row leg 15 of plan leg 16); ukorz after it is not in the rows (TestZulfarrakWithoutBlyReachesUkorz).
+func TestZulfarrakIsCutAtBly(t *testing.T) {
p, err := dungeon.Load("../../dungeons/zulfarrak.json")
if err != nil {
t.Fatal(err)
@@ -381,16 +446,16 @@ func TestZulfarrakIsCutAfterZumrah(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- whole := buildWhole(t, zulfarrakThroughZumrah(t))
- if len(res.Rows.Legs) != 11 || res.Rows.Legs[10].Name != "zumrah" || len(res.Rows.Steps) != len(whole.Rows.Steps) || res.Rows.Count() != whole.Rows.Count() {
- t.Fatalf("%d legs, %d steps (the plan through zumrah: %d)", len(res.Rows.Legs), len(res.Rows.Steps), len(whole.Rows.Steps))
+ whole := buildWhole(t, zulfarrakThrough(t, "weegli_door"))
+ if len(res.Rows.Legs) != 15 || res.Rows.Legs[14].Name != "weegli_door" || len(res.Rows.Steps) != len(whole.Rows.Steps) || res.Rows.Count() != whole.Rows.Count() {
+ t.Fatalf("%d legs, %d steps (the plan through weegli_door: %d)", len(res.Rows.Legs), len(res.Rows.Steps), len(whole.Rows.Steps))
}
- for _, want := range []string{"exported legs 0-10 (zumrah) only", "cut before leg 11 (stairs)", "after_boss[1]: wait_for.unit has no run leader step (unit 7607 seen)", "5 after it"} {
+ for _, want := range []string{"exported legs 0-14 (weegli_door) only", "cut before leg 15 (bly)", "steps[0]: wait_for.unit has no run leader step (unit 7607 gone)", "1 after it"} {
if !strings.Contains(res.Cut, want) {
t.Errorf("the cut note lacks %q: %s", want, res.Cut)
}
}
- if !slices.Contains(res.Warnings, res.Cut) || !strings.Contains(res.Rows.Plan.Comment, "cut before leg 11 (stairs)") {
+ if !slices.Contains(res.Warnings, res.Cut) || !strings.Contains(res.Rows.Plan.Comment, "cut before leg 15 (bly)") {
t.Errorf("warnings %v, plan comment %q", res.Warnings, res.Rows.Plan.Comment)
}
back, err := Parse(WriteSQL([]Rows{res.Rows}))
@@ -399,6 +464,74 @@ func TestZulfarrakIsCutAfterZumrah(t *testing.T) {
}
}
+// With the bly leg left out (the exporter's skip-legs option: the run leader has no boss that turns hostile after his gossip) the shipped plan
+// exports whole: the weegli_door leg is the hold on the pyramid phase 8 at Weegli's place, the gossip 7607 option 1 (the plan's option 0), and the
+// End Door wait; the ukorz leg follows it in plain kinds.
+func TestZulfarrakWithoutBlyReachesUkorz(t *testing.T) {
+ p, err := dungeon.Load("../../dungeons/zulfarrak.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, err := Build(p, Options{Source: "test", DropCompanionsLoot: true, SkipLegs: []string{"bly"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Cut != "" {
+ t.Fatalf("cut: %s", res.Cut)
+ }
+ legs := res.Rows.Legs
+ if len(legs) != 16 || legs[14].Name != "weegli_door" || legs[15].Name != "ukorz" || legs[15].Leg != 15 {
+ t.Fatalf("legs %+v", legs)
+ }
+ var door []string
+ for _, s := range res.Rows.Steps {
+ if s.Leg == 14 {
+ door = append(door, fmt.Sprintf("%s %d %g %g %g r%g p%d f%d t%d %v", s.Kind, s.Entry, s.X, s.Y, s.Z, s.Radius, s.Param, s.Flags, s.TimeoutS, s.Entries))
+ }
+ }
+ want := []string{
+ "move 0 1884 1212 9 r0 p0 f0 t0 []",
+ "hold 1 1877.5 1199.6 8.9 r30 p8 f8 t600 [7787 7788 7789 8876 8877]",
+ "gossip 7607 1877.52 1199.63 8.87 r0 p1 f0 t0 []",
+ "wait_object 146084 1854.5 1142.9 16.1 r0 p1 f0 t60 []",
+ }
+ if !slices.Equal(door, want) {
+ t.Errorf("weegli_door rows:\n%s\nwant\n%s", strings.Join(door, "\n"), strings.Join(want, "\n"))
+ }
+ ukorz := kinds(res.Rows, 15)
+ if len(ukorz) == 0 || ukorz[len(ukorz)-1] != KindBoss || slices.Contains(ukorz, KindGossip) {
+ t.Errorf("ukorz kinds %v", ukorz)
+ }
+ if !slices.ContainsFunc(res.Warnings, func(w string) bool {
+ return strings.Contains(w, "leg 16 (bly): left out of the export by the exporter's skip-legs option")
+ }) {
+ t.Errorf("no warning for the skipped leg: %v", res.Warnings)
+ }
+ // The skipped leg's attributes are not warned about: its gossip stand, its skip list and the like are not in any row.
+ for _, w := range res.Warnings {
+ if strings.Contains(w, "leg 16 (bly)") && !strings.Contains(w, "left out of the export") {
+ t.Errorf("a warning about the skipped leg: %s", w)
+ }
+ }
+}
+
+// zulfarrakThrough is the shipped Zul'Farrak plan cut after the leg named last.
+func zulfarrakThrough(t *testing.T, last string) *dungeon.Plan {
+ t.Helper()
+ p, err := dungeon.Load("../../dungeons/zulfarrak.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, l := range p.Legs {
+ if l.Name == last {
+ p.Legs = p.Legs[:i+1]
+ return p
+ }
+ }
+ t.Fatalf("the plan has no %s leg", last)
+ return nil
+}
+
// buildWhole builds a plan that has to export whole.
func buildWhole(t *testing.T, p *dungeon.Plan) Result {
t.Helper()
@@ -479,6 +612,58 @@ func TestZulfarrakWithZumrahExports(t *testing.T) {
}
}
+// The pool-stairs approach rows reach the run leader: in the exported velratha leg the two packs each have their approach moves right
+// before the pull row (the STUCK #2 fix; the five-mob pack has three since the island Shadowhunter 81610 is left alone, the four-mob pack
+// two). The live 86-step plan's step 83 was this pull group; with the plan's approach points the exported leg grows by move rows and the
+// pull is that many rows later.
+func TestZulfarrakPoolStairsApproachRows(t *testing.T) {
+ p := zulfarrakThroughZumrah(t)
+ res, err := Build(p, Options{Source: "test", DropCompanionsLoot: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ leg := -1
+ for i, l := range res.Rows.Legs {
+ if l.Name == "velratha" {
+ leg = i
+ }
+ }
+ if leg < 0 {
+ t.Fatal("no velratha leg in the rows")
+ }
+ idx := func(entries string) int {
+ for i, s := range res.Rows.Steps {
+ if s.Leg == leg && s.Kind == KindPull && rowsEntries(s.Entries) == entries {
+ return i
+ }
+ }
+ return -1
+ }
+ for _, tc := range []struct{ entries, first, last string }{
+ {"5650,7246,8095,5649", "1598.0 1028.0 9.6", "1606.0 1040.0 10.3"},
+ {"7246,5650", "1588.0 1080.0 9.1", "1584.0 1088.0 9.1"},
+ } {
+ i := idx(tc.entries)
+ if i < 2 {
+ t.Fatalf("pull %s at %d", tc.entries, i)
+ }
+ a, b := res.Rows.Steps[i-2], res.Rows.Steps[i-1]
+ if a.Kind != KindMove || b.Kind != KindMove || !strings.HasSuffix(a.Comment, " approach") ||
+ fmt.Sprintf("%.1f %.1f %.1f", a.X, a.Y, a.Z) != tc.first || fmt.Sprintf("%.1f %.1f %.1f", b.X, b.Y, b.Z) != tc.last || a.Seq+2 != res.Rows.Steps[i].Seq {
+ t.Fatalf("pull %s: %+v %+v before %+v", tc.entries, a, b, res.Rows.Steps[i])
+ }
+ }
+}
+
+// rowsEntries is a step row's entries as the plan's comma list.
+func rowsEntries(e []uint32) string {
+ s := make([]string, len(e))
+ for i, v := range e {
+ s[i] = fmt.Sprint(v)
+ }
+ return strings.Join(s, ",")
+}
+
func buildDropLoot(t *testing.T, p *dungeon.Plan) Rows {
t.Helper()
res, err := Build(p, Options{Source: "test", DropCompanionsLoot: true})
@@ -726,3 +911,322 @@ func TestLongHopStartsAtTheLastFight(t *testing.T) {
t.Fatalf("leg 1 %s", got)
}
}
+
+// The run leader numbers the rows it loaded: an optional leg takes no leg number and its legs close up, and a pull group starts at its
+// first row (its approach move, else its pull row). An observer's from_leg / from_pull_group map through these.
+func TestLegNumberAndGroupStep(t *testing.T) {
+ const groups = `{"version":1,"name":"Groups","status":"live-checked (test)","map":70,"entrance":{"x":0,"y":0,"z":0},"legs":[
+ {"name":"first","to":{"x":10,"y":0,"z":0},"pull_groups":[
+ {"entries":[100],"positions":[{"x":20,"y":0,"z":0}],"approach":[{"x":15,"y":0,"z":0}]},
+ {"entries":[101],"positions":[{"x":25,"y":0,"z":0}]}]},
+ {"name":"gong","to":{"x":0,"y":0,"z":0},"optional":{"item":9240,"name":"Mallet"},"steps":[{"use_object":{"entry":900,"near":{"x":2,"y":0,"z":0},"requires_item":{"entry":9240}}}],"boss":{"entry":7,"position":{"x":5,"y":0,"z":0}}},
+ {"name":"third","to":{"x":30,"y":0,"z":0},"pull_groups":[{"entries":[102],"positions":[{"x":40,"y":0,"z":0}]}]}]}`
+ p := parsePlan(t, groups)
+ r := build(t, p)
+ if r.StepCount() != 6 || len(r.Steps) != 6 {
+ t.Fatalf("%d steps: %+v", r.StepCount(), r.Steps)
+ }
+ for leg, want := range map[int]int{0: 0, 2: 1} {
+ if got, ok := r.LegNumber(p, leg); !ok || got != want {
+ t.Errorf("leg %d: number %d ok %v, want %d", leg, got, ok, want)
+ }
+ }
+ if _, ok := r.LegNumber(p, 1); ok {
+ t.Error("the optional leg took a number")
+ }
+ for _, c := range []struct{ leg, group, want int }{
+ {0, 0, 2}, // the move to the leg (1), then the group's approach move
+ {0, 1, 4}, // the second group's pull row
+ {2, 0, 6}, // second leg written, its pull row after the leg's move
+ } {
+ got, ok := r.GroupStep(p, c.leg, c.group)
+ if !ok || got != c.want {
+ t.Errorf("leg %d group %d: step %d ok %v, want %d", c.leg, c.group, got, ok, c.want)
+ }
+ }
+ if _, ok := r.GroupStep(p, 1, 0); ok {
+ t.Error("the optional leg has no group step")
+ }
+ if _, ok := r.GroupStep(p, 0, 2); ok {
+ t.Error("a group the leg does not have has a step")
+ }
+}
+
+// The group starts at the walk left in front of it, not at its pull row (C): the live Wailing Caverns rows put three "approach path" rows
+// before leg 9's group 4 (steps 199-201, pull at 202), and cut hops put "path" rows before a long move of the approach.
+func TestGroupStepStartsAtTheWalkBeforeTheGroup(t *testing.T) {
+ groups := 9
+ var groupsJSON []string
+ for i := 0; i < groups; i++ {
+ groupsJSON = append(groupsJSON, fmt.Sprintf(`{"entries":[%d],"positions":[{"x":%d,"y":0,"z":0}]}`, 100+i, 20+i))
+ }
+ p := parsePlan(t, `{"version":1,"name":"Shape","status":"live-checked (test)","map":43,"entrance":{"x":0,"y":0,"z":0},"legs":[
+ {"name":"west_column","to":{"x":10,"y":0,"z":0},"pull_groups":[`+strings.Join(groupsJSON, ",")+`]}]}`)
+ // The rows of the live migration's leg 9, steps 192-206.
+ row := func(kind, comment string) StepRow { return StepRow{Leg: 0, Kind: kind, Comment: comment} }
+ r := Rows{Legs: []LegRow{{Plan: "wailing_caverns", Leg: 0, Name: "west_column"}}, Steps: []StepRow{
+ row(KindMove, "to"),
+ row(KindPull, "pull group 0"), row(KindPull, "pull group 1"), row(KindPull, "pull group 2"),
+ row(KindMove, "approach path"), row(KindMove, "approach path"),
+ row(KindPull, "pull group 3"),
+ row(KindMove, "approach path"), row(KindMove, "approach path"), row(KindMove, "approach path"),
+ row(KindPull, "pull group 4"),
+ row(KindPull, "pull group 5"), row(KindPull, "pull group 6"), row(KindPull, "pull group 7"), row(KindPull, "pull group 8"),
+ }}
+ if n, ok := r.GroupStep(p, 0, 4); !ok || n != 8 { // 192+7: steps 199-201 are the approach, 202 the pull
+ t.Fatalf("leg 9 group 4: step %d ok %v, want the first approach row (8 in the shape; 202 in the live plan)", n, ok)
+ }
+ // A group right after an anchor keeps its own approach row, and one with no walk in front is its pull row.
+ if n, ok := r.GroupStep(p, 0, 2); !ok || n != 4 {
+ t.Fatalf("group 2: %d %v, want 4 (its own pull row, no walk in front)", n, ok)
+ }
+}
+
+// The same through Build: with --approach-hops the cut rows before a far pack are part of the group (#248 from_pull_group).
+func TestGroupStepWithApproachHops(t *testing.T) {
+ p := parsePlan(t, `{"version":1,"name":"Hops","status":"live-checked (test)","map":43,"entrance":{"x":0,"y":0,"z":0},"legs":[
+ {"name":"first","to":{"x":0,"y":0,"z":0},"pull_groups":[{"entries":[100],"positions":[{"x":200,"y":0,"z":0}]}]}]}`)
+ cut := func(_ uint32, from, to dungeon.Point) ([]dungeon.Point, error) {
+ return []dungeon.Point{{X: 60, Y: 0, Z: 0}, {X: 120, Y: 0, Z: 0}, {X: 180, Y: 0, Z: 0}}, nil
+ }
+ res, err := Build(p, Options{Source: "test", Cut: cut, ApproachHops: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := kinds(res.Rows, 0); strings.Join(got, " ") != "move move move pull" {
+ t.Fatalf("kinds %v", got)
+ }
+ if n, ok := res.Rows.GroupStep(p, 0, 0); !ok || n != 2 {
+ t.Fatalf("group 0: step %d ok %v, want 2 (the first approach path row, not the pull row 4)", n, ok)
+ }
+}
+
+// TestExactViaIsAFlaggedMove: an exact via point is a move row with FlagExact, right before its move_direct; the flag survives the SQL
+// round trip; Validate refuses the flag on any other kind and an exact move that is not followed by its move_direct.
+func TestExactViaIsAFlaggedMove(t *testing.T) {
+ p := parsePlan(t, strings.Replace(small, `{"x":1,"y":0,"z":0}`, `{"x":1,"y":0,"z":0,"exact":true}`, 1))
+ r := build(t, p)
+ if s := r.Steps[0]; s.Kind != KindMove || s.X != 1 || s.Flags != FlagExact || !strings.Contains(s.Comment, "exact") {
+ t.Fatalf("exact via %+v", s)
+ }
+ if s := r.Steps[1]; s.Kind != KindMoveHopExact || s.Flags != 0 || s.X != 10 {
+ t.Fatalf("its direct step %+v", s)
+ }
+ for i, s := range r.Steps {
+ if i != 0 && s.Flags&FlagExact != 0 {
+ t.Fatalf("step %d carries the exact flag: %+v", i, s)
+ }
+ }
+ back, err := Parse(WriteSQL([]Rows{r}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := Equal([]Rows{r}, back); err != nil || back[0].Steps[0].Flags != FlagExact {
+ t.Fatalf("round trip: %v %+v", err, back[0].Steps[0])
+ }
+ // Without exact in the plan, no row has the flag and the direct step stays a move_direct (the plain mapping is unchanged).
+ plain := build(t, parsePlan(t, small))
+ for _, s := range plain.Steps {
+ if s.Flags&FlagExact != 0 || s.Kind == KindMoveHopExact {
+ t.Fatalf("exact without exact: %+v", s)
+ }
+ }
+ if plain.Steps[1].Kind != KindMoveDirect {
+ t.Fatalf("plain direct step %+v", plain.Steps[1])
+ }
+ // The pairing both ways: an exact move before a plain move_direct, and a move_hop_exact after a plain move, are refused.
+ bad := r
+ bad.Steps = slices.Clone(r.Steps)
+ bad.Steps[1].Kind = KindMoveDirect
+ if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "followed by the move_hop_exact") {
+ t.Fatalf("exact move before a plain move_direct: %v", err)
+ }
+ bad.Steps = slices.Clone(r.Steps)
+ bad.Steps[0].Flags = 0
+ if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "must follow the exact move") {
+ t.Fatalf("move_hop_exact after a plain move: %v", err)
+ }
+ bad.Steps = slices.Clone(r.Steps)
+ bad.Steps[1].Flags = FlagExact // on the move_hop_exact
+ if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "the exact flag is for a move row") {
+ t.Fatalf("exact on a move_direct: %v", err)
+ }
+ bad.Steps = slices.Clone(r.Steps)
+ bad.Steps = append(bad.Steps[:1:1], append([]StepRow{{Plan: r.Steps[0].Plan, Leg: 0, Kind: KindMove, X: 5}}, r.Steps[1:]...)...)
+ if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "followed by the move_hop_exact") {
+ t.Fatalf("a row between the exact move and its direct step: %v", err)
+ }
+}
+
+// TestWailingCavernsSerpentisFootIsExact: the export of the real plan carries the live-proven hop as the exact foot, then the
+// move_direct to the landing, in the serpentis leg.
+func TestWailingCavernsSerpentisFootIsExact(t *testing.T) {
+ p, err := dungeon.Load("../../dungeons/wailing_caverns.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, err := Build(p, Options{Source: "test", DropCompanionsLoot: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ legOf := map[int]string{}
+ for _, l := range res.Rows.Legs {
+ legOf[l.Leg] = l.Name
+ }
+ var exact []StepRow
+ for i, s := range res.Rows.Steps {
+ if s.Flags&FlagExact == 0 {
+ continue
+ }
+ exact = append(exact, s)
+ n := res.Rows.Steps[i+1]
+ if legOf[s.Leg] != "serpentis" || s.X != -293.3 || s.Y != 0.5 || s.Z != -58 {
+ t.Fatalf("exact row %+v (leg %s)", s, legOf[s.Leg])
+ }
+ if n.Kind != KindMoveHopExact || n.X != -287.5 || n.Y != 5 || n.Z != -63.6 {
+ t.Fatalf("after the foot: %+v", n)
+ }
+ }
+ if len(exact) != 1 {
+ t.Fatalf("%d exact rows", len(exact))
+ }
+ w := strings.Join(res.Warnings, "\n")
+ for _, want := range []string{"skip (leg 0 (", "patrol (", "boss.tactics (", "fleers (plan", "not in the rows"} {
+ if !strings.Contains(w, want) {
+ t.Fatalf("warnings lack %q:\n%s", want, w)
+ }
+ }
+ if strings.Contains(w, "check_skip") {
+ t.Fatalf("check_skip is the plan check's only and is not warned:\n%s", w)
+ }
+}
+
+// A leg's `leave` list has no place in the rows: it is the leg row's comment (free text, round-trips through the SQL) and one warning that
+// says the run leader does not read it. A leg without one has no comment and no warning.
+func TestLeaveIsTheLegCommentAndWarned(t *testing.T) {
+ const js = `{"version":1,"name":"lv","status":"live-checked (test)","map":209,"entrance":{"x":0,"y":0,"z":0},"legs":[
+ {"name":"a","to":{"x":10,"y":0,"z":0},"leave":[{"entry":7246,"spawn":81610,"at":{"x":1591,"y":1047.7,"z":9},"why":"island Shadowhunter"},{"entry":5,"at":{"x":3,"y":4,"z":0},"why":"x"}],
+ "pull_groups":[{"entries":[100],"positions":[{"x":20,"y":0,"z":0}]}]},
+ {"name":"b","to":{"x":30,"y":0,"z":0},"boss":{"entry":7000,"position":{"x":40,"y":0,"z":0}}}]}`
+ res, err := Build(parsePlan(t, js), Options{Source: "test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c := res.Rows.Legs[0].Comment; c != "leave (not read by the run leader yet): 7246 spawn 81610 at 1591 1047.7: island Shadowhunter; 5 at 3 4: x" {
+ t.Errorf("leg comment %q", c)
+ }
+ if c := res.Rows.Legs[1].Comment; c != "" {
+ t.Errorf("a leg without leave has the comment %q", c)
+ }
+ w := strings.Join(res.Warnings, "\n")
+ if !strings.Contains(w, "leave (leg 0 (a) 7246 at 1591.0 1047.7; leg 0 (a) 5 at 3.0 4.0): not in the rows: no column or step kind") || strings.Count(w, "leave (") != 1 {
+ t.Errorf("warnings %v", res.Warnings)
+ }
+ back, err := Parse(WriteSQL([]Rows{res.Rows}))
+ if err != nil || Equal([]Rows{res.Rows}, back) != nil {
+ t.Fatalf("round trip: %v", err)
+ }
+ // A long reason is cut to the comment column, not refused.
+ long := strings.Repeat("x", 400)
+ res, err = Build(parsePlan(t, strings.Replace(js, "island Shadowhunter", long, 1)), Options{Source: "test"})
+ if err != nil || len(res.Rows.Legs[0].Comment) > MaxComment {
+ t.Fatalf("%v, comment %d bytes", err, len(res.Rows.Legs[0].Comment))
+ }
+}
+
+// TestUnexpressedAttributesAreWarned: every plan attribute the rows cannot carry is one warning naming where it stands; what is no run
+// attribute (notes, check_skip, equiv) is not; an exported leg only (a cut leg's attributes are not the rows').
+func TestUnexpressedAttributesAreWarned(t *testing.T) {
+ const js = `{"version":1,"name":"attrs","status":"live-checked (test)","map":70,"entrance":{"x":0,"y":0,"z":0},
+ "level_band":[20,25],"fleers":[300],"no_cc":[301],"notes":["a note"],
+ "legs":[
+ {"name":"a","to":{"x":10,"y":0,"z":0},"skip":[200],"leftover_entries":[201],"rest":{"health":70},"notes":["leg note"],
+ "via":[{"x":5,"y":0,"z":0,"check_skip":"a hole"}],
+ "pull_groups":[{"entries":[100],"positions":[{"x":20,"y":0,"z":0}],"equiv":"e1"}],
+ "steps":[{"loot_object":{"entry":902,"near":{"x":21,"y":0,"z":0},"stand":{"x":20,"y":0,"z":0}}},
+ {"ritual":{"entry":903,"position":{"x":22,"y":0,"z":0},"participants":3,"stand":{"x":21,"y":0,"z":0}}}]},
+ {"name":"b","to":{"x":30,"y":0,"z":0},"skip":[202],"boss":{"entry":7000,"position":{"x":40,"y":0,"z":0}}}]}`
+ res, err := Build(parsePlan(t, js), Options{Source: "test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := strings.Join(res.Warnings, "\n")
+ for _, want := range []string{
+ "level_band (plan [20 25])", "fleers (plan [300])", "no_cc (plan [301])",
+ "skip (leg 0 (a) [200]; leg 1 (b) [202]): not in the rows", "leftover_entries (leg 0 (a) [201])", "rest (leg 0 (a))",
+ "stand (loot_object, ritual) (leg 0 (a) steps[0]; leg 0 (a) steps[1])",
+ } {
+ if !strings.Contains(w, want) {
+ t.Fatalf("warnings lack %q:\n%s", want, w)
+ }
+ }
+ for _, not := range []string{"check_skip", "equiv", "note"} {
+ if strings.Contains(w, not) {
+ t.Fatalf("%q is no run attribute and is not warned:\n%s", not, w)
+ }
+ }
+ if n := strings.Count(w, "skip ("); n != 1 {
+ t.Fatalf("one warning per attribute, %d for skip:\n%s", n, w)
+ }
+}
+
+// TestRouteLeavesTheExactHopAlone: the avoid-box router puts no via point before a move_hop_exact (it is a straight line, like a
+// move_direct): the exact foot stays right before its hop.
+func TestRouteLeavesTheExactHopAlone(t *testing.T) {
+ const js = `{"version":1,"name":"lavahop","status":"live-checked (test)","map":389,"entrance":{"x":0,"y":0,"z":0},
+ "avoid":[{"name":"basin","min":{"x":200,"y":-5,"z":-9},"max":{"x":210,"y":5,"z":-1},"why":"lava"}],
+ "legs":[{"name":"a","via":[{"x":5,"y":0,"z":0,"exact":true},{"x":10,"y":0,"z":-4,"direct":true}],"to":{"x":20,"y":0,"z":-4},
+ "boss":{"entry":7000,"position":{"x":30,"y":0,"z":-4}}}]}`
+ route := func(_ uint32, a, b dungeon.Point) ([]dungeon.Point, error) {
+ return []dungeon.Point{{X: (a.X + b.X) / 2, Y: 1, Z: b.Z}}, nil // a via point on every hop it is asked about
+ }
+ res, err := Build(parsePlan(t, js), Options{Source: "test", Route: route})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, s := range res.Rows.Steps {
+ if s.Flags&FlagExact != 0 && res.Rows.Steps[i+1].Kind != KindMoveHopExact {
+ t.Fatalf("a row between the foot and its hop: %+v", res.Rows.Steps[i+1])
+ }
+ }
+}
+
+// TestKindsFitTheColumn: ai_dungeon_step.kind is a varchar(16); every kind the exporter writes fits it, and Validate refuses one that
+// does not (move_direct_exact, 17 characters, was the first name of the exact hop's kind).
+func TestKindsFitTheColumn(t *testing.T) {
+ for _, k := range []string{KindMove, KindMoveDirect, KindMoveHopExact, KindPull, KindBoss, KindLoot, KindOpen, KindUseItem, KindWaitObject,
+ KindWaitClear, KindRest, KindRitual, KindHold} {
+ if len(k) > MaxKind {
+ t.Fatalf("kind %q is %d characters", k, len(k))
+ }
+ }
+ r := Rows{Plan: PlanRow{Plan: "q", Map: 1}, Legs: []LegRow{{Plan: "q"}}, Steps: []StepRow{{Plan: "q", Kind: "move_direct_exact"}}}
+ if err := Validate(r); err == nil || !strings.Contains(err.Error(), "varchar") && !strings.Contains(err.Error(), "kind is longer") {
+ t.Fatalf("%v", err)
+ }
+}
+
+// TestCutterLeavesTheExactHopAlone: with a cutter, a move_hop_exact farther than MoveGapWarnYd from its exact move gets no cut points
+// (the hop is indivisible): Build succeeds and the exact move stays right before its hop; the hop is the anchor of the next hop.
+func TestCutterLeavesTheExactHopAlone(t *testing.T) {
+ const js = `{"version":1,"name":"farhop","status":"live-checked (test)","map":43,"entrance":{"x":0,"y":0,"z":0},
+ "legs":[{"name":"a","via":[{"x":5,"y":0,"z":0,"exact":true},{"x":20,"y":0,"z":-4,"direct":true}],"to":{"x":75,"y":0,"z":-4},
+ "boss":{"entry":7000,"position":{"x":80,"y":0,"z":-4}}}]}`
+ // dungeon.Parse holds a direct via to 25 yd, so the 65 yd hop is set on the parsed plan: the exporter must not rely on that limit.
+ p := parsePlan(t, js)
+ p.Legs[0].Via[1].X = 70
+ calls := 0
+ cut := func(_ uint32, a, b dungeon.Point) ([]dungeon.Point, error) {
+ calls++
+ return []dungeon.Point{{X: (a.X + b.X) / 2, Y: 1, Z: b.Z}}, nil
+ }
+ res, err := Build(p, Options{Source: "test", Cut: cut})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, s := range res.Rows.Steps {
+ if s.Flags&FlagExact != 0 && (res.Rows.Steps[i+1].Kind != KindMoveHopExact || res.Rows.Steps[i+1].X != 70) {
+ t.Fatalf("a row between the foot and its hop: %+v", res.Rows.Steps[i+1])
+ }
+ }
+}
diff --git a/internal/planexport/unexpressed.go b/internal/planexport/unexpressed.go
new file mode 100644
index 00000000..b7ae872e
--- /dev/null
+++ b/internal/planexport/unexpressed.go
@@ -0,0 +1,187 @@
+package planexport
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
+)
+
+// The plan attributes the run leader's rows cannot carry: each one the plan sets is a warning (TortoiseBots #984: nothing is dropped
+// silently), naming every place it stands and what the run leader does instead. The attributes the rows do carry (the points, pull
+// groups, bosses, holds, steps, unit lists, team and ambush flags, direct and exact via points) are not listed, nor are those that are
+// no run attribute at all: the plan's name, version, status, kind, map and notes, a via's check_skip (turtle-agent's plan check only),
+// a pull group's equiv (the pull-test scenario only), a step's fail_text and a use_item's name (both in the row's comment).
+var unexpressedWhy = map[string]string{
+ "entrance/teleports/portal": "the run leader starts inside the instance (`.bot run start` checks the party is in it); getting there is the caller's",
+ "level_band": "the run leader leads the bound party at whatever level it is",
+ "hire_composition": "the run leader leads the bound party as it is",
+ "fleers": "no column; the bots' own AI chases or lets go a fleeing mob",
+ "no_cc": "no column; the run leader's crowd control picks only from cc_priority and cc_always",
+ "summoned_kill_first": "no column; only kill_first is focused",
+ "raid": "no column; the run leader leads one party",
+ "watchdog": "the agent's watchdog; the run leader has its own ceilings",
+ "avoid": "the run leader does not know avoid boxes; export with --mmaps to route its hops around them (a fight can still drag the party in)",
+ "skip": "no skip list column: the run leader pulls a hostile within 15 yd of a hop point that no later step owns, a skipped unit too",
+ "leave": "no column or step kind: the spawns are free text in the leg row's comment and the run leader still pulls, fights and hops to them like any hostile (a module part that reads the list would need a step kind or a flag on the leg's pull and hop rows)",
+ "leftover_entries": "no sweep of idle summoned units; the bots fight what aggroes",
+ "rest": "the run leader's own rest lines apply",
+ "companions_loot false": "dropped (--drop-companions-loot): the bots loot as they like on this leg",
+ "companions_loot false (the chests exported)": "not carried: turn the bots' looting off for this leg (setup), or a module with the trapped-chest rule; the setup of the carrier run: looting off for every bot, party loot method free-for-all, the Executioner's Key 8444 on the lead bot, at least 16 free bag slots on the lead bot, a fresh instance (a looted grave respawns after 2 h); flag 256 needs a TortoiseBots module with the gone-is-done step flag: an older module ignores it and pauses on a despawned grave after a wipe (then a .bot run skip skips the row)",
+ "needs_walk": "the point was not snapped to the navmesh by a mapping walk; the run leader walks to it as given",
+ "patrol": "no patrol path or period: the run leader pulls the pack where it stands when it comes",
+ "boss.tactics": "the plan's tactics are the agent's; a bound party uses the module's boss tactics (docs/guides/boss-tactics.md)",
+ "boss.companions_loot": "no column; the bots loot the boss as they like",
+ "stand (loot_object, ritual)": "no stand point in the row: the bots walk to the object themselves",
+ "object spawn/range": "the row names the object by entry near its point only (the module's own search range)",
+ "loot_object batch": "no batches: the chests are opened one step at a time",
+ "wait_for with a hold": "the row is the wait's hold (the event's spot, fought until its instance data value); the wait's own condition (a unit, an object, hostiles gone) and its fail_text are the agent's only",
+ "gossip stand/by/retry_s": "the row is the order `cmd gossip `: the lead bot talks (the tank or carrier of a plan is the same bot), walks to the NPC itself, asks once (no retry), and ends on the order's ACK; the stand point, the talker and retry_s are the agent's only (fail_text is in the row's comment)",
+ "area_trigger expect/timeout": "only the walk into the zone is a row (a move to the stand point); the expect check and its timeout are not run (the comment names the expect)",
+}
+
+// sweepsLeftovers says whether the leg's LeftoverEntries are carried by the export anyway: with CarrierChests a companions_loot-false
+// leg's exported chest rows end each in the sweep pull of the same entries, so the entries are in the rows and not warned here.
+func (o Options) sweepsLeftovers(l dungeon.Leg) bool {
+ if !o.CarrierChests || l.CompanionsLoot == nil || *l.CompanionsLoot {
+ return false
+ }
+ for _, st := range l.Steps {
+ if st.LootObject != nil {
+ return true
+ }
+ }
+ return false
+}
+
+// unexpressed lists the attributes of the plan's exported legs (legs before `upto`, the optional ones left out) that the rows cannot carry.
+func unexpressed(p *dungeon.Plan, opt Options, upto int) []string {
+ var order []string
+ where := map[string][]string{}
+ add := func(attr, at string) {
+ if _, ok := where[attr]; !ok {
+ order = append(order, attr)
+ }
+ where[attr] = append(where[attr], at)
+ }
+ if p.EntranceTrigger != 0 || p.EntranceTele != "" || p.OutsideTele != "" || p.Portal != nil {
+ add("entrance/teleports/portal", "plan")
+ }
+ if len(p.LevelBand) > 0 {
+ add("level_band", fmt.Sprintf("plan %v", p.LevelBand))
+ }
+ if len(p.HireComposition) > 0 {
+ add("hire_composition", "plan "+strings.Join(p.HireComposition, ","))
+ }
+ if len(p.Fleers) > 0 {
+ add("fleers", fmt.Sprintf("plan %v", p.Fleers))
+ }
+ if len(p.NoCC) > 0 {
+ add("no_cc", fmt.Sprintf("plan %v", p.NoCC))
+ }
+ if len(p.SummonedKillFirst) > 0 {
+ add("summoned_kill_first", fmt.Sprintf("plan %v", p.SummonedKillFirst))
+ }
+ if p.Raid != nil {
+ add("raid", "plan")
+ }
+ if p.Watchdog != nil {
+ add("watchdog", "plan")
+ }
+ if len(p.Avoid) > 0 && opt.Route == nil {
+ add("avoid", fmt.Sprintf("plan, %d boxes", len(p.Avoid)))
+ }
+ for li, l := range p.Legs {
+ if li >= upto {
+ break
+ }
+ if l.Optional != nil || slices.Contains(opt.SkipLegs, l.Name) {
+ continue // left out of the export, with its own warning
+ }
+ leg := fmt.Sprintf("leg %d (%s)", li, l.Name)
+ if len(l.Skip) > 0 {
+ add("skip", fmt.Sprintf("%s %v", leg, l.Skip))
+ }
+ for _, lv := range l.Leave {
+ add("leave", fmt.Sprintf("%s %d at %.1f %.1f", leg, lv.Entry, lv.At.X, lv.At.Y))
+ }
+ // The leftovers are carried anyway when the leg's chests are carrier rows: every exported chest's sweep pull names them.
+ if len(l.LeftoverEntries) > 0 && !opt.sweepsLeftovers(l) {
+ add("leftover_entries", fmt.Sprintf("%s %v", leg, l.LeftoverEntries))
+ }
+ if l.Rest != nil {
+ add("rest", leg)
+ }
+ if l.CompanionsLoot != nil && !*l.CompanionsLoot && opt.DropCompanionsLoot {
+ if opt.CarrierChests {
+ add("companions_loot false (the chests exported)", leg)
+ } else {
+ add("companions_loot false", leg)
+ }
+ }
+ if l.NeedsWalk {
+ add("needs_walk", leg+" to")
+ }
+ for gi, g := range l.PullGroups {
+ if g.Patrol != nil {
+ add("patrol", fmt.Sprintf("%s pull group %d", leg, gi))
+ }
+ if g.NeedsWalk {
+ add("needs_walk", fmt.Sprintf("%s pull group %d", leg, gi))
+ }
+ }
+ if b := l.Boss; b != nil {
+ if b.Tactics != nil {
+ add("boss.tactics", fmt.Sprintf("%s boss %d", leg, b.Entry))
+ }
+ if b.CompanionsLoot != nil {
+ add("boss.companions_loot", fmt.Sprintf("%s boss %d", leg, b.Entry))
+ }
+ if b.NeedsWalk {
+ add("needs_walk", fmt.Sprintf("%s boss %d", leg, b.Entry))
+ }
+ }
+ steps := func(list string, ss []dungeon.Step) {
+ for si, st := range ss {
+ at := fmt.Sprintf("%s %s[%d]", leg, list, si)
+ ref := func(r dungeon.ObjectRef) {
+ if r.Spawn != 0 || r.Range != 0 {
+ add("object spawn/range", at)
+ }
+ }
+ switch {
+ case st.UseObject != nil:
+ ref(st.UseObject.ObjectRef)
+ case st.LootObject != nil:
+ ref(st.LootObject.ObjectRef)
+ if st.LootObject.Stand != nil {
+ add("stand (loot_object, ritual)", at)
+ }
+ if st.LootObject.Batch != "" {
+ add("loot_object batch", at)
+ }
+ case st.WaitFor != nil && st.WaitFor.Hold != nil:
+ add("wait_for with a hold", at)
+ case st.WaitFor != nil && st.WaitFor.GameObject != nil:
+ ref(st.WaitFor.GameObject.ObjectRef)
+ case st.Ritual != nil:
+ if st.Ritual.Stand != nil {
+ add("stand (loot_object, ritual)", at)
+ }
+ case st.Gossip != nil:
+ add("gossip stand/by/retry_s", at)
+ case st.AreaTrigger != nil:
+ add("area_trigger expect/timeout", at)
+ }
+ }
+ }
+ steps("steps", l.Steps)
+ steps("after_boss", l.AfterBoss)
+ }
+ out := make([]string, 0, len(order))
+ for _, attr := range order {
+ out = append(out, fmt.Sprintf("%s (%s): not in the rows: %s", attr, strings.Join(where[attr], "; "), unexpressedWhy[attr]))
+ }
+ return out
+}
diff --git a/internal/routeoracle/bosses.go b/internal/routeoracle/bosses.go
new file mode 100644
index 00000000..b325f556
--- /dev/null
+++ b/internal/routeoracle/bosses.go
@@ -0,0 +1,549 @@
+package routeoracle
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
+ "github.com/pjerra/turtle-agent/internal/navmesh"
+)
+
+// The bosses mode asks a different question from the walk mode. The walk mode proves the
+// plan's stored hops are walkable. The bosses mode asks whether a route could be planned LIVE
+// with the plan's hand data left out: each objective is one navmesh plan from where the party
+// stands, avoid-aware, with NONE of the plan's hand points and NONE of its break hops. Whatever
+// the mesh cannot reach that way is where hand data is still needed, and the report names the
+// stored break that sits there.
+//
+// Two sets of objectives (the --objectives flag):
+//
+// plan (default) each stored leg's own objective: its boss, else its to, else its last
+// step's point. A leg that has a boss is a boss-to-boss plan; the other legs are planned
+// to a hand-chosen point the plan stores, so this is NOT a bare boss list.
+// bosses ONLY the chain entrance -> boss 1 -> boss 2 -> ...: a leg without a boss is folded into
+// the next boss objective (its pull groups, stands and to point are measured against
+// that one live path, and no stored to point is an objective), and legs after the last
+// boss are not planned.
+
+const (
+ // HandNearYd is how close a stored hand point (via, to, approach, stand) must lie to the
+ // live path to be called redundant: the live path already passes it.
+ HandNearYd = 10.0
+ // BreakHitYd is how close a stored break must lie to where the live path ends, or to the
+ // line from there to the objective, to be named as the break the live plan ran into.
+ BreakHitYd = 15.0
+)
+
+// The two objective sets of Bosses.
+const (
+ ObjectivesPlan = "plan"
+ ObjectivesBosses = "bosses"
+)
+
+// BossLeg is one objective's live planning, from the entrance (or the previous objective) to it.
+type BossLeg struct {
+ // Leg is the stored leg the objective belongs to (in boss-only mode the leg that holds the
+ // boss); Legs lists the stored legs folded into this row (boss-only mode).
+ Leg int `json:"leg"`
+ Legs []int `json:"legs,omitempty"`
+ Name string `json:"name,omitempty"`
+ From dungeon.Point `json:"from"` // where the live plan starts
+ To dungeon.Point `json:"to"` // the objective
+ // Objective says what the objective is: boss, to, step, or none.
+ Objective string `json:"objective"`
+ // Status: complete (the mesh reaches the objective), partial or no_path (it does not: the
+ // leg is FAILED), or no_objective (nothing stored to plan to).
+ Status string `json:"status"`
+ Reason string `json:"reason,omitempty"`
+ // NonComparable: the leg started from the point a failed leg's path reached, not from the
+ // previous objective. Its result is a continuation, labelled and excluded from every
+ // aggregate: it says nothing about the plan's own journey.
+ NonComparable bool `json:"non_comparable,omitempty"`
+ NonComparableWhy string `json:"non_comparable_why,omitempty"`
+ // Live is the avoid-aware run. End is where the party stands: the objective on a complete
+ // path, the nearest reachable point on a partial one.
+ Live Run `json:"live"`
+ PlanMs float64 `json:"plan_ms"`
+
+ // BreakHits (failed legs): the stored breaks the live plan ran into: those that bridge the
+ // gap (the mesh reaches the hop's foot from the start and the objective from its landing)
+ // and those lying within BreakHitYd of the live end or of the line from it to the
+ // objective.
+ BreakHits []BreakHit `json:"break_hits,omitempty"`
+ // Bridged (a non-comparable leg only): the same leg planned from the previous leg's
+ // OBJECTIVE, as if the hand data had crossed the break before it. It is the answer to "what
+ // else does the mesh not reach", and enters no primary aggregate (see Totals.Bridged).
+ Bridged *BossLeg `json:"bridged,omitempty"`
+ // Breaks: all the leg's stored breaks, and whether the live path passes one (within
+ // BreakNearYd), which a complete leg shows the mesh needs no hop for.
+ Breaks []BreakTouched `json:"breaks,omitempty"`
+
+ // The corridor metrics, complete legs only.
+ Packs []PullOnPath `json:"packs,omitempty"`
+ PacksOnPath int `json:"packs_on_path"`
+ Hand []HandPoint `json:"hand,omitempty"`
+ HandRedundant int `json:"hand_redundant"`
+ ZJumps []ZJump `json:"z_jumps,omitempty"`
+}
+
+// BreakHit is a stored break a failed live plan ran into.
+type BreakHit struct {
+ Leg int `json:"leg"` // the stored leg
+ Via int `json:"via"` // the waypoint's index in that leg's via, from 0
+ Kind string `json:"kind"` // exact, direct, check_skip, gm_cross (comma joined)
+ At dungeon.Point `json:"at"`
+ // EndYd and LineYd: the break's distance to where the live path ends, and to the line from
+ // there to the objective.
+ EndYd float64 `json:"end_yd"`
+ LineYd float64 `json:"line_yd"`
+ // Bridges: the mesh reaches the hop's foot (the stored point before it, or the start) and
+ // the objective from the hop's landing, so this hop is what the live plan lacks.
+ Bridges bool `json:"bridges"`
+ // Why says which of the rules named it.
+ Why string `json:"why"`
+}
+
+// HandPoint is a stored hand point of a complete leg (a via, to, approach, boss approach or
+// stand that is not a break and not the objective) and how far the live path passes from it.
+type HandPoint struct {
+ Seq int `json:"seq"`
+ Leg *int `json:"leg,omitempty"` // boss-only mode: the stored leg (a pointer: leg 0 is written)
+ Kind string `json:"kind"`
+ At dungeon.Point `json:"at"`
+ NearYd float64 `json:"near_yd"`
+ Redundant bool `json:"redundant"` // within HandNearYd of the live path
+}
+
+// BossesReport is one plan's bosses-mode report.
+type BossesReport struct {
+ Plan string `json:"plan"`
+ Map uint32 `json:"map"`
+ Status string `json:"status,omitempty"`
+ Objectives string `json:"objectives"` // plan or bosses
+ Avoid int `json:"avoid_boxes"`
+ // Unplanned (boss-only): the stored legs after the last boss, which have no boss to plan to.
+ Unplanned []int `json:"unplanned_legs,omitempty"`
+ // EmptyGroups: pull groups with no position and no hold, left out of every count.
+ EmptyGroups int `json:"empty_groups,omitempty"`
+ Legs []BossLeg `json:"legs"`
+ PlanMs float64 `json:"plan_ms"`
+ Tiles []TileFile `json:"tiles"`
+}
+
+// Failed is a leg the live plan could not finish and that is a fair test: it started where the
+// plan's own journey does.
+func (l *BossLeg) Failed() bool {
+ return !l.NonComparable && (l.Status == string(navmesh.Partial) || l.Status == string(navmesh.NoPath))
+}
+
+// Counted is a leg whose metrics enter the aggregates: complete and comparable.
+func (l *BossLeg) Counted() bool { return l.Status == "complete" && !l.NonComparable }
+
+// Totals are the aggregates of a bosses report, over the counted legs only.
+type Totals struct {
+ Legs, Complete, Failed, NoObjective int
+ Continuations, ContinuationsOK int
+ Sum Sums // the counted legs
+ // Bridged: the counted legs plus the bridged re-plans of the non-comparable ones (each
+ // planned from the previous objective): what the mesh reaches when the hand data crosses
+ // every break it must. Never mixed into the primary numbers.
+ BridgedComplete, BridgedFailed int
+ Bridged Sums
+}
+
+// Sums are the metric sums of a set of complete legs.
+type Sums struct {
+ Packs, PacksOn int
+ Hand, HandRedundant int
+ ZJumps int
+ LiveYd float64
+}
+
+func (s *Sums) add(l *BossLeg) {
+ s.Packs += len(l.Packs)
+ s.PacksOn += l.PacksOnPath
+ s.Hand += len(l.Hand)
+ s.HandRedundant += l.HandRedundant
+ s.ZJumps += len(l.ZJumps)
+ s.LiveYd += l.Live.LiveLenYd
+}
+
+// Totals sums the report. A failed leg and a non-comparable continuation add nothing to the
+// primary sums.
+func (r BossesReport) Totals() Totals {
+ t := Totals{Legs: len(r.Legs)}
+ for i := range r.Legs {
+ l := &r.Legs[i]
+ switch {
+ case l.Status == "no_objective":
+ t.NoObjective++
+ case l.NonComparable:
+ t.Continuations++
+ if l.Status == "complete" {
+ t.ContinuationsOK++
+ }
+ if b := l.Bridged; b != nil {
+ if b.Status == "complete" {
+ t.BridgedComplete++
+ t.Bridged.add(b)
+ } else {
+ t.BridgedFailed++
+ }
+ }
+ case l.Failed():
+ t.Failed++
+ t.BridgedFailed++
+ case l.Status == "complete":
+ t.Complete++
+ t.BridgedComplete++
+ t.Sum.add(l)
+ t.Bridged.add(l)
+ }
+ }
+ return t
+}
+
+func pct(a, b int) float64 {
+ if b == 0 {
+ return 0
+ }
+ return 100 * float64(a) / float64(b)
+}
+
+// Summary is the plan's one summary line.
+func (r BossesReport) Summary() string {
+ t := r.Totals()
+ unit := "legs"
+ if r.Objectives == ObjectivesBosses {
+ unit = "boss objectives"
+ }
+ if t.Legs == 0 {
+ return fmt.Sprintf("%s: map %d, no %s", r.Plan, r.Map, unit)
+ }
+ s := fmt.Sprintf("%s: map %d, %d %s: %d complete, %d failed", r.Plan, r.Map, t.Legs, unit, t.Complete, t.Failed)
+ if t.Continuations > 0 {
+ s += fmt.Sprintf(", %d non-comparable (%d reach their objective)", t.Continuations, t.ContinuationsOK)
+ }
+ if t.NoObjective > 0 {
+ s += fmt.Sprintf(", %d without an objective", t.NoObjective)
+ }
+ if len(r.Unplanned) > 0 {
+ s += fmt.Sprintf(", %d legs after the last boss not planned", len(r.Unplanned))
+ }
+ if t.Sum.Packs > 0 {
+ s += fmt.Sprintf("; packs on path %d/%d (%.0f%%)", t.Sum.PacksOn, t.Sum.Packs, pct(t.Sum.PacksOn, t.Sum.Packs))
+ }
+ if t.Sum.Hand > 0 {
+ s += fmt.Sprintf("; hand points redundant %d/%d (%.0f%%)", t.Sum.HandRedundant, t.Sum.Hand, pct(t.Sum.HandRedundant, t.Sum.Hand))
+ }
+ s += fmt.Sprintf("; z jumps %d; live %.0f yd; plan %.0f ms", t.Sum.ZJumps, t.Sum.LiveYd, r.PlanMs)
+ if t.Continuations > 0 {
+ s += fmt.Sprintf("; bridged (non-comparable legs planned from the previous objective): %d complete, %d failed", t.BridgedComplete-t.Complete, t.BridgedFailed-t.Failed)
+ }
+ if r.EmptyGroups > 0 {
+ s += fmt.Sprintf("; %d empty pull groups skipped", r.EmptyGroups)
+ }
+ return s
+}
+
+// objective is one thing the live chain plans to: the stored legs it covers (the last one holds
+// the objective point) and what the point is.
+type objective struct {
+ legs []int
+ at dungeon.Point
+ kind string
+}
+
+// planObjectives is each stored leg's own objective (a leg with nothing stored has kind none).
+func planObjectives(plan *dungeon.Plan) []objective {
+ out := make([]objective, len(plan.Legs))
+ for i := range plan.Legs {
+ at, kind := legObjective(&plan.Legs[i])
+ out[i] = objective{legs: []int{i}, at: at, kind: kind}
+ }
+ return out
+}
+
+// bossObjectives is the chain of the plan's bosses only: one objective per leg that has a boss,
+// covering that leg and the boss-less legs just before it; the legs after the last boss are
+// returned as unplanned.
+func bossObjectives(plan *dungeon.Plan) (out []objective, unplanned []int) {
+ var pending []int
+ for i := range plan.Legs {
+ pending = append(pending, i)
+ if b := plan.Legs[i].Boss; b != nil {
+ out = append(out, objective{legs: pending, at: b.Position, kind: "boss"})
+ pending = nil
+ }
+ }
+ return out, pending
+}
+
+// Bosses plans the plan's objectives live (objectives is ObjectivesPlan or ObjectivesBosses; see
+// the file comment): from where the party stands straight to the objective on a scratch planner
+// with the plan's avoid boxes, no hand points and no break hops. The party starts at the
+// entrance; an objective the mesh reaches leaves it standing there.
+//
+// An objective whose plan does not reach it FAILED (partial or no_path): it names the stored
+// breaks it ran into, and adds nothing to any aggregate. The objective after it starts from the
+// last point the failed path REACHED, which is not where the plan's journey goes on: it is
+// labelled non-comparable and is excluded as well, until one completes and puts the party on a
+// real objective again. Such an objective is also planned a second time from the previous
+// objective (Bridged), to show what else the mesh does not reach once the break is crossed by
+// hand data; that is reported beside the primary numbers, never in them.
+//
+// A planner error (no folder, no engine, a broken tile) is returned, not reported.
+func Bosses(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan, objectives string) (BossesReport, error) {
+ if err := CheckName(plan.Name); err != nil {
+ return BossesReport{}, fmt.Errorf("plan file: %w", err)
+ }
+ rep := BossesReport{Plan: plan.Name, Map: plan.Map, Status: plan.Status, Avoid: len(plan.Avoid),
+ Objectives: objectives, EmptyGroups: emptyGroups(plan)}
+ var objs []objective
+ switch objectives {
+ case ObjectivesPlan:
+ objs = planObjectives(plan)
+ case ObjectivesBosses:
+ objs, rep.Unplanned = bossObjectives(plan)
+ default:
+ return rep, fmt.Errorf("unknown objectives %q (want %s or %s)", objectives, ObjectivesPlan, ObjectivesBosses)
+ }
+ scratch := p.Scratch()
+ defer scratch.Close()
+ scratch.SetAvoid(plan.Map, dungeon.NavBoxes(plan.Avoid))
+
+ cur := vec(plan.Entrance) // where the party stands
+ anchor := vec(plan.Entrance) // the previous objective: where the plan's journey stands
+ lost := "" // why the party is not on the plan's journey: the last failed objective
+ for _, o := range objs {
+ last := o.legs[len(o.legs)-1]
+ if o.kind == "none" {
+ rep.Legs = append(rep.Legs, BossLeg{Leg: last, Name: plan.Legs[last].Name, From: point(cur), To: o.at, Objective: o.kind, Status: "no_objective"})
+ continue
+ }
+ bl, end, err := planBossLeg(scratch, plan, o, cur, objectives == ObjectivesBosses)
+ if err != nil {
+ return rep, err
+ }
+ if lost != "" {
+ bl.NonComparable = true
+ bl.NonComparableWhy = "planned from " + lost + ", not from the previous objective"
+ if anchor != cur {
+ b, _, err := planBossLeg(scratch, plan, o, anchor, objectives == ObjectivesBosses)
+ if err != nil {
+ return rep, err
+ }
+ bl.Bridged = &b
+ }
+ }
+ if bl.Status == string(navmesh.Complete) {
+ cur, lost = end, ""
+ } else {
+ lost = fmt.Sprintf("where leg %d (%s) ended: %s, %.1f yd short of its objective", last, bl.Name, fmtPos(end), dist3(end, vec(o.at)))
+ cur = end
+ }
+ anchor = vec(o.at)
+ rep.PlanMs += bl.PlanMs
+ rep.Legs = append(rep.Legs, bl)
+ }
+ t, err := tiles(p, scratch, mmapsDir, plan)
+ rep.Tiles = t
+ return rep, err
+}
+
+// planBossLeg plans one objective from a start and measures the result: a complete path gets
+// the corridor metrics of every stored leg the objective covers; a failed one gets the stored
+// breaks it ran into. end is where the party stands afterwards.
+func planBossLeg(sc *navmesh.Planner, plan *dungeon.Plan, o objective, from Vec3, folded bool) (BossLeg, Vec3, error) {
+ last := o.legs[len(o.legs)-1]
+ names := make([]string, len(o.legs))
+ for i, li := range o.legs {
+ names[i] = plan.Legs[li].Name
+ }
+ bl := BossLeg{Leg: last, Name: strings.Join(names, "+"), From: point(from), To: o.at, Objective: o.kind}
+ if folded {
+ bl.Legs = o.legs
+ }
+ t0 := time.Now()
+ path, err := sc.Plan(plan.Map, from, vec(o.at))
+ if err != nil {
+ return bl, from, fmt.Errorf("plan %s leg %d (%s): %w", plan.Name, last, bl.Name, err)
+ }
+ bl.PlanMs = float64(time.Since(t0).Microseconds()) / 1000
+ bl.Status = string(path.Status)
+ bl.Live = runOf(path, vec(o.at))
+ bl.Reason = firstLine(path.Reason)
+ end := pathEndOr(path, from)
+ if path.Status == navmesh.Complete {
+ measureBossLeg(&bl, plan, o, path, folded)
+ return bl, end, nil
+ }
+ for _, li := range o.legs {
+ for _, b := range breaksOf(&plan.Legs[li], path.Points, end, path.Status == navmesh.Partial) {
+ if folded {
+ b.Leg = intPtr(li)
+ }
+ bl.Breaks = append(bl.Breaks, b)
+ }
+ }
+ bl.BreakHits, err = breakHits(sc, plan.Map, plan, o.legs, from, end, vec(o.at))
+ if err != nil {
+ return bl, end, fmt.Errorf("plan %s leg %d (%s): %w", plan.Name, last, bl.Name, err)
+ }
+ return bl, end, nil
+}
+
+// measureBossLeg fills the corridor metrics of an objective whose live path completed, over
+// every stored leg it covers: the packs the path passes, the hand points it makes redundant, the
+// floor jumps, the breaks it passes.
+func measureBossLeg(bl *BossLeg, plan *dungeon.Plan, o objective, path navmesh.Path, folded bool) {
+ route := path.Points
+ for _, li := range o.legs {
+ l := &plan.Legs[li]
+ for gi := range l.PullGroups {
+ pg, ok := GroupOnRoute(gi, &l.PullGroups[gi], route)
+ if !ok {
+ continue
+ }
+ if folded {
+ pg.Leg = intPtr(li)
+ }
+ bl.Packs = append(bl.Packs, pg)
+ if pg.OnPath {
+ bl.PacksOnPath++
+ }
+ }
+ for _, h := range handPoints(l, route, o.at) {
+ if folded {
+ h.Leg = intPtr(li)
+ }
+ bl.Hand = append(bl.Hand, h)
+ if h.Redundant {
+ bl.HandRedundant++
+ }
+ }
+ for _, b := range breaksOf(l, route, Vec3{}, false) {
+ if folded {
+ b.Leg = intPtr(li)
+ }
+ bl.Breaks = append(bl.Breaks, b)
+ }
+ }
+ bl.ZJumps = ZJumps(PathSamples(path))
+}
+
+// handPoints lists the leg's stored hand points (via, to, approach, boss approach, stand) and
+// how far the live route passes from each. A break waypoint is not one (it is the break, listed
+// on its own) and neither is the objective itself: in the plan-objectives mode a to point that
+// is the leg's objective is left out; in the boss-only mode no to point is an objective, so
+// every one counts.
+func handPoints(l *dungeon.Leg, route []Vec3, obj dungeon.Point) []HandPoint {
+ var out []HandPoint
+ for j, w := range legWalk(l) {
+ switch w.kind {
+ case "via", "to", "approach", "boss_approach", "stand":
+ default:
+ continue
+ }
+ if w.via != nil && breakKind(*w.via) != "" {
+ continue
+ }
+ if w.at == vec(obj) {
+ continue
+ }
+ near := NearestPolyline(route, w.at)
+ out = append(out, HandPoint{Seq: j + 1, Kind: w.kind, At: point(w.at), NearYd: near.Dist3D,
+ Redundant: near.Dist3D >= 0 && near.Dist3D <= HandNearYd})
+ }
+ return out
+}
+
+// hopRec is one stored via of a covered leg, with the stored point the party stands on before it.
+type hopRec struct {
+ leg, via int
+ w dungeon.Waypoint
+ foot Vec3
+}
+
+// breakHits lists the stored breaks a failed live plan ran into, over the stored legs the
+// objective covers. A break hop (a via that is a direct step, a gm_cross landing or a
+// check_skip) is named when
+// - it bridges: the mesh reaches the hop's foot (the stored point before it: the previous via,
+// else the last stored point of the leg before, else the start) from the start and the
+// objective from the hop's landing, so the missing link is that hop; or
+// - it lies within BreakHitYd of where the live path ended, or of the line from there to the
+// objective (the break sits at the end of the reachable ground).
+//
+// The exact via before a named hop is the hop's foot and is named with it.
+func breakHits(sc *navmesh.Planner, mapID uint32, plan *dungeon.Plan, legs []int, start, end, obj Vec3) ([]BreakHit, error) {
+ var recs []hopRec
+ prevLast := start
+ for _, li := range legs {
+ l := &plan.Legs[li]
+ for vi := range l.Via {
+ foot := prevLast
+ if vi > 0 {
+ foot = vec(l.Via[vi-1].Point)
+ }
+ recs = append(recs, hopRec{leg: li, via: vi, w: l.Via[vi], foot: foot})
+ }
+ if walk := legWalk(l); len(walk) > 0 {
+ prevLast = walk[len(walk)-1].at
+ }
+ }
+ var out []BreakHit
+ line := []Vec3{end, obj}
+ named := map[[2]int]bool{}
+ for _, r := range recs {
+ w := r.w
+ kind := breakKind(w)
+ if kind == "" || (!w.Direct && !w.GMCross && w.CheckSkip == "") {
+ continue
+ }
+ at := vec(w.Point)
+ h := BreakHit{Leg: r.leg, Via: r.via, Kind: kind, At: w.Point, EndYd: dist3(end, at), LineYd: NearestPolyline(line, at).Dist3D}
+ var why []string
+ toFoot, err := sc.Plan(mapID, start, r.foot)
+ if err != nil {
+ return nil, err
+ }
+ if toFoot.Status == navmesh.Complete {
+ fromLanding, err := sc.Plan(mapID, at, obj)
+ if err != nil {
+ return nil, err
+ }
+ if fromLanding.Status == navmesh.Complete {
+ h.Bridges = true
+ why = append(why, fmt.Sprintf("the mesh reaches its foot %s from the start and the objective from its landing", fmtPos(r.foot)))
+ }
+ }
+ if h.EndYd <= BreakHitYd || h.LineYd <= BreakHitYd {
+ why = append(why, fmt.Sprintf("it lies %.1f yd from the live end and %.1f yd from the line to the objective", h.EndYd, h.LineYd))
+ }
+ if len(why) == 0 {
+ continue
+ }
+ h.Why = strings.Join(why, "; ")
+ named[[2]int{r.leg, r.via}] = true
+ out = append(out, h)
+ }
+ // The exact foot before a named hop.
+ for i, r := range recs {
+ if i+1 < len(recs) && recs[i+1].leg == r.leg && named[[2]int{r.leg, r.via + 1}] && r.w.Exact && !named[[2]int{r.leg, r.via}] {
+ at := vec(r.w.Point)
+ out = append(out, BreakHit{Leg: r.leg, Via: r.via, Kind: breakKind(r.w), At: r.w.Point, EndYd: dist3(end, at),
+ LineYd: NearestPolyline(line, at).Dist3D, Why: fmt.Sprintf("the exact foot of via %d's hop", r.via+1)})
+ }
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Leg != out[j].Leg {
+ return out[i].Leg < out[j].Leg
+ }
+ return out[i].Via < out[j].Via
+ })
+ return out, nil
+}
+
+func intPtr(v int) *int { return &v }
diff --git a/internal/routeoracle/bosses_cgo_test.go b/internal/routeoracle/bosses_cgo_test.go
new file mode 100644
index 00000000..e758ff60
--- /dev/null
+++ b/internal/routeoracle/bosses_cgo_test.go
@@ -0,0 +1,480 @@
+//go:build cgo
+
+package routeoracle
+
+import (
+ "encoding/json"
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
+ "github.com/pjerra/turtle-agent/internal/navmesh"
+ "github.com/pjerra/turtle-agent/internal/navmesh/navfixture"
+)
+
+// gapMesh is the L corridor with the column cell y 26..30 taken out: the mesh has a break at
+// (48, 28) that only a hand hop (a direct step or a GM) crosses.
+func gapMesh() []navfixture.Poly {
+ var out []navfixture.Poly
+ for _, p := range navfixture.LCorridor(0) {
+ if p[0][0] == 46 && p[0][1] == 26 {
+ continue
+ }
+ out = append(out, p)
+ }
+ return out
+}
+
+// Leg 2 walks past the gap to the boss at (48, 48). Its stored via at (48, 34) is the break the
+// plan crosses (check_skip, so the walk mode skips it); the exact and direct vias at (20, 12)
+// and (36, 12) are breaks far from where the live path stops. Leg 3 goes back to (46, 12) from
+// where leg 2's path stopped; leg 4 goes on from leg 3's objective.
+const gapPlan = `{"version":1,"name":"gap","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"hall","to":{"x":46,"y":12,"z":0},
+ "via":[{"x":22,"y":12,"z":0}],
+ "pull_groups":[
+ {"entries":[100],"positions":[{"x":44,"y":12,"z":0}]},
+ {"entries":[101],"approach":[{"x":48,"y":40,"z":0}],"positions":[{"x":48,"y":40,"z":0}],"radius":10}]},
+ {"name":"stairs","to":{"x":48,"y":20,"z":0},
+ "via":[{"x":48,"y":34,"z":0,"check_skip":"fixture"}],
+ "boss":{"entry":200,"position":{"x":48,"y":24,"z":0}}},
+ {"name":"serpentis","to":{"x":48,"y":44,"z":0},
+ "via":[{"x":20,"y":12,"z":0,"exact":true},{"x":36,"y":12,"z":0,"direct":true,"check_skip":"fixture"},{"x":48,"y":34,"z":0,"check_skip":"fixture"}],
+ "pull_groups":[{"entries":[102],"positions":[{"x":48,"y":40,"z":0}]}],
+ "boss":{"entry":201,"position":{"x":48,"y":48,"z":0}}},
+ {"name":"back","to":{"x":46,"y":12,"z":0},
+ "via":[{"x":48,"y":16,"z":0}],
+ "pull_groups":[{"entries":[103],"positions":[{"x":44,"y":12,"z":0}]}]},
+ {"name":"onward","to":{"x":30,"y":12,"z":0},
+ "pull_groups":[{"entries":[104],"positions":[{"x":30,"y":12,"z":0}]}]}]}`
+
+func bossesFixtureParts(t *testing.T) (*navmesh.Planner, string, *dungeon.Plan) {
+ t.Helper()
+ dir := navfixture.WriteDir(t, 0, gapMesh())
+ p := navmesh.NewPlanner(dir)
+ t.Cleanup(p.Close)
+ plan, err := dungeon.Parse("gap.json", []byte(gapPlan))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ return p, dir, plan
+}
+
+func bossesFixture(t *testing.T) BossesReport {
+ t.Helper()
+ p, dir, plan := bossesFixtureParts(t)
+ rep, err := Bosses(p, dir, plan, ObjectivesPlan)
+ if err != nil {
+ t.Fatalf("Bosses: %v", err)
+ }
+ if len(rep.Legs) != 5 {
+ t.Fatalf("legs: %d", len(rep.Legs))
+ }
+ return rep
+}
+
+// A leg the mesh cannot finish FAILS and names the stored break at the gap: the walk mode skips
+// that break hop, the bosses mode must not. A stored break far from where the path stops is not
+// named.
+func TestBossesGapFailsTheLegAndNamesTheBreak(t *testing.T) {
+ rep := bossesFixture(t)
+ for _, i := range []int{0, 1} {
+ if rep.Legs[i].Status != "complete" || rep.Legs[i].NonComparable || rep.Legs[i].Failed() {
+ t.Errorf("leg %d: %+v", i, rep.Legs[i])
+ }
+ }
+ f := rep.Legs[2]
+ if f.Status != string(navmesh.Partial) || !f.Failed() {
+ t.Fatalf("leg 2: status %s failed %v, want a failed partial", f.Status, f.Failed())
+ }
+ if f.Live.EndGapYd < 15 || f.Live.EndGapYd > 25 {
+ t.Errorf("leg 2 gap %.1f yd, want ~22 (the path stops at the gap)", f.Live.EndGapYd)
+ }
+ if len(f.BreakHits) != 1 {
+ t.Fatalf("leg 2 break hits: %+v", f.BreakHits)
+ }
+ h := f.BreakHits[0]
+ if h.Via != 2 || h.Kind != "check_skip" || h.At.Y != 34 || h.EndYd > 15 {
+ t.Errorf("break hit %+v, want via 2 check_skip at y 34 within 15 yd of the end", h)
+ }
+ // The failed leg's own metrics stay empty.
+ if len(f.Packs) != 0 || len(f.Hand) != 0 || len(f.ZJumps) != 0 {
+ t.Errorf("failed leg carries metrics: %+v", f)
+ }
+ lines := strings.Join(failedBossLegsForTest(rep), "\n")
+ if !strings.Contains(lines, "serpentis") || !strings.Contains(lines, "check_skip") {
+ t.Errorf("failed lines %q", lines)
+ }
+}
+
+// failedBossLegsForTest keeps the package test free of the command's text.
+func failedBossLegsForTest(rep BossesReport) []string {
+ var out []string
+ for i := range rep.Legs {
+ if rep.Legs[i].Failed() {
+ out = append(out, rep.Legs[i].Name+" "+rep.Legs[i].BreakHits[0].Kind)
+ }
+ }
+ return out
+}
+
+// The leg after the failed one starts from the last point the failed path reached and is
+// non-comparable; it adds nothing to the aggregates; the leg after it (which started on a real
+// objective) counts again.
+func TestBossesFailedLegDoesNotContaminate(t *testing.T) {
+ rep := bossesFixture(t)
+ f, nc, next := rep.Legs[2], rep.Legs[3], rep.Legs[4]
+ if !nc.NonComparable || nc.NonComparableWhy == "" || !strings.Contains(nc.NonComparableWhy, "leg 2 (serpentis)") {
+ t.Errorf("leg 3 not labelled non-comparable: %+v", nc)
+ }
+ if nc.From != f.Live.End {
+ t.Errorf("leg 3 starts at %v, want the failed path's end %v", nc.From, f.Live.End)
+ }
+ if nc.Status != "complete" || nc.Failed() || nc.Counted() {
+ t.Errorf("leg 3: status %s failed %v counted %v", nc.Status, nc.Failed(), nc.Counted())
+ }
+ if next.NonComparable || !next.Counted() {
+ t.Errorf("leg 4 should count again: %+v", next)
+ }
+ if next.From.X != 46 || next.From.Y < 11 || next.From.Y > 13 {
+ t.Errorf("leg 4 starts at %v, want leg 3's objective (46, 12)", next.From)
+ }
+ // Totals: legs 0, 1 and 4 only.
+ tot := rep.Totals()
+ if tot.Complete != 3 || tot.Failed != 1 || tot.Continuations != 1 || tot.ContinuationsOK != 1 {
+ t.Errorf("totals %+v", tot)
+ }
+ var packs, on, hand, red, zj int
+ var live float64
+ for _, i := range []int{0, 1, 4} {
+ l := rep.Legs[i]
+ packs += len(l.Packs)
+ on += l.PacksOnPath
+ hand += len(l.Hand)
+ red += l.HandRedundant
+ zj += len(l.ZJumps)
+ live += l.Live.LiveLenYd
+ }
+ if tot.Sum.Packs != packs || tot.Sum.PacksOn != on || tot.Sum.Hand != hand || tot.Sum.HandRedundant != red || tot.Sum.ZJumps != zj || tot.Sum.LiveYd != live {
+ t.Errorf("totals %+v do not equal the counted legs' sums (%d/%d packs, %d/%d hand, %d jumps, %.1f yd)", tot, on, packs, red, hand, zj, live)
+ }
+ // Leg 3 would have changed the sums had it been counted: its pack sits on its path.
+ if nc.PacksOnPath == 0 || len(nc.Hand) == 0 {
+ t.Errorf("leg 3 carries no metrics to exclude: %+v", nc)
+ }
+ s := rep.Summary()
+ if !strings.Contains(s, "3 complete, 1 failed, 1 non-comparable (1 reach their objective)") {
+ t.Errorf("summary %q", s)
+ }
+}
+
+// The corridor metrics of a complete leg: a pack whose centre the live path passes is on it, a
+// pack far from it is not; a via the path passes is redundant, an approach far from it is not;
+// a break via is no hand point.
+func TestBossesCorridorMetrics(t *testing.T) {
+ rep := bossesFixture(t)
+ h := rep.Legs[0]
+ if len(h.Packs) != 2 || !h.Packs[0].OnPath || h.Packs[1].OnPath {
+ t.Errorf("hall packs: %+v", h.Packs)
+ }
+ if len(h.Hand) != 2 {
+ t.Fatalf("hall hand points: %+v", h.Hand)
+ }
+ if h.Hand[0].Kind != "via" || !h.Hand[0].Redundant {
+ t.Errorf("via (22,12): %+v, want redundant", h.Hand[0])
+ }
+ if h.Hand[1].Kind != "approach" || h.Hand[1].Redundant || h.Hand[1].NearYd < 20 {
+ t.Errorf("approach (48,40): %+v, want far from the live path", h.Hand[1])
+ }
+ // Leg 1's only via is a break: not a hand point, but listed with the breaks, untouched.
+ st := rep.Legs[1]
+ if len(st.Hand) != 1 || st.Hand[0].Kind != "to" {
+ t.Errorf("stairs hand points %+v: want the to alone (a break via is not one)", st.Hand)
+ }
+ if len(st.Breaks) != 1 || st.Breaks[0].Touched {
+ t.Errorf("stairs breaks %+v", st.Breaks)
+ }
+ if st.Live.LiveLenYd < 12 {
+ t.Errorf("stairs live %.1f yd", st.Live.LiveLenYd)
+ }
+}
+
+// A planner on a mesh with no mmaps is an error, not a report; a bad plan name is refused.
+func TestBossesRefusesABadName(t *testing.T) {
+ p := navmesh.NewPlanner("")
+ defer p.Close()
+ if _, err := Bosses(p, "", &dungeon.Plan{Name: "../escape"}, ObjectivesPlan); err == nil || !strings.Contains(err.Error(), "not a plan name") {
+ t.Errorf("err %v", err)
+ }
+}
+
+// A break far from where the live path stops is still named when it is the missing link: the
+// island at (100..120) is reached by the plan's check_skip hop at (102, 102), 70 yd from the
+// end of the reachable ground; a decoy break at (30, 12) does not lead to the island and is
+// not named. Then the leg after the failed one is planned a second time from the failed leg's
+// objective (bridged), so the mesh's other gaps show up.
+func TestBossesNamesTheBridgingBreak(t *testing.T) {
+ plan := `{"version":1,"name":"island","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"crossing","to":{"x":110,"y":110,"z":0},
+ "via":[{"x":30,"y":12,"z":0,"check_skip":"decoy"},{"x":48,"y":20,"z":0},{"x":102,"y":102,"z":0,"check_skip":"the island"}],
+ "boss":{"entry":300,"position":{"x":110,"y":110,"z":0}}},
+ {"name":"island hall","to":{"x":104,"y":104,"z":0},"boss":{"entry":301,"position":{"x":116,"y":116,"z":0}}},
+ {"name":"voided","to":{"x":300,"y":300,"z":0},"boss":{"entry":302,"position":{"x":300,"y":300,"z":0}}}]}`
+ polys := append(navfixture.LCorridor(0), navfixture.Square(100, 100, 120, 120, 0))
+ dir := navfixture.WriteDir(t, 0, polys)
+ p := navmesh.NewPlanner(dir)
+ t.Cleanup(p.Close)
+ dp, err := dungeon.Parse("island.json", []byte(plan))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ rep, err := Bosses(p, dir, dp, ObjectivesPlan)
+ if err != nil {
+ t.Fatalf("Bosses: %v", err)
+ }
+ f := rep.Legs[0]
+ if !f.Failed() || len(f.BreakHits) != 1 {
+ t.Fatalf("leg 0: status %s hits %+v", f.Status, f.BreakHits)
+ }
+ h := f.BreakHits[0]
+ if h.Via != 2 || !h.Bridges || h.EndYd < 40 || !strings.Contains(h.Why, "reaches its foot") {
+ t.Errorf("hit %+v, want via 2 named only because it bridges (end %.0f yd away)", h, h.EndYd)
+ }
+ // Leg 1 starts on the mainland (the failed path's end): non-comparable and failing; planned
+ // from leg 0's objective on the island it completes, which the bridged re-plan says.
+ n := rep.Legs[1]
+ if !n.NonComparable || n.Status == "complete" {
+ t.Errorf("leg 1: %+v", n)
+ }
+ if n.Bridged == nil || n.Bridged.Status != "complete" || n.Bridged.From != f.To || len(n.Bridged.Hand) != 1 {
+ t.Errorf("leg 1 bridged: %+v", n.Bridged)
+ }
+ tot := rep.Totals()
+ if tot.Complete != 0 || tot.Failed != 1 || tot.Continuations != 2 || tot.BridgedComplete != 1 || tot.Sum.LiveYd != 0 || tot.Bridged.Hand != 1 {
+ t.Errorf("totals %+v", tot)
+ }
+ // The void leg is non-comparable too, and its bridged plan fails as well.
+ if v := rep.Legs[2]; !v.NonComparable || v.Bridged == nil || v.Bridged.Status == "complete" {
+ t.Errorf("leg 2: %+v", v)
+ }
+}
+
+// Boss-only objectives: the chain is entrance -> boss 1 -> boss 2. The gap plan's legs 0 (hall),
+// 1 (stairs, a boss) and 2 (serpentis, a boss) give two objectives; the hall is folded into the
+// stairs row, its to point (46, 12) is a hand point and NOT an objective, and the legs after the
+// last boss (3, 4) are not planned.
+func TestBossOnlyChainFoldsBosslessLegs(t *testing.T) {
+ p, dir, plan := bossesFixtureParts(t)
+ rep, err := Bosses(p, dir, plan, ObjectivesBosses)
+ if err != nil {
+ t.Fatalf("Bosses: %v", err)
+ }
+ if rep.Objectives != ObjectivesBosses || len(rep.Legs) != 2 || len(rep.Unplanned) != 2 || rep.Unplanned[0] != 3 || rep.Unplanned[1] != 4 {
+ t.Fatalf("objectives %s legs %d unplanned %v", rep.Objectives, len(rep.Legs), rep.Unplanned)
+ }
+ a := rep.Legs[0]
+ if a.Name != "hall+stairs" || a.Leg != 1 || len(a.Legs) != 2 || a.Objective != "boss" || a.To.Y != 24 {
+ t.Errorf("row 0: %+v", a)
+ }
+ if a.From.X != 12 || a.From.Y != 12 || a.Status != "complete" {
+ t.Errorf("row 0 starts %v status %s", a.From, a.Status)
+ }
+ // The hall's to point (46, 12) and the stairs' to (48, 20) are hand points here (neither is
+ // an objective); the hall's far approach too. In the plan mode the hall's to IS its objective.
+ var to, sawHallTo int
+ for _, h := range a.Hand {
+ if h.Kind == "to" {
+ to++
+ if h.Leg != nil && *h.Leg == 0 && h.At.X == 46 {
+ sawHallTo++
+ }
+ }
+ }
+ if to != 2 || sawHallTo != 1 {
+ t.Errorf("row 0 to points among the hand points: %d (hall's: %d), hand %+v", to, sawHallTo, a.Hand)
+ }
+ if len(a.Packs) != 2 || a.Packs[0].Leg == nil || *a.Packs[0].Leg != 0 || a.Packs[1].Leg == nil || *a.Packs[1].Leg != 0 {
+ t.Errorf("row 0 packs %+v, want the hall's two groups", a.Packs)
+ }
+ plain, err := Bosses(p, dir, plan, ObjectivesPlan)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range plain.Legs[0].Hand {
+ if h.Kind == "to" {
+ t.Errorf("plan mode counted the hall's objective to point as a hand point: %+v", h)
+ }
+ }
+ if plain.Legs[0].Objective != "to" {
+ t.Errorf("plan mode leg 0 objective %s", plain.Legs[0].Objective)
+ }
+ // Row 1: serpentis fails at the gap, names the break (stored leg 2, via 2).
+ f := rep.Legs[1]
+ if !f.Failed() || f.Leg != 2 || len(f.BreakHits) != 1 || f.BreakHits[0].Leg != 2 || f.BreakHits[0].Via != 2 {
+ t.Errorf("row 1: status %s hits %+v", f.Status, f.BreakHits)
+ }
+ tot := rep.Totals()
+ if tot.Complete != 1 || tot.Failed != 1 || tot.Sum.Packs != 2 || tot.Sum.PacksOn != 2 {
+ t.Errorf("totals %+v", tot)
+ }
+ if s := rep.Summary(); !contains(s, "2 boss objectives: 1 complete, 1 failed") || !contains(s, "2 legs after the last boss not planned") {
+ t.Errorf("summary %q", s)
+ }
+}
+
+func contains(s, sub string) bool { return strings.Contains(s, sub) }
+
+// Boss-only chain after a failure: the boss after the failed one is non-comparable and bridged
+// from the failed boss's position, as in the plan mode.
+func TestBossOnlyContinuationAfterAFailedBoss(t *testing.T) {
+ plan := `{"version":1,"name":"island","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"crossing","to":{"x":60,"y":60,"z":0},
+ "via":[{"x":48,"y":20,"z":0},{"x":102,"y":102,"z":0,"check_skip":"the island"}],
+ "boss":{"entry":300,"position":{"x":110,"y":110,"z":0}}},
+ {"name":"island hall","to":{"x":104,"y":104,"z":0},"boss":{"entry":301,"position":{"x":116,"y":116,"z":0}}}]}`
+ polys := append(navfixture.LCorridor(0), navfixture.Square(100, 100, 120, 120, 0))
+ dir := navfixture.WriteDir(t, 0, polys)
+ p := navmesh.NewPlanner(dir)
+ t.Cleanup(p.Close)
+ dp, err := dungeon.Parse("island.json", []byte(plan))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ rep, err := Bosses(p, dir, dp, ObjectivesBosses)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rep.Legs) != 2 || !rep.Legs[0].Failed() || len(rep.Legs[0].BreakHits) != 1 {
+ t.Fatalf("legs %+v", rep.Legs)
+ }
+ n := rep.Legs[1]
+ if !n.NonComparable || n.Bridged == nil || n.Bridged.Status != "complete" || n.Bridged.From != rep.Legs[0].To {
+ t.Errorf("row 1: %+v", n)
+ }
+ if tot := rep.Totals(); tot.Complete != 0 || tot.Failed != 1 || tot.Continuations != 1 || tot.Sum.LiveYd != 0 {
+ t.Errorf("totals %+v", tot)
+ }
+ if _, err := Bosses(p, dir, dp, "boss"); err == nil {
+ t.Error("an unknown objectives value was accepted")
+ }
+}
+
+// Both modes judge a pack by the same definition: a group's position within its radius plus 18
+// yd of the live route. Two groups of radius 5 sit 22 and 24 yd off the corridor: on (<= 23) and
+// off (> 23), in the walk mode and in the bosses mode alike.
+func TestPackDefinitionIsTheSameInBothModes(t *testing.T) {
+ plan := `{"version":1,"name":"packs","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"hall","to":{"x":46,"y":12,"z":0},"pull_groups":[
+ {"entries":[1],"radius":5,"positions":[{"x":30,"y":34,"z":0}]},
+ {"entries":[2],"radius":5,"positions":[{"x":30,"y":36,"z":0}]}]}]}`
+ dir := navfixture.WriteDir(t, 0, navfixture.LCorridor(0))
+ p := navmesh.NewPlanner(dir)
+ t.Cleanup(p.Close)
+ dp, err := dungeon.Parse("packs.json", []byte(plan))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ walk, err := Oracle(p, dir, dp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ live, err := Bosses(p, dir, dp, ObjectivesPlan)
+ if err != nil {
+ t.Fatal(err)
+ }
+ w, b := walk.Legs[0].Pulls, live.Legs[0].Packs
+ if len(w) != 2 || len(b) != 2 {
+ t.Fatalf("walk %d packs, bosses %d", len(w), len(b))
+ }
+ for i, want := range []bool{true, false} {
+ if w[i].OnPath != want || b[i].OnPath != want {
+ t.Errorf("group %d: walk %v bosses %v, want %v (near %.1f / %.1f yd)", i, w[i].OnPath, b[i].OnPath, want, w[i].NearYd, b[i].NearYd)
+ }
+ }
+}
+
+// A pull group with no position and no hold has nothing to walk to: the walk leaves it out
+// instead of walking to the NaN centroid of nothing, and neither mode counts it in a pack total.
+func TestEmptyPullGroupIsSkippedAndCounted(t *testing.T) {
+ plan := &dungeon.Plan{Name: "empty", Entrance: dungeon.Point{X: 12, Y: 12}, Legs: []dungeon.Leg{{
+ Name: "hall", To: dungeon.Point{X: 46, Y: 12},
+ PullGroups: []dungeon.PullGroup{{Entries: []uint32{1}}, {Entries: []uint32{2}, Positions: []dungeon.Point{{X: 44, Y: 12}}}},
+ }}}
+ dir := navfixture.WriteDir(t, 0, navfixture.LCorridor(0))
+ p := navmesh.NewPlanner(dir)
+ t.Cleanup(p.Close)
+ walk, err := Oracle(p, dir, plan)
+ if err != nil {
+ t.Fatalf("Oracle: %v", err)
+ }
+ if walk.EmptyGroups != 1 || len(walk.Legs[0].Pulls) != 1 || walk.Legs[0].Pulls[0].Group != 1 || walk.Legs[0].Status != "complete" {
+ t.Errorf("walk: empty %d pulls %+v status %s", walk.EmptyGroups, walk.Legs[0].Pulls, walk.Legs[0].Status)
+ }
+ for _, s := range walk.Legs[0].Segments {
+ if math.IsNaN(float64(s.At.X)) || s.Kind == "pull" && s.Group == 0 {
+ t.Errorf("a hop to the empty group: %+v", s)
+ }
+ }
+ live, err := Bosses(p, dir, plan, ObjectivesPlan)
+ if err != nil {
+ t.Fatalf("Bosses: %v", err)
+ }
+ if live.EmptyGroups != 1 || len(live.Legs[0].Packs) != 1 || live.Legs[0].Packs[0].Group != 1 {
+ t.Errorf("bosses: empty %d packs %+v", live.EmptyGroups, live.Legs[0].Packs)
+ }
+ if !contains(walk.Summary(), "1 empty pull groups skipped") || !contains(live.Summary(), "1 empty pull groups skipped") {
+ t.Errorf("summaries %q / %q", walk.Summary(), live.Summary())
+ }
+}
+
+// A folded boss-only row says which stored leg each pack, hand point and break belongs to, and
+// writes the index even for leg 0 (a pointer, not an omitted zero). Leg 1 here is the NON-zero
+// case: dropping the assignment leaves the field nil and fails.
+func TestBossOnlyRowNamesTheStoredLegOfEachItem(t *testing.T) {
+ plan := `{"version":1,"name":"fold","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"a","to":{"x":30,"y":12,"z":0},"pull_groups":[{"entries":[1],"positions":[{"x":30,"y":14,"z":0}]}]},
+ {"name":"b","to":{"x":46,"y":16,"z":0},"via":[{"x":40,"y":12,"z":0,"check_skip":"fixture"}],
+ "pull_groups":[{"entries":[2],"positions":[{"x":46,"y":14,"z":0}]}],
+ "boss":{"entry":9,"position":{"x":48,"y":24,"z":0}}}]}`
+ dir := navfixture.WriteDir(t, 0, navfixture.LCorridor(0))
+ p := navmesh.NewPlanner(dir)
+ t.Cleanup(p.Close)
+ dp, err := dungeon.Parse("fold.json", []byte(plan))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ rep, err := Bosses(p, dir, dp, ObjectivesBosses)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rep.Legs) != 1 || rep.Legs[0].Status != "complete" || len(rep.Legs[0].Legs) != 2 {
+ t.Fatalf("rows %+v", rep.Legs)
+ }
+ row := rep.Legs[0]
+ got := func(p *int) int {
+ if p == nil {
+ return -1
+ }
+ return *p
+ }
+ if len(row.Packs) != 2 || got(row.Packs[0].Leg) != 0 || got(row.Packs[1].Leg) != 1 {
+ t.Errorf("pack legs: %+v", row.Packs)
+ }
+ var toLegs []int
+ for _, h := range row.Hand {
+ if h.Kind == "to" {
+ toLegs = append(toLegs, got(h.Leg))
+ }
+ }
+ if len(toLegs) != 2 || toLegs[0] != 0 || toLegs[1] != 1 {
+ t.Errorf("to points' legs %v, want [0 1]", toLegs)
+ }
+ if len(row.Breaks) != 1 || got(row.Breaks[0].Leg) != 1 {
+ t.Errorf("break legs: %+v", row.Breaks)
+ }
+ b, err := json.Marshal(row.Packs)
+ if err != nil || !strings.Contains(string(b), `"leg":0`) || !strings.Contains(string(b), `"leg":1`) {
+ t.Errorf("packs json %s (err %v): leg 0 must be written too", b, err)
+ }
+}
diff --git a/internal/routeoracle/bosses_test.go b/internal/routeoracle/bosses_test.go
new file mode 100644
index 00000000..95e8c350
--- /dev/null
+++ b/internal/routeoracle/bosses_test.go
@@ -0,0 +1,76 @@
+package routeoracle
+
+import (
+ "math"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
+)
+
+// The pack-on-route definition both modes share, at its edges: a position counts when it is
+// within the group's radius plus CorridorSlackYd (18) of the route horizontally and no more than
+// CorridorMaxDZYd (8) above or below it. Each case sits just either side of an edge, so a
+// threshold moved by a yard (the slack dropped, doubled, or the radius left out) fails.
+func TestGroupOnRouteBoundaries(t *testing.T) {
+ route := []Vec3{{0, 0, 0}, {100, 0, 0}}
+ pos := func(y, z float32) []dungeon.Point { return []dungeon.Point{{X: 50, Y: y, Z: z}} }
+ cases := []struct {
+ name string
+ radius float32
+ at []dungeon.Point
+ on bool
+ }{
+ {"radius 10: 27.9 yd out is on", 10, pos(27.9, 0), true},
+ {"radius 10: 28.1 yd out is off", 10, pos(28.1, 0), false},
+ {"default radius 25: 42.9 yd out is on", 0, pos(42.9, 0), true},
+ {"default radius 25: 43.1 yd out is off", 0, pos(43.1, 0), false},
+ {"radius 10: 7.9 yd above is on", 10, pos(0, 7.9), true},
+ {"radius 10: 8.1 yd above is off", 10, pos(0, 8.1), false},
+ {"radius 10: 8.1 yd below is off", 10, pos(0, -8.1), false},
+ {"any one position is enough", 10, []dungeon.Point{{X: 50, Y: 90, Z: 0}, {X: 20, Y: 27.9, Z: 0}}, true},
+ {"none of two positions", 10, []dungeon.Point{{X: 50, Y: 90, Z: 0}, {X: 20, Y: 28.1, Z: 0}}, false},
+ }
+ for _, c := range cases {
+ g := &dungeon.PullGroup{Radius: c.radius, Positions: c.at}
+ got, ok := GroupOnRoute(0, g, route)
+ if !ok || got.OnPath != c.on {
+ t.Errorf("%s: ok %v on %v (near %.1f dz %.1f), want %v", c.name, ok, got.OnPath, got.NearYd, got.DZYd, c.on)
+ }
+ }
+}
+
+// A hold-only group is judged by its spot; a group with neither a position nor a hold is empty:
+// no result, so it is in no denominator.
+func TestGroupOnRouteHoldAndEmptyGroups(t *testing.T) {
+ route := []Vec3{{0, 0, 0}, {100, 0, 0}}
+ hold := &dungeon.PullGroup{Radius: 10, Hold: &dungeon.Hold{Spot: dungeon.Point{X: 50, Y: 27.9}}}
+ if got, ok := GroupOnRoute(2, hold, route); !ok || !got.OnPath || got.Group != 2 {
+ t.Errorf("hold-only: %+v ok %v", got, ok)
+ }
+ if _, ok := GroupOnRoute(0, &dungeon.PullGroup{Entries: []uint32{1}}, route); ok {
+ t.Error("an empty group has a pack result")
+ }
+ if n := emptyGroups(&dungeon.Plan{Legs: []dungeon.Leg{{PullGroups: []dungeon.PullGroup{{}, *hold}}, {PullGroups: []dungeon.PullGroup{{}}}}}); n != 2 {
+ t.Errorf("empty groups %d, want 2", n)
+ }
+}
+
+// NearYd is the nearest of a group's positions, not the last one examined: with an off-path
+// group whose nearest position comes first it still reports the nearest.
+func TestGroupNearYdIsTheMinimumOverAllPositions(t *testing.T) {
+ route := []Vec3{{0, 0, 0}, {100, 0, 0}}
+ g := &dungeon.PullGroup{Radius: 10, Positions: []dungeon.Point{{X: 50, Y: 30, Z: 0}, {X: 50, Y: 90, Z: 0}}}
+ got, ok := GroupOnRoute(0, g, route)
+ if !ok || got.OnPath || math.Abs(got.NearYd-30) > 0.01 {
+ t.Errorf("off-path group: %+v ok %v, want near 30.0 (not the last position's 90.0)", got, ok)
+ }
+ // Same answer whatever the order, and when a later position puts the group on the path.
+ g2 := &dungeon.PullGroup{Radius: 10, Positions: []dungeon.Point{{X: 50, Y: 90, Z: 0}, {X: 50, Y: 30, Z: 0}}}
+ if got, _ := GroupOnRoute(0, g2, route); math.Abs(got.NearYd-30) > 0.01 {
+ t.Errorf("reversed: near %.1f", got.NearYd)
+ }
+ g3 := &dungeon.PullGroup{Radius: 10, Positions: []dungeon.Point{{X: 50, Y: 20, Z: 0}, {X: 50, Y: 60, Z: 0}, {X: 50, Y: 5, Z: 0}}}
+ if got, _ := GroupOnRoute(0, g3, route); !got.OnPath || math.Abs(got.NearYd-5) > 0.01 {
+ t.Errorf("on-path group: %+v, want on and near 5.0", got)
+ }
+}
diff --git a/internal/routeoracle/metrics.go b/internal/routeoracle/metrics.go
new file mode 100644
index 00000000..6a9bb03e
--- /dev/null
+++ b/internal/routeoracle/metrics.go
@@ -0,0 +1,159 @@
+// Package routeoracle compares a dungeon plan's stored route with what the server's navmesh
+// plans for the same walks, leg by leg, and reports how far the two are apart. It is the
+// route-oracle command's engine and Stage 1's fixture writer: the stored polyline is what the
+// plan and planexport hold, the live path is what Planner.Plan answers, and the report's
+// numbers (ratio, corridor membership, break proximity, Z jumps) are the gap between them.
+//
+// The package reads plans and mmaps and nothing else.
+package routeoracle
+
+import (
+ "math"
+
+ "github.com/pjerra/turtle-agent/internal/navmesh"
+)
+
+// Vec3 is a WoW position (x, y, z).
+type Vec3 = navmesh.Vec3
+
+// The report's distances and the thresholds behind them, in yards. The brief fixes them:
+// a pull group is on the path when one of its positions stands within its radius plus
+// CorridorSlackYd of it (horizontally) and no more than CorridorMaxDZYd above or below it;
+// a stored approach or via point within RedundantYd of the path adds nothing; a break
+// (a waypoint with exact/direct/gm_cross/check_skip) is touched when the path passes within
+// BreakNearYd of its foot, or when the leg is Partial and the path's end gap ends within
+// BreakGapYd of the foot; consecutive path samples more than ZJumpYd apart in Z are a jump
+// the mesh joins without a climb.
+const (
+ CorridorSlackYd = 18.0
+ CorridorMaxDZYd = 8.0
+ RedundantYd = 5.0
+ BreakNearYd = 6.0
+ BreakGapYd = 10.0
+ ZJumpYd = 8.0
+
+ // DefaultPackRadius is the dungeon driver's radius around a pull position when a group
+ // gives none (driver.go's PackRadius default).
+ DefaultPackRadius = 25.0
+)
+
+func sq(v float64) float64 { return v * v }
+
+// dist3 is the straight distance between two points.
+func dist3(a, b Vec3) float64 {
+ return math.Sqrt(sq(float64(a[0]-b[0])) + sq(float64(a[1]-b[1])) + sq(float64(a[2]-b[2])))
+}
+
+// dist2 is the horizontal distance between two points.
+func dist2(a, b Vec3) float64 {
+ return math.Sqrt(sq(float64(a[0]-b[0])) + sq(float64(a[1]-b[1])))
+}
+
+// NearPoint is the distance of a point to a polyline: the straight (3D) distance to the
+// nearest point of it, the horizontal (2D) one, the height between the two, and the nearest
+// point itself. Dist3D is the honest "how far is it from what is walked"; the corridor tests
+// take Dist2D and DZ apart, because a pack a floor above a corridor is not on it however
+// close horizontally.
+type NearPoint struct {
+ Dist3D float64
+ Dist2D float64
+ DZ float64
+ At Vec3 // the nearest point of the polyline
+}
+
+// NearestPolyline is the nearest point of the polyline pts to p. A one-point polyline is a
+// point; an empty one returns nothing (Dist3D negative).
+func NearestPolyline(pts []Vec3, p Vec3) NearPoint {
+ if len(pts) == 0 {
+ return NearPoint{Dist3D: -1}
+ }
+ best := NearPoint{Dist3D: math.Inf(1), Dist2D: math.Inf(1)}
+ upd := func(q Vec3) {
+ d3, d2, dz := dist3(p, q), dist2(p, q), math.Abs(float64(p[2]-q[2]))
+ if d3 < best.Dist3D {
+ best = NearPoint{Dist3D: d3, Dist2D: d2, DZ: dz, At: q}
+ }
+ }
+ for i := range pts {
+ upd(pts[i])
+ }
+ for i := 1; i < len(pts); i++ {
+ a, b := pts[i-1], pts[i]
+ ab := [3]float64{float64(b[0] - a[0]), float64(b[1] - a[1]), float64(b[2] - a[2])}
+ ln := sq(ab[0]) + sq(ab[1]) + sq(ab[2])
+ if ln <= 0 {
+ continue
+ }
+ ap := [3]float64{float64(p[0] - a[0]), float64(p[1] - a[1]), float64(p[2] - a[2])}
+ f := (ab[0]*ap[0] + ab[1]*ap[1] + ab[2]*ap[2]) / ln
+ if f <= 0 || f >= 1 {
+ continue // the ends are done
+ }
+ upd(Vec3{a[0] + float32(ab[0]*f), a[1] + float32(ab[1]*f), a[2] + float32(ab[2]*f)})
+ }
+ return best
+}
+
+// PolylineLength is the length of the walk along the polyline, in yards.
+func PolylineLength(pts []Vec3) float64 {
+ var n float64
+ for i := 1; i < len(pts); i++ {
+ n += dist3(pts[i-1], pts[i])
+ }
+ return n
+}
+
+// PathSamples flattens a navmesh path into the samples a walk passes: every corner and,
+// between two corners, the surface samples of the segment. A gap between two samples of
+// these more than ZJumpYd tall is ground the walk falls or hops.
+func PathSamples(path navmesh.Path) []Vec3 {
+ if len(path.Points) == 0 {
+ return nil
+ }
+ out := make([]Vec3, 0, len(path.Points))
+ out = append(out, path.Points[0])
+ for i := 1; i < len(path.Points); i++ {
+ out = append(out, path.Ground[i-1]...)
+ out = append(out, path.Points[i])
+ }
+ return out
+}
+
+// ZJump is one step of more than ZJumpYd between two consecutive samples of a path: a rise
+// or drop the mesh joins without a climb a walk can take. At is the lower of the two samples.
+type ZJump struct {
+ At Vec3
+ DropYd float64
+}
+
+// ZJumps lists the jumps between consecutive samples of a path, in walk order.
+func ZJumps(samples []Vec3) []ZJump {
+ var out []ZJump
+ for i := 1; i < len(samples); i++ {
+ dz := float64(samples[i][2] - samples[i-1][2])
+ if math.Abs(dz) > ZJumpYd {
+ at := samples[i-1]
+ if dz < 0 {
+ at = samples[i]
+ }
+ out = append(out, ZJump{At: at, DropYd: math.Abs(dz)})
+ }
+ }
+ return out
+}
+
+// OnCorridor says whether a position is in the corridor of the polyline: within radius
+// (a pack's radius) plus CorridorSlackYd of it horizontally, and no more than
+// CorridorMaxDZYd above or below the path at that point.
+func OnCorridor(near NearPoint, radius float64) bool {
+ if near.Dist3D < 0 {
+ return false
+ }
+ return near.Dist2D <= radius+CorridorSlackYd && near.DZ <= CorridorMaxDZYd
+}
+
+// Redundant says whether a stored approach or via point lies within RedundantYd of the
+// live path: the walk the plan stores adds nothing the path does not already pass.
+func Redundant(near NearPoint) bool {
+ return near.Dist3D >= 0 && near.Dist3D <= RedundantYd
+}
diff --git a/internal/routeoracle/metrics_test.go b/internal/routeoracle/metrics_test.go
new file mode 100644
index 00000000..36225109
--- /dev/null
+++ b/internal/routeoracle/metrics_test.go
@@ -0,0 +1,162 @@
+package routeoracle
+
+import (
+ "math"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/navmesh"
+)
+
+func near(t *testing.T, pts []Vec3, p Vec3) NearPoint {
+ t.Helper()
+ n := NearestPolyline(pts, p)
+ if n.Dist3D < 0 {
+ t.Fatalf("NearestPolyline of %v: empty polyline", p)
+ }
+ return n
+}
+
+func TestNearestPolyline(t *testing.T) {
+ cases := []struct {
+ name string
+ pts []Vec3
+ p Vec3
+ want NearPoint
+ }{
+ {"mid segment", []Vec3{{0, 0, 0}, {10, 0, 0}}, Vec3{5, 3, 0}, NearPoint{Dist3D: 3, Dist2D: 3, At: Vec3{5, 0, 0}}},
+ {"past the end", []Vec3{{0, 0, 0}, {10, 0, 0}}, Vec3{14, 0, 0}, NearPoint{Dist3D: 4, Dist2D: 4, At: Vec3{10, 0, 0}}},
+ {"before the start", []Vec3{{0, 0, 0}, {10, 0, 0}}, Vec3{-4, 0, 0}, NearPoint{Dist3D: 4, Dist2D: 4, At: Vec3{0, 0, 0}}},
+ {"second segment wins", []Vec3{{0, 0, 0}, {10, 0, 0}, {10, 10, 0}}, Vec3{11, 6, 0}, NearPoint{Dist3D: 1, Dist2D: 1, At: Vec3{10, 6, 0}}},
+ {"straight above", []Vec3{{0, 0, 0}, {10, 0, 0}}, Vec3{5, 0, 7}, NearPoint{Dist3D: 7, Dist2D: 0, DZ: 7, At: Vec3{5, 0, 0}}},
+ {"single point", []Vec3{{2, 3, 1}}, Vec3{2, 3, 1}, NearPoint{Dist3D: 0, Dist2D: 0, At: Vec3{2, 3, 1}}},
+ {"empty polyline", nil, Vec3{0, 0, 0}, NearPoint{Dist3D: -1}},
+ }
+ for _, c := range cases {
+ got := NearestPolyline(c.pts, c.p)
+ if (got.Dist3D < 0) != (c.want.Dist3D < 0) {
+ t.Errorf("%s: got Dist3D %v, want %v", c.name, got.Dist3D, c.want.Dist3D)
+ continue
+ }
+ if got.Dist3D < 0 {
+ continue
+ }
+ if math.Abs(got.Dist3D-c.want.Dist3D) > 1e-6 || math.Abs(got.Dist2D-c.want.Dist2D) > 1e-6 || math.Abs(got.DZ-c.want.DZ) > 1e-6 {
+ t.Errorf("%s: got %+v, want %+v", c.name, got, c.want)
+ }
+ }
+}
+
+func TestNearestPolylineEmpty(t *testing.T) {
+ if n := NearestPolyline(nil, Vec3{1, 2, 3}); n.Dist3D >= 0 {
+ t.Errorf("empty polyline: %+v", n)
+ }
+}
+
+func TestPolylineLength(t *testing.T) {
+ cases := []struct {
+ name string
+ pts []Vec3
+ want float64
+ }{
+ {"one point", []Vec3{{0, 0, 0}}, 0},
+ {"two points", []Vec3{{0, 0, 0}, {3, 4, 0}}, 5},
+ {"three points", []Vec3{{0, 0, 0}, {3, 4, 0}, {3, 4, 12}}, 17},
+ }
+ for _, c := range cases {
+ if got := PolylineLength(c.pts); math.Abs(got-c.want) > 1e-6 {
+ t.Errorf("%s: %v, want %v", c.name, got, c.want)
+ }
+ }
+}
+
+func TestZJumps(t *testing.T) {
+ cases := []struct {
+ name string
+ samples []Vec3
+ want []float64 // the jumps' sizes, in order
+ }{
+ {"flat", []Vec3{{0, 0, 0}, {4, 0, 0}, {8, 0, 0}}, nil},
+ {"slope under the limit", []Vec3{{0, 0, 0}, {4, 0, 7}, {8, 0, 14}}, nil},
+ {"one drop", []Vec3{{0, 0, 0}, {4, 0, 0}, {8, 0, -12}, {12, 0, -12}}, []float64{12}},
+ {"one rise", []Vec3{{0, 0, 0}, {4, 0, 9}, {8, 0, 9}}, []float64{9}},
+ {"exactly at the limit is no jump", []Vec3{{0, 0, 0}, {4, 0, 8}, {8, 0, 8}}, nil},
+ }
+ for _, c := range cases {
+ got := ZJumps(c.samples)
+ if len(got) != len(c.want) {
+ t.Errorf("%s: %d jumps (%v), want %d", c.name, len(got), got, len(c.want))
+ continue
+ }
+ for i := range got {
+ if math.Abs(got[i].DropYd-c.want[i]) > 1e-6 {
+ t.Errorf("%s: jump %d: %v, want %v", c.name, i, got[i].DropYd, c.want[i])
+ }
+ }
+ }
+}
+
+func TestZJumpsAtIsTheLowerSample(t *testing.T) {
+ got := ZJumps([]Vec3{{0, 0, 20}, {4, 0, 4}, {8, 0, 4}})
+ if len(got) != 1 || got[0].At != (Vec3{4, 0, 4}) {
+ t.Errorf("drop: %+v, want the lower sample at At", got)
+ }
+ got = ZJumps([]Vec3{{0, 0, 0}, {4, 0, 12}, {8, 0, 12}})
+ if len(got) != 1 || got[0].At != (Vec3{0, 0, 0}) {
+ t.Errorf("rise: %+v, want the lower sample at At", got)
+ }
+}
+
+func TestOnCorridor(t *testing.T) {
+ cases := []struct {
+ name string
+ near NearPoint
+ radius float64
+ want bool
+ }{
+ {"on the path", NearPoint{Dist3D: 0, Dist2D: 0}, 25, true},
+ {"just inside the radius", NearPoint{Dist3D: 42, Dist2D: 42, DZ: 1}, 25, true},
+ {"too far horizontally", NearPoint{Dist3D: 44, Dist2D: 44, DZ: 0}, 25, false},
+ {"a floor above", NearPoint{Dist3D: 20, Dist2D: 3, DZ: 20}, 25, false},
+ {"no path at all", NearPoint{Dist3D: -1}, 25, false},
+ }
+ for _, c := range cases {
+ if got := OnCorridor(c.near, c.radius); got != c.want {
+ t.Errorf("%s: %v, want %v", c.name, got, c.want)
+ }
+ }
+}
+
+func TestRedundant(t *testing.T) {
+ cases := []struct {
+ name string
+ near NearPoint
+ want bool
+ }{
+ {"on the path", NearPoint{Dist3D: 0}, true},
+ {"just inside", NearPoint{Dist3D: RedundantYd}, true},
+ {"just outside", NearPoint{Dist3D: RedundantYd + 0.1}, false},
+ {"no path", NearPoint{Dist3D: -1}, false},
+ }
+ for _, c := range cases {
+ if got := Redundant(c.near); got != c.want {
+ t.Errorf("%s: %v, want %v", c.name, got, c.want)
+ }
+ }
+}
+
+func TestPathSamples(t *testing.T) {
+ p := navmesh.Path{Points: []Vec3{{0, 0, 0}, {10, 0, 0}}, Ground: [][]Vec3{{{4, 0, 1}, {8, 0, 2}}}}
+ got := PathSamples(p)
+ want := []Vec3{{0, 0, 0}, {4, 0, 1}, {8, 0, 2}, {10, 0, 0}}
+ if len(got) != len(want) {
+ t.Fatalf("got %d samples, want %d", len(got), len(want))
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("sample %d: %v, want %v", i, got[i], want[i])
+ }
+ }
+ if got := PathSamples(navmesh.Path{}); got != nil {
+ t.Errorf("empty path: %v", got)
+ }
+}
diff --git a/internal/routeoracle/oracle.go b/internal/routeoracle/oracle.go
new file mode 100644
index 00000000..9af80c52
--- /dev/null
+++ b/internal/routeoracle/oracle.go
@@ -0,0 +1,822 @@
+package routeoracle
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
+ "github.com/pjerra/turtle-agent/internal/navmesh"
+)
+
+// PlanName is the strict basename a plan's name must take, in the plan file and on the
+// command line (the same regex the CLI's --plan flags hold): the name is also the file stem
+// the report is written under, so a name read from a plan JSON ("../escape") must never
+// reach a path.
+var PlanName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`)
+
+// CheckName says why the plan name is not one a report may be written under.
+func CheckName(name string) error {
+ if !PlanName.MatchString(name) {
+ return fmt.Errorf("plan name %q is not a plan name (a-z, 0-9, _ and -, at most 40 characters)", name)
+ }
+ return nil
+}
+
+// Run is one live planning of a walk: what the navmesh answered for it.
+type Run struct {
+ Status string `json:"status"` // complete, partial, no_path; or error, when the planner failed
+ EndGapYd float64 `json:"end_gap_yd"` // from the path's end to the walk's goal
+ LiveLenYd float64 `json:"live_len_yd"`
+ // End is where the path ends: the walk's goal, or, on a partial path, the nearest point
+ // to it the mesh reaches — where the party is left standing.
+ End dungeon.Point `json:"end,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// Segment is one stored hop of a leg: from the previous stored walk point to this one, with
+// the two navmesh plans of the same hop. A leg's segments are the hops the plan stores, in
+// the order planexport writes the points, so the live route goes through exactly the points
+// the plan does.
+type Segment struct {
+ Seq int `json:"seq"` // the leg's hops, from 1
+ Kind string `json:"kind"` // via, to, approach, pull, hold, stand, boss_approach, boss
+ // Group is the pull group the hop belongs to (the hop into the group's approach points
+ // or its pull centre); -1 for the leg's shared walk. A group's corridor membership is
+ // said against the route without its own hops.
+ Group int `json:"group,omitempty"`
+ From dungeon.Point `json:"from"`
+ At dungeon.Point `json:"at"`
+ StoredLenYd float64 `json:"stored_len_yd"` // the straight hop between the two stored points
+ Plain Run `json:"plain"`
+ Avoid Run `json:"avoid"`
+ // Ratio is the stored hop over the avoid run's live length: the mesh detour the hop takes.
+ Ratio float64 `json:"ratio"`
+ // Note says the hop kind beyond the plain walk: a direct hop walked in a line, a
+ // gm_cross landing a GM moves the party to, or a hop the plan's check_skip exempts.
+ Note string `json:"note,omitempty"`
+ // NonComparable: planned from the point the partial path before it reached, after the
+ // leg's break, so the walk is not the one the plan stores and its numbers score nothing.
+ NonComparable bool `json:"non_comparable,omitempty"`
+}
+
+// StoredPoint is a stored walk point of a leg and how far the leg's live route passes from it.
+type StoredPoint struct {
+ Seq int `json:"seq"` // the point's place among the leg's walk points, from 1
+ Kind string `json:"kind"`
+ At dungeon.Point `json:"at"`
+ NearYd float64 `json:"near_yd"`
+ // Redundant: the route would lose nothing without the point — the navmesh path from the
+ // previous to the next stored point passes within RedundantYd of it. Said only of the
+ // approach and via points (a to, a stand and a boss are the leg's objectives, not hops
+ // the route may skip).
+ Redundant bool `json:"redundant"`
+}
+
+// BreakTouched is a break waypoint of a leg (a via with exact/direct/gm_cross/check_skip)
+// and whether the leg's live route touched it.
+type BreakTouched struct {
+ Via int `json:"via"` // the waypoint's index in the leg's via, from 0
+ Leg *int `json:"leg,omitempty"` // boss-only mode: the stored leg it belongs to (a pointer: leg 0 is written)
+ Kind string `json:"kind"`
+ At dungeon.Point `json:"at"`
+ Touched bool `json:"touched"`
+ Why string `json:"why,omitempty"`
+}
+
+// PullOnPath is a pull group and whether the leg's live route's corridor holds one of its
+// positions.
+type PullOnPath struct {
+ Group int `json:"group"`
+ Leg *int `json:"leg,omitempty"` // boss-only mode: the stored leg the group belongs to (a pointer: leg 0 is written; absent outside a folded row)
+ OnPath bool `json:"on_path"`
+ NearYd float64 `json:"near_yd"` // horizontal distance of the nearest position to the route
+ DZYd float64 `json:"dz_yd"`
+ RadiusYd float64 `json:"radius_yd"`
+ Entries []uint32 `json:"entries"`
+}
+
+// PackPositions is the positions a pull group is judged by: its mob positions, else a hold's
+// spot. A group with neither has nothing to pull or to walk to: it is EMPTY, left out of the
+// legs' walk and out of every pack count (the reports count them).
+func PackPositions(g *dungeon.PullGroup) []dungeon.Point {
+ if len(g.Positions) > 0 {
+ return g.Positions
+ }
+ if g.Hold != nil {
+ return []dungeon.Point{g.Hold.Spot}
+ }
+ return nil
+}
+
+// GroupOnRoute is the one definition of "a pack is on the route" both modes use: one of the
+// group's positions lies within the group's radius plus CorridorSlackYd of the route
+// horizontally and no more than CorridorMaxDZYd above or below it. ok is false for an empty
+// group.
+func GroupOnRoute(gi int, g *dungeon.PullGroup, route []Vec3) (pg PullOnPath, ok bool) {
+ pos := PackPositions(g)
+ if len(pos) == 0 {
+ return PullOnPath{}, false
+ }
+ radius := g.Radius
+ if radius <= 0 {
+ radius = DefaultPackRadius
+ }
+ pg = PullOnPath{Group: gi, RadiusYd: float64(radius), Entries: g.Entries}
+ if len(route) == 0 {
+ return pg, true
+ }
+ for i := range pos {
+ near := NearestPolyline(route, vec(pos[i]))
+ // NearYd is the minimum over ALL positions, whether or not the group is on the path yet.
+ if i == 0 || near.Dist2D < pg.NearYd {
+ pg.NearYd, pg.DZYd = near.Dist2D, near.DZ
+ }
+ if OnCorridor(near, float64(radius)) {
+ pg.OnPath = true
+ }
+ }
+ return pg, true
+}
+
+// emptyGroups counts the plan's pull groups with no position and no hold.
+func emptyGroups(plan *dungeon.Plan) int {
+ n := 0
+ for li := range plan.Legs {
+ for gi := range plan.Legs[li].PullGroups {
+ if len(PackPositions(&plan.Legs[li].PullGroups[gi])) == 0 {
+ n++
+ }
+ }
+ }
+ return n
+}
+
+// LegReport is one leg's comparison of the stored route and the live route.
+type LegReport struct {
+ Leg int `json:"leg"`
+ Name string `json:"name,omitempty"`
+ From dungeon.Point `json:"from"` // where the leg's route starts (the previous leg's objective, or the entrance)
+ To dungeon.Point `json:"to"` // the leg's objective
+ // Objective says what the leg's objective point is: boss, to, step (a leg without a boss
+ // or to: its last step's point) or none (nothing stored for the leg).
+ Objective string `json:"objective"`
+ // Status is the leg's own: complete when every comparable segment plans complete, the
+ // first failing segment's status (partial, no_path) when one does not, or indeterminate
+ // when a leg before it failed and nothing is planned here.
+ Status string `json:"status"`
+ Reason string `json:"reason,omitempty"`
+ // Plain and Avoid sum the comparable segments' runs; the avoid ones are the scratch
+ // planner with SetAvoid of the plan's avoid boxes, whose paths are the live route.
+ Plain Run `json:"plain"`
+ Avoid Run `json:"avoid"`
+ Segments []Segment `json:"segments,omitempty"`
+
+ StoredLenYd float64 `json:"stored_len_yd"` // the comparable segments' stored hops together
+ Ratio float64 `json:"ratio"` // stored over live (the avoid run's)
+ StoredMaxYd float64 `json:"stored_max_yd"` // the stored point farthest from the live route
+ StoredMeanYd float64 `json:"stored_mean_yd"`
+ ZJumps []ZJump `json:"z_jumps,omitempty"`
+ Breaks []BreakTouched `json:"breaks,omitempty"`
+ Pulls []PullOnPath `json:"pulls,omitempty"`
+ Stored []StoredPoint `json:"stored,omitempty"`
+
+ BreaksTouched int `json:"breaks_touched"`
+ PacksOnPath int `json:"packs_on_path"`
+ PlanMs float64 `json:"plan_ms"` // the leg's plans together
+}
+
+// TileFile is one mmap or mmtile file the report's planning used.
+type TileFile struct {
+ Name string `json:"name"`
+ Sha256 string `json:"sha256"`
+ Bytes int64 `json:"bytes"`
+}
+
+// Report is one plan's oracle report.
+type Report struct {
+ Plan string `json:"plan"`
+ Map uint32 `json:"map"`
+ Status string `json:"status,omitempty"` // the plan's own status line
+ Avoid int `json:"avoid_boxes"`
+ Legs []LegReport `json:"legs"`
+ // EmptyGroups: pull groups with no position and no hold, skipped everywhere (not walked to,
+ // not in a pack count).
+ EmptyGroups int `json:"empty_groups,omitempty"`
+ PlanMs float64 `json:"plan_ms"` // the legs' plans together
+ Tiles []TileFile `json:"tiles"`
+}
+
+// Summary is the plan's one summary line. The aggregates are over the COMPLETE legs only: a
+// leg with a partial or no-path hop is failed (its route is not the one the plan stores, and
+// the point a partial path ends at would poison every number behind it), and the legs after a
+// failed one are indeterminate. The mean ratio, the packs and the counts are theirs alone.
+func (r Report) Summary() string {
+ var complete, failed, indet, other int
+ var ratio, on, packs float64
+ for i := range r.Legs {
+ switch r.Legs[i].Status {
+ case "complete":
+ complete++
+ case "indeterminate":
+ indet++
+ case string(navmesh.Partial), string(navmesh.NoPath), "no_objective":
+ failed++
+ default:
+ other++
+ }
+ if r.Legs[i].Status == "complete" {
+ ratio += r.Legs[i].Ratio
+ on += float64(r.Legs[i].PacksOnPath)
+ packs += float64(len(r.Legs[i].Pulls))
+ }
+ }
+ n := len(r.Legs)
+ if n == 0 {
+ return fmt.Sprintf("%s: map %d, no legs", r.Plan, r.Map)
+ }
+ var packsOn string
+ if packs > 0 {
+ packsOn = fmt.Sprintf(", packs on path %d/%d (%.0f%%)", int(on), int(packs), 100*on/packs)
+ }
+ mean := 0.0
+ if complete > 0 {
+ mean = ratio / float64(complete)
+ }
+ empty := ""
+ if r.EmptyGroups > 0 {
+ empty = fmt.Sprintf("; %d empty pull groups skipped", r.EmptyGroups)
+ }
+ return fmt.Sprintf("%s: map %d, %d legs: %d complete, %d failed, %d indeterminate; mean ratio %.2f%s%s; plan %.0f ms",
+ r.Plan, r.Map, n, complete, failed, indet, mean, packsOn, empty, r.PlanMs)
+}
+
+// Oracle builds the plan's report. The walk it plans is the driver's own, leg by leg: the
+// leg's stored points in the order planexport writes them (the via points, the To, each pull
+// group's approach points and its pull point — the positions' centroid, or a hold's spot —,
+// the steps' stand points, the boss's approach points and position, the after-boss stands),
+// from where the previous leg left the party. Every hop of that walk is planned twice: plain,
+// and on a scratch planner with the plan's avoid boxes, whose paths are the live route the
+// metrics use, because it is the one a run plans with.
+//
+// A hop that does not plan complete fails the leg: the leg keeps the failing hop's status
+// (partial, no_path), its remaining hops are planned on from the point the failed path
+// reached and marked non-comparable, and the legs after it are indeterminate — the failed
+// leg's and their numbers score nothing (they are still in the report, for the reading).
+//
+// A planner error (no folder, no engine, a broken tile) is returned, not reported.
+func Oracle(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan) (Report, error) {
+ if err := CheckName(plan.Name); err != nil {
+ return Report{}, fmt.Errorf("plan file: %w", err)
+ }
+ rep := Report{Plan: plan.Name, Map: plan.Map, Status: plan.Status, Avoid: len(plan.Avoid), EmptyGroups: emptyGroups(plan)}
+ scratch := p.Scratch()
+ defer scratch.Close()
+ scratch.SetAvoid(plan.Map, dungeon.NavBoxes(plan.Avoid))
+
+ cur := vec(plan.Entrance) // where the driver actually stands (a partial path ends short of its goal)
+ broken := "" // why nothing after the failed leg is comparable
+ for i := range plan.Legs {
+ l := &plan.Legs[i]
+ lr := LegReport{Leg: i, Name: l.Name, From: point(cur)}
+ obj, kind := legObjective(l)
+ lr.To = obj
+ lr.Objective = kind
+
+ switch {
+ case broken != "":
+ lr.Status, lr.Reason = "indeterminate", broken
+ rep.Legs = append(rep.Legs, lr)
+ continue
+ case kind == "none":
+ lr.Status = "no_objective"
+ rep.Legs = append(rep.Legs, lr)
+ continue
+ }
+
+ walk := legWalk(l)
+ lr.Stored = make([]StoredPoint, len(walk))
+ for j, w := range walk {
+ lr.Stored[j] = StoredPoint{Seq: j + 1, Kind: w.kind, At: point(w.at)}
+ }
+ paths := make([]navmesh.Path, len(walk))
+ failing := -1
+ var failingEnd Vec3
+ for j, w := range walk {
+ seg := Segment{Seq: j + 1, Kind: w.kind, Group: w.group, From: point(cur), At: point(w.at), StoredLenYd: dist3(cur, w.at)}
+ seg.NonComparable = failing >= 0
+ t1 := time.Now()
+ plain, _, note, err := hop(plan.Map, p, cur, w)
+ if err != nil {
+ return rep, fmt.Errorf("plan %s leg %d (%s) hop %d: %s plan: %w", plan.Name, i, l.Name, j+1, note, err)
+ }
+ aware, anext, note, err := hop(plan.Map, scratch, cur, w)
+ if err != nil {
+ return rep, fmt.Errorf("plan %s leg %d (%s) hop %d: %s plan: %w", plan.Name, i, l.Name, j+1, note, err)
+ }
+ seg.Plain, seg.Avoid, seg.Note = runOf(plain, w.at), runOf(aware, w.at), note
+ if seg.Avoid.LiveLenYd > 0 {
+ seg.Ratio = seg.StoredLenYd / seg.Avoid.LiveLenYd
+ }
+ lr.PlanMs += float64(time.Since(t1).Microseconds()) / 1000
+ cur = anext
+ paths[j] = aware
+ lr.Segments = append(lr.Segments, seg)
+ if failing < 0 && seg.Avoid.Status != string(navmesh.Complete) && seg.Avoid.Status != hopSkipped {
+ failing = j
+ failingEnd = anext
+ }
+ }
+ sumLeg(&lr, l, failing, paths, failingEnd)
+ if failing >= 0 {
+ f := &lr.Segments[failing]
+ broken = fmt.Sprintf("leg %d (%s) failed: hop %d (%s) is %s: %s", i, l.Name, f.Seq, f.Kind,
+ f.Avoid.Status, firstLine(f.Avoid.Reason))
+ }
+ if err := markRedundant(plan.Map, scratch, walk, &lr); err != nil {
+ return rep, fmt.Errorf("plan %s leg %d (%s): %w", plan.Name, i, l.Name, err)
+ }
+ for i := range lr.Breaks {
+ if lr.Breaks[i].Touched {
+ lr.BreaksTouched++
+ }
+ }
+ for i := range lr.Pulls {
+ if lr.Pulls[i].OnPath {
+ lr.PacksOnPath++
+ }
+ }
+ rep.PlanMs += lr.PlanMs
+ rep.Legs = append(rep.Legs, lr)
+ }
+ t, err := tiles(p, scratch, mmapsDir, plan)
+ rep.Tiles = t
+ if err != nil {
+ return rep, err
+ }
+ return rep, nil
+}
+
+// sumLeg sums the comparable segments into the leg's own run, measures the stored points,
+// the breaks, the pull groups and the Z jumps against the leg's live route, and says the
+// leg's status. failingEnd is the point the failing hop's path reached (the party's spot on
+// a partial path).
+func sumLeg(lr *LegReport, l *dungeon.Leg, failing int, paths []navmesh.Path, failingEnd Vec3) {
+ var stored, plain, live, lastGap float64
+ var route, samples []Vec3
+ for i := range lr.Segments {
+ seg := &lr.Segments[i]
+ samples = append(samples, PathSamples(paths[i])...)
+ if seg.NonComparable {
+ continue
+ }
+ route = append(route, paths[i].Points...)
+ live += seg.Avoid.LiveLenYd
+ plain += seg.Plain.LiveLenYd
+ stored += seg.StoredLenYd
+ lastGap = seg.Avoid.EndGapYd
+ }
+ failingPartial := failing >= 0 && paths[failing].Status == navmesh.Partial
+ switch {
+ case failing >= 0:
+ f := &lr.Segments[failing]
+ lr.Status = f.Avoid.Status
+ lr.Reason = fmt.Sprintf("hop %d (%s) is %s: %s", f.Seq, f.Kind, f.Avoid.Status, firstLine(f.Avoid.Reason))
+ lr.Avoid = f.Avoid
+ lr.Plain = f.Plain
+ default:
+ lr.Status = "complete"
+ lr.Avoid = Run{Status: "complete", EndGapYd: lastGap, LiveLenYd: live}
+ lr.Plain = Run{Status: "complete", EndGapYd: lastGap, LiveLenYd: plain}
+ }
+ lr.StoredLenYd = stored
+ if live > 0 {
+ lr.Ratio = stored / live
+ }
+ if len(route) > 0 {
+ var sum, most float64
+ for i := range lr.Stored {
+ near := NearestPolyline(route, vec(lr.Stored[i].At))
+ lr.Stored[i].NearYd = near.Dist3D
+ sum += near.Dist3D
+ if near.Dist3D > most {
+ most = near.Dist3D
+ }
+ }
+ lr.StoredMeanYd = sum / float64(len(lr.Stored))
+ lr.StoredMaxYd = most
+ }
+ lr.ZJumps = ZJumps(samples)
+ lr.Breaks = breaksOf(l, route, failingEnd, failingPartial)
+ lr.Pulls = pullGroupsOf(l, paths, lr.Segments)
+}
+
+// hop plans one hop of the leg's walk the way the dungeon driver takes it (plancheck's
+// walks): a plain waypoint on the navmesh; a direct waypoint as a straight line within the
+// module's limits, with ground under its end and a clear line over the mesh; a gm_cross
+// waypoint as a landing the party needs mesh floor under, with no walk looked for (a GM
+// moves the party there); and a hop the plan's check_skip exempts is not walked on the mesh
+// at all. next is where the party stands afterwards: the path's end for a plain walk (its
+// start again when no path plans), the landing point for the three break kinds.
+func hop(mapID uint32, pl *navmesh.Planner, cur Vec3, spec storedSpec) (path navmesh.Path, next Vec3, note string, err error) {
+ next = cur
+ // A pull group's centre and a boss are looked at, the way plancheck's look is tolerant:
+ // the party stands at the pull's standoff when a complete path plans, or when the
+ // planned path's end comes within the pull's 25 yd view of the anchor — a pack on a
+ // ledge or under a bridge ends the path short of it and is pulled fine live.
+ switch spec.kind {
+ case "pull", "boss":
+ path, err = pl.Plan(mapID, cur, spec.at)
+ if err != nil {
+ return navmesh.Path{}, next, "look", err
+ }
+ end := pathEndOr(path, cur)
+ if path.Status != navmesh.Complete && dist3(end, spec.at) > dungeon.ClearSeeRadius {
+ return path, end, "look", nil
+ }
+ if pts := pathPoints(path); len(pts) >= 2 {
+ stand, moved := dungeon.LookPoint(pts, point(spec.at), lookStandoffYd, lookStandoffYd,
+ func(p dungeon.Point) dungeon.Point {
+ if n, ok, _ := pl.Nearest(mapID, vec(p)); ok {
+ return point(n)
+ }
+ return p
+ })
+ if moved {
+ next = vec(stand)
+ }
+ }
+ path.Status = navmesh.Complete
+ return path, next, "look", nil
+ }
+ if spec.via == nil {
+ path, err = pl.Plan(mapID, cur, spec.at)
+ if n := len(path.Points); n > 0 {
+ next = path.Points[n-1]
+ }
+ return path, next, "", err
+ }
+ w := spec.via
+ switch {
+ case w.GMCross:
+ n, ok, err := pl.Nearest(mapID, spec.at)
+ if err != nil {
+ return navmesh.Path{}, next, "gm_cross", err
+ }
+ if !ok || dist3(n, spec.at) > hopFloorYd {
+ return navmesh.Path{Status: navmesh.NoPath, Reason: fmt.Sprintf(
+ "the gm_cross landing %s is off the navmesh (no walkable polygon within %.1f yd)",
+ fmtPos(spec.at), hopFloorYd)}, spec.at, "gm_cross", nil
+ }
+ return navmesh.Path{Status: navmesh.Complete}, spec.at, "gm_cross (a GM moves the party here; no walk)", nil
+ case w.CheckSkip != "":
+ return navmesh.Path{Status: hopSkipped}, spec.at, "skipped (the plan's check_skip: the mesh cannot judge the hop on purpose)", nil
+ case w.Direct:
+ if !dungeon.DirectStepOK(point(cur), point(spec.at)) {
+ return navmesh.Path{Status: navmesh.NoPath, Reason: fmt.Sprintf(
+ "%.1f yd across and %.1f up or down: the direct limits are %g and %g",
+ dist2(cur, spec.at), math.Abs(float64(spec.at[2]-cur[2])), dungeon.DirectMaxDistance, dungeon.DirectMaxVertical)},
+ spec.at, "direct", nil
+ }
+ n, ok, err := pl.Nearest(mapID, spec.at)
+ if err != nil {
+ return navmesh.Path{}, spec.at, "direct", err
+ }
+ if !ok || dist3(n, spec.at) > dungeon.DirectGroundTolerance {
+ return navmesh.Path{Status: navmesh.NoPath, Reason: fmt.Sprintf(
+ "no ground within %g yd under the landing %s", dungeon.DirectGroundTolerance, fmtPos(spec.at))},
+ spec.at, "direct", nil
+ }
+ ray, err := pl.Raycast(mapID, cur, spec.at)
+ if err != nil {
+ return navmesh.Path{}, spec.at, "direct", err
+ }
+ if !ray.Known || float64(ray.Hit) < dist2(cur, spec.at)-checkRayEndYd {
+ return navmesh.Path{Status: navmesh.NoPath, Reason: fmt.Sprintf(
+ "the line from %s leaves the mesh after %.1f of %.1f yd", fmtPos(cur), ray.Hit, dist2(cur, spec.at))},
+ spec.at, "direct", nil
+ }
+ return navmesh.Path{Status: navmesh.Complete, Points: []Vec3{cur, spec.at}, Length: float32(dist3(cur, spec.at))},
+ spec.at, "direct (walked in a line)", nil
+ }
+ // A plain via: walked like the leg's other points.
+ path, err = pl.Plan(mapID, cur, spec.at)
+ if n := len(path.Points); n > 0 {
+ next = path.Points[n-1]
+ }
+ return path, next, "", err
+}
+
+// The hop check's own limits, in yards: the plan check's walk and ray tolerances
+// (plancheck.go's checkWalkDZ, checkWalkDH and checkRayEnd), and the driver's pull standoff
+// (driver.go's look_standoff default).
+const (
+ hopFloorYd = 2.5
+ checkRayEndYd = 1.5
+ lookStandoffYd = 21.0
+)
+
+// hopSkipped is a hop the oracle does not measure: the plan exempts it (check_skip) or a GM
+// moves the party past it.
+const hopSkipped = "skipped"
+
+// pathPoints is a planned path's corners as the plan format's points.
+func pathPoints(p navmesh.Path) []dungeon.Point {
+ out := make([]dungeon.Point, len(p.Points))
+ for i, v := range p.Points {
+ out[i] = point(v)
+ }
+ return out
+}
+
+// pathEndOr is where a planned path ends, or the fallback when it holds no corners.
+func pathEndOr(p navmesh.Path, fallback Vec3) Vec3 {
+ if n := len(p.Points); n > 0 {
+ return p.Points[n-1]
+ }
+ return fallback
+}
+
+// fmtPos is a position for a message.
+func fmtPos(v Vec3) string { return fmt.Sprintf("%.1f %.1f %.1f", v[0], v[1], v[2]) }
+
+func firstLine(s string) string {
+ s, _, _ = strings.Cut(s, "\n")
+ return s
+}
+
+// runOf turns a planned path into a Run.
+func runOf(p navmesh.Path, goal Vec3) Run {
+ r := Run{Status: string(p.Status), LiveLenYd: float64(p.Length), Reason: p.Reason}
+ if n := len(p.Points); n > 0 {
+ r.End = point(p.Points[n-1])
+ r.EndGapYd = dist3(p.Points[n-1], goal)
+ }
+ return r
+}
+
+// breaksOf lists the leg's break waypoints (a via with exact/direct/gm_cross/check_skip) and
+// whether the leg's live route touched them: within BreakNearYd of the foot, or, when the
+// failing hop is partial, with the path's end within BreakGapYd of the foot.
+func breaksOf(l *dungeon.Leg, route []Vec3, failingEnd Vec3, failingPartial bool) []BreakTouched {
+ var out []BreakTouched
+ for vi := range l.Via {
+ w := l.Via[vi]
+ kind := breakKind(w)
+ if kind == "" {
+ continue
+ }
+ b := BreakTouched{Via: vi, Kind: kind, At: w.Point}
+ if len(route) > 0 {
+ near := NearestPolyline(route, vec(w.Point))
+ if near.Dist3D <= BreakNearYd {
+ b.Touched = true
+ b.Why = fmt.Sprintf("the route passes %.1f yd from it", near.Dist3D)
+ }
+ }
+ if !b.Touched && failingPartial && len(route) > 0 {
+ if d := dist3(failingEnd, vec(w.Point)); d <= BreakGapYd {
+ b.Touched = true
+ b.Why = fmt.Sprintf("the partial path ends %.1f yd from it", d)
+ }
+ }
+ out = append(out, b)
+ }
+ return out
+}
+
+// breakKind is the waypoint's break flags, comma joined; "" when it is none.
+func breakKind(w dungeon.Waypoint) string {
+ var parts []string
+ if w.Exact {
+ parts = append(parts, "exact")
+ }
+ if w.Direct {
+ parts = append(parts, "direct")
+ }
+ if w.GMCross {
+ parts = append(parts, "gm_cross")
+ }
+ if w.CheckSkip != "" {
+ parts = append(parts, "check_skip")
+ }
+ return strings.Join(parts, ",")
+}
+
+// pullGroupsOf is the leg's pull groups and whether the leg's live route passes one of the
+// group's positions: within the group's radius plus CorridorSlackYd of the route
+// horizontally, no more than CorridorMaxDZYd above or below the route at that point. The
+// route measured is the one the driver walks anyway — every hop but the group's own (its
+// approach hops and the hop into its pull centre) — so a group the party would pass without
+// the stored waypoints counts as on the route.
+func pullGroupsOf(l *dungeon.Leg, paths []navmesh.Path, segs []Segment) []PullOnPath {
+ var out []PullOnPath
+ for gi := range l.PullGroups {
+ g := &l.PullGroups[gi]
+ var route []Vec3
+ for i := range segs {
+ if segs[i].Group != gi && len(paths[i].Points) > 0 {
+ route = append(route, paths[i].Points...)
+ }
+ }
+ pg, ok := GroupOnRoute(gi, g, route)
+ if !ok {
+ continue // an empty group: not in the pack counts
+ }
+ out = append(out, pg)
+ }
+ return out
+}
+
+// markRedundant says, for each stored approach or via point of a complete leg, whether the
+// route would lose nothing without it: the navmesh path from the previous to the next stored
+// point (the avoid-aware one) passes within RedundantYd of the point. The leg's first and
+// last walk points have no such pair of neighbours and are never redundant.
+func markRedundant(mapID uint32, sc *navmesh.Planner, walk []storedSpec, lr *LegReport) error {
+ for i := range lr.Stored {
+ s := &lr.Stored[i]
+ if !isApproachVia(s.Kind) || i == 0 || i == len(walk)-1 {
+ continue
+ }
+ skip, err := sc.Plan(mapID, walk[i-1].at, walk[i+1].at)
+ if err != nil {
+ return fmt.Errorf("hop %d redundancy plan: %w", i+1, err)
+ }
+ if skip.Status == navmesh.NoPath || len(skip.Points) == 0 {
+ continue
+ }
+ s.Redundant = Redundant(NearestPolyline(skip.Points, walk[i].at))
+ }
+ return nil
+}
+
+// isApproachVia says whether the stored point is an approach or via point (the redundancy
+// check's kind; a boss's approach is one too).
+func isApproachVia(kind string) bool {
+ return kind == "approach" || kind == "via" || kind == "boss_approach"
+}
+
+// legObjective is the leg's objective chain point: the boss's position, else the leg's To,
+// else the last step's point, else nothing.
+func legObjective(l *dungeon.Leg) (dungeon.Point, string) {
+ if l.Boss != nil {
+ return l.Boss.Position, "boss"
+ }
+ if l.To != (dungeon.Point{}) {
+ return l.To, "to"
+ }
+ if p, ok := lastStepPoint(l.Steps); ok {
+ return p, "step"
+ }
+ if p, ok := lastStepPoint(l.AfterBoss); ok {
+ return p, "step"
+ }
+ return dungeon.Point{}, "none"
+}
+
+// lastStepPoint is the last step of the list that holds a stand point.
+func lastStepPoint(steps []dungeon.Step) (dungeon.Point, bool) {
+ for i := len(steps) - 1; i >= 0; i-- {
+ if p, ok := stepPoint(steps[i]); ok {
+ return p, true
+ }
+ }
+ return dungeon.Point{}, false
+}
+
+// stepPoint is a step's stand point: where the step wants the party standing (an object's or
+// trigger's stand, else the point the object stands at).
+func stepPoint(st dungeon.Step) (dungeon.Point, bool) {
+ switch {
+ case st.UseObject != nil:
+ return standOrNear(st.UseObject.Stand, st.UseObject.Near)
+ case st.LootObject != nil:
+ return standOrNear(st.LootObject.Stand, st.LootObject.Near)
+ case st.Ritual != nil:
+ return standOrNear(st.Ritual.Stand, &st.Ritual.Position)
+ case st.Gossip != nil:
+ return standOrNear(st.Gossip.Stand, &st.Gossip.Near)
+ case st.AreaTrigger != nil:
+ return st.AreaTrigger.Stand, true
+ }
+ return dungeon.Point{}, false
+}
+
+func standOrNear(stand, near *dungeon.Point) (dungeon.Point, bool) {
+ if stand != nil {
+ return *stand, true
+ }
+ if near != nil {
+ return *near, true
+ }
+ return dungeon.Point{}, false
+}
+
+// storedSpec is one stored point of the leg's walk; group is the pull group it belongs to
+// (its approach points and pull point), -1 for the shared walk (via, to, stands, boss). via
+// holds the waypoint a via point was stored from, with its direct/gm_cross/check_skip flags.
+type storedSpec struct {
+ at Vec3
+ kind string
+ group int
+ via *dungeon.Waypoint
+}
+
+// legWalk is the leg's stored walk: its own points in the order planexport writes them — the
+// via waypoints, the To, each pull group's approach points and its pull point (the
+// positions' centroid, or a hold's spot), the steps' stand points, the boss's approach
+// points and position, the after-boss stands. The first point is reached from where the
+// previous leg left the party; a leg without any of these has no walk.
+func legWalk(l *dungeon.Leg) []storedSpec {
+ var out []storedSpec
+ add := func(p dungeon.Point, kind string, group int) *storedSpec {
+ out = append(out, storedSpec{at: vec(p), kind: kind, group: group})
+ return &out[len(out)-1]
+ }
+ for i := range l.Via {
+ add(l.Via[i].Point, "via", -1).via = &l.Via[i]
+ }
+ if l.To != (dungeon.Point{}) {
+ add(l.To, "to", -1)
+ }
+ for gi := range l.PullGroups {
+ g := &l.PullGroups[gi]
+ if len(PackPositions(g)) == 0 {
+ continue // an empty group has nothing to pull: no walk to it (the centroid of nothing is NaN)
+ }
+ for i := range g.Approach {
+ add(g.Approach[i], "approach", gi)
+ }
+ if g.Hold != nil {
+ add(g.Hold.Spot, "hold", gi)
+ continue
+ }
+ add(centroid(g.Positions), "pull", gi)
+ }
+ for i := range l.Steps {
+ if p, ok := stepPoint(l.Steps[i]); ok {
+ add(p, "stand", -1)
+ }
+ }
+ if l.Boss != nil {
+ for i := range l.Boss.Approach {
+ add(l.Boss.Approach[i], "boss_approach", -1)
+ }
+ add(l.Boss.Position, "boss", -1)
+ }
+ for i := range l.AfterBoss {
+ if p, ok := stepPoint(l.AfterBoss[i]); ok {
+ add(p, "stand", -1)
+ }
+ }
+ return out
+}
+
+// centroid is the mean of the points, as planexport writes a pull row.
+func centroid(pts []dungeon.Point) dungeon.Point {
+ var cx, cy, cz float64
+ for i := range pts {
+ cx, cy, cz = cx+float64(pts[i].X), cy+float64(pts[i].Y), cz+float64(pts[i].Z)
+ }
+ n := float64(len(pts))
+ return dungeon.Point{X: float32(cx / n), Y: float32(cy / n), Z: float32(cz / n)}
+}
+
+func vec(p dungeon.Point) Vec3 { return Vec3{p.X, p.Y, p.Z} }
+func point(v Vec3) dungeon.Point { return dungeon.Point{X: v[0], Y: v[1], Z: v[2]} }
+
+// tiles is the mmap and mmtile files the report's planning used, with their SHA-256 and
+// sizes: the two planners' loaded tiles together and the map's own .mmap. A file that cannot
+// be read is an error: the report would name files it does not have.
+func tiles(p, scratch *navmesh.Planner, dir string, plan *dungeon.Plan) ([]TileFile, error) {
+ used := map[string]bool{}
+ for _, n := range p.Tiles(plan.Map) {
+ used[n] = true
+ }
+ for _, n := range scratch.Tiles(plan.Map) {
+ used[n] = true
+ }
+ used[fmt.Sprintf("%03d.mmap", plan.Map)] = true
+ var out []TileFile
+ var first error
+ for n := range used {
+ b, err := os.ReadFile(filepath.Join(dir, n))
+ if err != nil {
+ if first == nil {
+ first = fmt.Errorf("tile %s: %w", n, err)
+ }
+ continue
+ }
+ sum := sha256.Sum256(b)
+ out = append(out, TileFile{Name: n, Sha256: hex.EncodeToString(sum[:]), Bytes: int64(len(b))})
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return out, first
+}
diff --git a/internal/routeoracle/oracle_cgo_test.go b/internal/routeoracle/oracle_cgo_test.go
new file mode 100644
index 00000000..e0f4f9b9
--- /dev/null
+++ b/internal/routeoracle/oracle_cgo_test.go
@@ -0,0 +1,286 @@
+//go:build cgo
+
+package routeoracle
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
+ "github.com/pjerra/turtle-agent/internal/navmesh"
+ "github.com/pjerra/turtle-agent/internal/navmesh/navfixture"
+)
+
+// The fixtures are synthetic meshes (navfixture's L corridor: a row along x from 10 to 50 at
+// y 10..14, then a column up y to 50 at x 46..50) plus, where a test needs one, a detached
+// island of cells at (100..120). No real mmaps are needed.
+const walkPlan = `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"hall","to":{"x":46,"y":12,"z":0},
+ "via":[{"x":22,"y":12,"z":0},{"x":30,"y":12,"z":0,"direct":true,"check_skip":"fixture"}],
+ "pull_groups":[{"entries":[100],"positions":[{"x":44,"y":12,"z":0}]}]},
+ {"name":"boss","to":{"x":48,"y":30,"z":0},
+ "pull_groups":[{"entries":[103],"approach":[{"x":48,"y":20,"z":0}],"positions":[{"x":48,"y":40,"z":0}]}],
+ "boss":{"entry":200,"position":{"x":48,"y":48,"z":0}}},
+ {"name":"voidpull","to":{"x":48,"y":48,"z":0},
+ "pull_groups":[{"entries":[102],"positions":[{"x":200,"y":200,"z":0}]}]},
+ {"name":"after","to":{"x":46,"y":12,"z":0},
+ "pull_groups":[{"entries":[105],"positions":[{"x":46,"y":12,"z":0}]}]}]}`
+
+// A second plan whose third leg walks to a detached island: the hop fails PARTIAL, and the
+// hop after it is planned on from the point the partial path reached.
+const islandPlan = `{"version":1,"name":"island","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[
+ {"name":"hall","to":{"x":46,"y":12,"z":0},
+ "pull_groups":[{"entries":[100],"positions":[{"x":44,"y":12,"z":0}]}]},
+ {"name":"island","to":{"x":102,"y":102,"z":0},
+ "via":[{"x":48,"y":20,"z":0}],
+ "pull_groups":[{"entries":[104],"positions":[{"x":110,"y":110,"z":0}]}]},
+ {"name":"after","to":{"x":46,"y":12,"z":0},
+ "pull_groups":[{"entries":[105],"positions":[{"x":46,"y":12,"z":0}]}]}]}`
+
+func oracleFixture(t *testing.T, plan string, extra ...navfixture.Poly) (*navmesh.Planner, string, *dungeon.Plan) {
+ t.Helper()
+ polys := navfixture.LCorridor(0)
+ polys = append(polys, extra...)
+ dir := navfixture.WriteDir(t, 0, polys)
+ p := navmesh.NewPlanner(dir)
+ t.Cleanup(p.Close)
+ name := "corridor.json"
+ if strings.Contains(plan, `"name":"island"`) {
+ name = "island.json"
+ }
+ dp, err := dungeon.Parse(name, []byte(plan))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ return p, dir, dp
+}
+
+// The walk the oracle plans is the driver's own, hop by hop: the boss leg holds its To, the
+// pull group's approach point and pull centre, and the boss, and every hop of it plans
+// complete, so the leg's stored length and its live length agree (ratio ~1).
+func TestOraclePlansTheDriversWalk(t *testing.T) {
+ p, dir, plan := oracleFixture(t, walkPlan)
+ rep, err := Oracle(p, dir, plan)
+ if err != nil {
+ t.Fatalf("Oracle: %v", err)
+ }
+ if len(rep.Legs) != 4 {
+ t.Fatalf("legs: %d", len(rep.Legs))
+ }
+ // Leg 0: the hall with its via points; hop 2's via is direct with the plan's own
+ // check_skip, so the oracle does not walk it on the mesh (skipped) and the hop after it
+ // plans on from the skipped point.
+ lr := rep.Legs[0]
+ if lr.Status != "complete" || lr.Objective != "to" {
+ t.Errorf("leg 0: status %s objective %s", lr.Status, lr.Objective)
+ }
+ if len(lr.Segments) != 4 {
+ t.Fatalf("leg 0 hops: %d", len(lr.Segments))
+ }
+ wantStatus := []string{"complete", "skipped", "complete", "complete"}
+ for i := range lr.Segments {
+ s := lr.Segments[i]
+ if s.NonComparable || s.Avoid.Status != wantStatus[i] {
+ t.Errorf("leg 0 hop %d: status %s comparable %v, want %s", i+1, s.Avoid.Status, !s.NonComparable, wantStatus[i])
+ }
+ }
+ // The skipped hop is the leg's break waypoint: listed (not walked on the mesh), and
+ // touched — the route brings the party onto its foot, from where the direct step
+ // launches.
+ if len(lr.Breaks) != 1 || lr.Breaks[0].Kind != "direct,check_skip" || !lr.Breaks[0].Touched {
+ t.Errorf("leg 0 breaks: %+v", lr.Breaks)
+ }
+ // Leg 1: the boss leg with To and pulls — the case the review named. Its hops are the
+ // driver's sequence (to, approach, pull, boss), all complete, so the stored hops and the
+ // live route agree; a report that planned the boss directly instead would leave the
+ // stored chain (which holds the to, the approach and the pull) far longer than the live
+ // walk and fail this bound.
+ br := rep.Legs[1]
+ if br.Status != "complete" || br.Objective != "boss" {
+ t.Errorf("leg 1: status %s objective %s", br.Status, br.Objective)
+ }
+ kinds := []string{}
+ for i := range br.Segments {
+ kinds = append(kinds, br.Segments[i].Kind)
+ if s := br.Segments[i]; s.NonComparable || s.Avoid.Status != "complete" {
+ t.Errorf("leg 1 hop %d: %+v", i+1, s)
+ }
+ }
+ if strings.Join(kinds, ",") != "to,approach,pull,boss" {
+ t.Errorf("leg 1 hop kinds %v", kinds)
+ }
+ if br.Ratio < 0.9 || br.Ratio > 1.15 {
+ t.Errorf("leg 1 ratio %v, want ~1 (the live route goes through the stored points)", br.Ratio)
+ }
+ if br.StoredMeanYd > 1 {
+ t.Errorf("leg 1 stored mean %.1f yd, want ~0 (the route visits every stored point)", br.StoredMeanYd)
+ }
+ // Leg 2's single hop to the void pull centre (200, 200) finds no mesh there: no_path,
+ // and it fails the leg.
+ vp := rep.Legs[2]
+ if vp.Status != string(navmesh.NoPath) {
+ t.Errorf("leg 2 status %s, want no_path", vp.Status)
+ }
+ if len(vp.Segments) != 2 || vp.Segments[1].Kind != "pull" || vp.Segments[1].Avoid.Status != string(navmesh.NoPath) || vp.Segments[1].NonComparable {
+ t.Errorf("leg 2 hops: %+v", vp.Segments)
+ }
+ // Leg 3 comes after a failed leg: indeterminate, nothing planned for it — the numbers a
+ // report that advanced prev anyway would print are not here.
+ af := rep.Legs[3]
+ if af.Status != "indeterminate" || len(af.Segments) != 0 || af.Avoid.LiveLenYd != 0 {
+ t.Errorf("leg 3: %+v", af)
+ }
+ if !strings.Contains(af.Reason, "leg 2 (voidpull) failed") {
+ t.Errorf("leg 3 reason %q", af.Reason)
+ }
+ // The aggregates are the complete legs' only: mean ratio and the pack counts of legs 0-1.
+ s := rep.Summary()
+ if !strings.Contains(s, "2 complete, 1 failed, 1 indeterminate") {
+ t.Errorf("summary %q", s)
+ }
+ // The tile file is named and hashed.
+ if len(rep.Tiles) != 2 || rep.Tiles[0].Name != "000.mmap" || len(rep.Tiles[0].Sha256) != 64 {
+ t.Errorf("tiles: %+v", rep.Tiles)
+ }
+}
+
+// A partial hop fails its leg; the hop after it is planned on from the point the partial
+// path reached and marked non-comparable, and the legs after the leg are indeterminate.
+func TestOracleContinuesPastAPartialHop(t *testing.T) {
+ p, dir, plan := oracleFixture(t, islandPlan, navfixture.Square(100, 100, 120, 120, 0))
+ rep, err := Oracle(p, dir, plan)
+ if err != nil {
+ t.Fatalf("Oracle: %v", err)
+ }
+ if len(rep.Legs) != 3 {
+ t.Fatalf("legs: %d", len(rep.Legs))
+ }
+ if rep.Legs[0].Status != "complete" {
+ t.Errorf("leg 0 status %s", rep.Legs[0].Status)
+ }
+ il := rep.Legs[1]
+ if il.Status != string(navmesh.Partial) {
+ t.Fatalf("leg 1 status %s, want partial", il.Status)
+ }
+ if len(il.Segments) != 3 {
+ t.Fatalf("leg 1 hops: %d", len(il.Segments))
+ }
+ if il.Segments[0].NonComparable || il.Segments[0].Avoid.Status != "complete" {
+ t.Errorf("leg 1 hop 1: %+v", il.Segments[0])
+ }
+ f := il.Segments[1] // the failing hop: to the island, partial
+ if f.NonComparable || f.Kind != "to" || f.Avoid.Status != string(navmesh.Partial) {
+ t.Errorf("leg 1 hop 2: %+v", f)
+ }
+ c := il.Segments[2] // planned on from the partial path's end
+ if !c.NonComparable || c.From != il.Segments[1].Avoid.End {
+ t.Errorf("leg 1 hop 3: %+v (want it planned on from hop 2's path end %v)", c, il.Segments[1].Avoid.End)
+ }
+ if !strings.Contains(il.Reason, "hop 2 (to) is partial") {
+ t.Errorf("leg 1 reason %q", il.Reason)
+ }
+ af := rep.Legs[2]
+ if af.Status != "indeterminate" || !strings.Contains(af.Reason, "leg 1 (island) failed") {
+ t.Errorf("leg 2: %+v", af)
+ }
+ // The failed leg scores nothing: the plan's mean ratio is leg 0's alone.
+ if !strings.Contains(rep.Summary(), "1 complete, 1 failed, 1 indeterminate") {
+ t.Errorf("summary %q", rep.Summary())
+ }
+}
+
+// The corridor membership and the break proximity of points against a really planned route.
+func TestCorridorAndBreaksAgainstAPlannedRoute(t *testing.T) {
+ p, _, _ := oracleFixture(t, walkPlan)
+ aware, err := p.Plan(0, Vec3{12, 12, 0}, Vec3{48, 48, 0})
+ if err != nil {
+ t.Fatalf("Plan: %v", err)
+ }
+ if aware.Status != navmesh.Complete {
+ t.Fatalf("path %s (%s)", aware.Status, aware.Reason)
+ }
+ cases := []struct {
+ name string
+ p Vec3
+ radius float64
+ on bool
+ }{
+ {"on the corridor", Vec3{12, 30, 0}, 25, true},
+ {"a pack's radius inside it", Vec3{14, 30, 0}, 10, true},
+ {"a floor above", Vec3{12, 30, 20}, 25, false},
+ {"in the void", Vec3{200, 200, 0}, 25, false},
+ }
+ for _, c := range cases {
+ near := NearestPolyline(aware.Points, c.p)
+ if got := OnCorridor(near, c.radius); got != c.on {
+ t.Errorf("%s: on corridor %v, want %v (near %+v)", c.name, got, c.on, near)
+ }
+ }
+ // A break within BreakNearYd of the route is touched; one in the void is not.
+ for _, c := range []struct {
+ p Vec3
+ touched bool
+ }{
+ {Vec3{30, 12, 0}, true},
+ {Vec3{200, 200, 0}, false},
+ } {
+ l := dungeon.Leg{Via: []dungeon.Waypoint{{Point: dungeon.Point{X: c.p[0], Y: c.p[1], Z: c.p[2]}, Direct: true}}}
+ got := breaksOf(&l, aware.Points, Vec3{}, false)
+ if len(got) != 1 || got[0].Touched != c.touched {
+ t.Errorf("break at %v: %+v, want touched %v", c.p, got, c.touched)
+ }
+ }
+}
+
+// The ratio of the stored route to the live route on the L corridor: the plan's own points
+// cut the corner the walk goes round, so the stored line is shorter.
+func TestRatioCutsTheCorner(t *testing.T) {
+ p, _, _ := oracleFixture(t, walkPlan)
+ aware, err := p.Plan(0, Vec3{12, 12, 0}, Vec3{48, 48, 0})
+ if err != nil {
+ t.Fatalf("Plan: %v", err)
+ }
+ stored := []Vec3{{12, 12, 0}, {48, 48, 0}}
+ ratio := PolylineLength(stored) / PolylineLength(aware.Points)
+ if ratio < 0.7 || ratio > 0.8 {
+ t.Errorf("ratio %v, want ~0.75 (50.9 yd straight over 68 yd round the corner)", ratio)
+ }
+}
+
+// A partial hop whose path's end stands near a break's foot touches it (the second break
+// rule).
+func TestBreakTouchedByAPartialEndGap(t *testing.T) {
+ // A path that ends short of the goal, 7 yd from the break's foot: within BreakGapYd.
+ route := []Vec3{{0, 0, 0}, {10, 0, 0}}
+ l := dungeon.Leg{Via: []dungeon.Waypoint{
+ {Point: dungeon.Point{X: 17, Y: 0, Z: 0}, CheckSkip: "the mesh does not link it"},
+ {Point: dungeon.Point{X: 40, Y: 0, Z: 0}, CheckSkip: "far"},
+ }}
+ got := breaksOf(&l, route, Vec3{10, 0, 0}, true)
+ if len(got) != 2 {
+ t.Fatalf("breaks: %+v", got)
+ }
+ if !got[0].Touched || got[0].Kind != "check_skip" {
+ t.Errorf("near break: %+v, want touched", got[0])
+ }
+ if got[1].Touched {
+ t.Errorf("far break: %+v, want untouched", got[1])
+ }
+}
+
+// The report marshals: Stage 1 reads these files as fixtures.
+func TestReportMarshals(t *testing.T) {
+ p, dir, plan := oracleFixture(t, walkPlan)
+ rep, err := Oracle(p, dir, plan)
+ if err != nil {
+ t.Fatalf("Oracle: %v", err)
+ }
+ b, err := json.MarshalIndent(rep, "", " ")
+ if err != nil {
+ t.Fatalf("Marshal: %v", err)
+ }
+ if len(b) < 100 {
+ t.Fatalf("report json is %d bytes", len(b))
+ }
+}
diff --git a/internal/routeoracle/oracle_test.go b/internal/routeoracle/oracle_test.go
new file mode 100644
index 00000000..2ed450e5
--- /dev/null
+++ b/internal/routeoracle/oracle_test.go
@@ -0,0 +1,58 @@
+package routeoracle
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/pjerra/turtle-agent/internal/dungeon"
+ "github.com/pjerra/turtle-agent/internal/navmesh"
+)
+
+func TestCheckName(t *testing.T) {
+ cases := []struct {
+ name string
+ valid bool
+ }{
+ {"wailing_caverns", true},
+ {"a", true},
+ {"0", true},
+ {"a-b_c9", true},
+ {"../escape", false},
+ {"../../escape", false},
+ {"Wailing", false},
+ {"", false},
+ {"with space", false},
+ {strings.Repeat("a", 41), false},
+ {"/etc/passwd", false},
+ }
+ for _, c := range cases {
+ err := CheckName(c.name)
+ if (err == nil) != c.valid {
+ t.Errorf("%q: err %v, want valid %v", c.name, err, c.valid)
+ }
+ }
+}
+
+// The oracle refuses a plan whose own name is not a plan name, before anything is planned
+// and before any caller could use the name as a path. (A name that passes the check but
+// differs from the requested plan is refused by the command, which owns the pairing.)
+func TestOracleRejectsABadName(t *testing.T) {
+ p := navmesh.NewPlanner("")
+ defer p.Close()
+ for _, name := range []string{"../escape", "../../etc/hosts", "Bad Name"} {
+ if _, err := Oracle(p, "", &dungeon.Plan{Name: name}); err == nil || !strings.Contains(err.Error(), "not a plan name") {
+ t.Errorf("%q: err %v, want a plan-name refusal", name, err)
+ }
+ }
+}
+
+// A valid name the report is built from is the one the report carries.
+func TestOracleCarriesThePlanName(t *testing.T) {
+ p := navmesh.NewPlanner("")
+ defer p.Close()
+ _, err := Oracle(p, "", &dungeon.Plan{Name: "wailing_caverns"})
+ // No mmaps folder: the oracle fails on the first plan, but the refusal is not the name's.
+ if err == nil || strings.Contains(err.Error(), "not a plan name") {
+ t.Errorf("err %v", err)
+ }
+}
diff --git a/internal/session/actions.go b/internal/session/actions.go
index 87822002..622c36eb 100644
--- a/internal/session/actions.go
+++ b/internal/session/actions.go
@@ -251,6 +251,7 @@ func (s *Session) Target(ctx context.Context, g packet.GUID, name string) (objec
}
s.mu.Lock()
s.target = g
+ s.selSentLocked(g)
s.mu.Unlock()
return nil, nil
})
@@ -726,6 +727,8 @@ func (s *Session) onNewWorld(body []byte) error {
s.mu.Lock()
s.mapID, s.pos = nw.Map, [4]float32{nw.X, nw.Y, nw.Z, nw.O}
s.target = 0
+ s.selUnknownLocked()
+ s.selAttacking = false
self := s.self.GUID
s.mu.Unlock()
s.stopWalk(errNewMap, false) // after the position, so a walk_path woken by it reads the new one
@@ -825,6 +828,9 @@ func (s *Session) onAttackStart(body []byte) error {
if err != nil {
return err
}
+ if a.Attacker == s.selfGUID() {
+ s.setAttacking(true) // Unit::Attack writes the victim into the target field in the next update
+ }
s.emit(events.Event{Kind: events.KindAttackStart, SourceGUID: a.Attacker, TargetGUID: a.Victim})
return nil
}
@@ -834,6 +840,9 @@ func (s *Session) onAttackStop(body []byte) error {
if err != nil {
return err
}
+ if a.Attacker == s.selfGUID() {
+ s.setAttacking(false) // AttackStop writes 0 into the target field in the next update: SelectionCombatSettle covers it
+ }
s.emit(events.Event{Kind: events.KindAttackStop, SourceGUID: a.Attacker, TargetGUID: a.Victim})
return nil
}
diff --git a/internal/session/chat.go b/internal/session/chat.go
index 92cf7fd3..e94a9e1c 100644
--- a/internal/session/chat.go
+++ b/internal/session/chat.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
+ "time"
"github.com/pjerra/turtle-agent/internal/events"
"github.com/pjerra/turtle-agent/internal/game"
@@ -139,39 +140,35 @@ func gmLine(text string) (string, error) {
return text, nil
}
-// GMOn selects the unit g and sends the GM command text as ONE operation of the session's owner goroutine: no other
-// request (a target call, the addon host's click) can change the selection between the two packets. Use it for a
-// command that acts on the selected unit (".aura ", ".revive"), which the core runs on whatever is selected
-// when it arrives (Chat.cpp:2329-2340).
+// GMOnSelectWait is how long GMOn gives the server to show the selection before the line is given up on.
+var GMOnSelectWait = 3 * time.Second
+
+// gmOnAttempts is how many times GMOn selects and confirms again when the selection changed after the confirmation.
+const gmOnAttempts = 3
+
+// GMOn selects the unit g, waits until the server is known to have it in force (SelectConfirmed), and sends the GM command text with
+// GMOnConfirmed: only while that confirmed selection still stands, with no selection packet of its own. Use it for a command that acts on
+// the selected unit (".aura ", ".revive"), which the core runs on whatever is selected when it arrives (Chat.cpp:2329-2340); the core
+// handles a selection on the map thread and a chat line on the world thread, so a line sent right after its selection can run on the one
+// before. If another request changed the selection after the confirmation, the unit is selected and confirmed again (gmOnAttempts times).
+// An error, with nothing sent, when the server does not show the selection in time.
func (s *Session) GMOn(ctx context.Context, g packet.GUID, text string) error {
if g == 0 {
return errors.New("gm: no unit to select")
}
- text, err := gmLine(text)
- if err != nil {
+ if _, err := gmLine(text); err != nil {
return err
}
- say, err := game.ChatTypeByName("say")
- if err != nil {
- return err
- }
- body, err := game.BuildMessageChat(say, "", text)
- if err != nil {
- return err
- }
- _, err = s.do(ctx, func() (any, error) {
- if err := s.inWorld(); err != nil {
- return nil, err
+ var err error
+ for try := 0; try < gmOnAttempts; try++ {
+ if err = s.SelectConfirmed(ctx, g, GMOnSelectWait); err != nil {
+ return err
}
- if err := s.conn.WritePacket(opcode.CMSGSetSelection, game.BuildSetSelection(g)); err != nil {
- return nil, err
+ if err = s.GMOnConfirmed(ctx, g, text); !errors.Is(err, ErrSelectionChanged) {
+ return err
}
- s.mu.Lock()
- s.target = g
- s.mu.Unlock()
- return nil, s.conn.WritePacket(opcode.CMSGMessagechat, body)
- })
- return err
+ }
+ return fmt.Errorf("%w: it changed %d times between the server's confirmation and the line, which was not sent", err, gmOnAttempts)
}
// ErrSelectionChanged is GMOnConfirmed's refusal: the selection is not the unit the caller confirmed (another request changed it, or the
@@ -179,15 +176,12 @@ func (s *Session) GMOn(ctx context.Context, g packet.GUID, text string) error {
var ErrSelectionChanged = errors.New("gm: the selection is not the confirmed unit any more")
// GMOnConfirmed sends the GM command text, and nothing else, as ONE operation of the session's owner goroutine, if the unit g is the selection the
-// caller confirmed: the session believes g is selected (no request selected another unit since) AND the server shows g as the player's selection
-// (ServerSelection). It sends no CMSG_SET_SELECTION of its own: the core handles a selection on the map thread and a GM chat line on the world
+// caller confirmed: the session believes g is selected (no request selected another unit since) AND the server is known to have g in force
+// (SelectionConfirmed: an echo that cannot answer an older select, selection.go). It sends no CMSG_SET_SELECTION of its own: the core handles a selection on the map thread and a GM chat line on the world
// thread, so a selection sent right before the line would not be in force when the line runs, and the line would act on whatever the server showed
// before. When the selection is no longer the confirmed one it returns ErrSelectionChanged with nothing sent, and the caller selects and confirms
-// again (see the commander's selectThenGM).
+// again (see GMOn and the commander's selectThenGM). g may be 0: a line that must run with nothing selected (".respawn").
func (s *Session) GMOnConfirmed(ctx context.Context, g packet.GUID, text string) error {
- if g == 0 {
- return errors.New("gm: no unit to select")
- }
text, err := gmLine(text)
if err != nil {
return err
@@ -205,9 +199,9 @@ func (s *Session) GMOnConfirmed(ctx context.Context, g packet.GUID, text string)
return nil, err
}
s.mu.Lock()
- believed := s.target
+ ok := s.target == g && s.selConfirmedLocked(g)
s.mu.Unlock()
- if sel, ok := s.ServerSelection(); believed != g || !ok || sel != g {
+ if !ok {
return nil, ErrSelectionChanged
}
return nil, s.conn.WritePacket(opcode.CMSGMessagechat, body)
@@ -229,6 +223,7 @@ func (s *Session) ClearTarget(ctx context.Context) error {
}
s.mu.Lock()
s.target = 0
+ s.selSentLocked(0)
s.mu.Unlock()
return nil, nil
})
diff --git a/internal/session/chat_test.go b/internal/session/chat_test.go
index 963d0d74..8f24ea19 100644
--- a/internal/session/chat_test.go
+++ b/internal/session/chat_test.go
@@ -5,15 +5,16 @@ import (
"context"
"errors"
"fmt"
+ "strings"
"sync"
"testing"
"time"
"github.com/pjerra/turtle-agent/internal/events"
"github.com/pjerra/turtle-agent/internal/game"
+ "github.com/pjerra/turtle-agent/internal/objects"
"github.com/pjerra/turtle-agent/internal/opcode"
"github.com/pjerra/turtle-agent/internal/packet"
- "github.com/pjerra/turtle-agent/internal/worldconn"
)
// chatBody transcribes ChatHandler::BuildChatPacket's default and say branches (Chat.cpp:2251-2314).
@@ -210,27 +211,50 @@ func TestUnansweredNameDoesNotStallAgain(t *testing.T) {
h.logout()
}
-// GMOn selects the unit and sends the command in one step of the session: however many target calls run at the same
-// time, the packet before each command is the selection of the unit it was meant for.
-func TestGMOnSelectsAndSendsAtomically(t *testing.T) {
+// targetEcho is the values update of the player's own object with the target field the core writes after a selection.
+func targetEcho(g packet.GUID) []byte {
+ return valuesUpdate(me.GUID, map[uint16]uint32{objects.UnitTarget: uint32(g), objects.UnitTarget + 1: uint32(g >> 32)})
+}
+
+// GMOn selects the unit, waits for the server to show that selection, and sends the command only while it stands: however many target
+// calls run at the same time, every command runs on the unit it was meant for. The server here applies each selection as it reads it and
+// echoes it when the field changes (as the core does); a command runs on the selection in force when it is read.
+func TestGMOnRunsOnlyOnTheConfirmedSelection(t *testing.T) {
ctx := context.Background()
h := start(t, Options{})
h.enterWorld()
- const mine, other, n = packet.GUID(0x31), packet.GUID(0x99), 40
+ h.send(opcode.SMSGUpdateObject, unitCreate(me.GUID, objects.TypePlayer, me.X, me.Y, me.Z, map[uint16]uint32{objects.UnitHealth: 800, objects.UnitMaxHealth: 800}))
+ const mine, other, n = packet.GUID(0x31), packet.GUID(0x99), 20
- var got []worldconn.Packet
- collected := make(chan struct{})
+ var mu sync.Mutex
+ var ranOn []packet.GUID
+ var texts []string
+ stopSrv := make(chan struct{})
+ srvDone := make(chan struct{})
go func() {
- defer close(collected)
- chats := 0
- for chats < n {
- p, err := h.srv.Next(3 * time.Second)
- if err != nil {
+ defer close(srvDone)
+ inForce := packet.GUID(0)
+ for {
+ select {
+ case <-stopSrv:
return
+ default:
+ }
+ p, err := h.srv.Next(50 * time.Millisecond)
+ if err != nil {
+ continue
}
- got = append(got, p)
- if p.Op == opcode.CMSGMessagechat {
- chats++
+ switch p.Op {
+ case opcode.CMSGSetSelection:
+ if g := packet.NewReader(p.Body).GUID(); g != inForce {
+ inForce = g
+ h.send(opcode.SMSGUpdateObject, targetEcho(g))
+ }
+ case opcode.CMSGMessagechat:
+ mu.Lock()
+ ranOn = append(ranOn, inForce)
+ texts = append(texts, string(p.Body))
+ mu.Unlock()
}
}
}()
@@ -243,38 +267,31 @@ func TestGMOnSelectsAndSendsAtomically(t *testing.T) {
select {
case <-stop:
return
- default:
+ case <-time.After(15 * time.Millisecond):
_, _ = h.s.Target(ctx, other, "")
}
}
}()
+ sent := 0
for i := 0; i < n; i++ {
- if err := h.s.GMOn(ctx, mine, fmt.Sprintf("aura %d", 1000+i)); err != nil {
- t.Fatal(err)
+ if err := h.s.GMOn(ctx, mine, fmt.Sprintf("aura %d", 1000+i)); err == nil {
+ sent++
}
}
close(stop)
wg.Wait()
- <-collected
-
- cmds := 0
- for i, p := range got {
- if p.Op != opcode.CMSGMessagechat {
- continue
- }
- cmds++
- if i == 0 || got[i-1].Op != opcode.CMSGSetSelection || packet.NewReader(got[i-1].Body).GUID() != mine {
- t.Fatalf("command %d (%q) was not right after the selection of its unit", cmds, p.Body)
+ time.Sleep(200 * time.Millisecond)
+ close(stopSrv)
+ <-srvDone
+ mu.Lock()
+ defer mu.Unlock()
+ if sent == 0 || len(ranOn) != sent {
+ t.Fatalf("%d sent, %d ran", sent, len(ranOn))
+ }
+ for i, g := range ranOn {
+ if g != mine {
+ t.Errorf("command %d (%q) ran on %v", i, texts[i], g)
}
- if !bytes.Contains(p.Body, []byte(fmt.Sprintf(".aura %d", 1000+cmds-1))) {
- t.Fatalf("command %d: % X", cmds, p.Body)
- }
- }
- if cmds != n {
- t.Fatalf("%d commands seen", cmds)
- }
- if h.s.Status().Target == 0 {
- t.Fatal("no target")
}
for _, bad := range []string{"", "..gm"} {
if err := h.s.GMOn(ctx, mine, bad); err == nil {
@@ -286,6 +303,29 @@ func TestGMOnSelectsAndSendsAtomically(t *testing.T) {
}
}
+// GMOn sends nothing when the server never shows the selection.
+func TestGMOnSendsNothingWithoutTheEcho(t *testing.T) {
+ old := GMOnSelectWait
+ GMOnSelectWait = 200 * time.Millisecond
+ defer func() { GMOnSelectWait = old }()
+ ctx := context.Background()
+ h := start(t, Options{})
+ h.enterWorld()
+ h.send(opcode.SMSGUpdateObject, unitCreate(me.GUID, objects.TypePlayer, me.X, me.Y, me.Z, map[uint16]uint32{objects.UnitHealth: 800, objects.UnitMaxHealth: 800}))
+ if err := h.s.GMOn(ctx, 0x31, ".aura 1"); err == nil || !strings.Contains(err.Error(), "did not show") {
+ t.Fatalf("%v", err)
+ }
+ for {
+ p, err := h.srv.Next(200 * time.Millisecond)
+ if err != nil {
+ break
+ }
+ if p.Op == opcode.CMSGMessagechat {
+ t.Fatalf("a line went out: %q", p.Body)
+ }
+ }
+}
+
// ClearTarget sends the selection of nothing, as a client does when the target is dropped, and the session no longer believes in one.
func TestClearTargetSendsAZeroSelection(t *testing.T) {
ctx := context.Background()
diff --git a/internal/session/create_test.go b/internal/session/create_test.go
index 279b7e59..3fcb2aa1 100644
--- a/internal/session/create_test.go
+++ b/internal/session/create_test.go
@@ -114,7 +114,7 @@ func TestCreateMissingCharacterThenLogin(t *testing.T) {
h.send(opcode.SMSGLoginVerifyWorld, packet.NewWriter().U32(0).F32(0).F32(0).F32(0).F32(0).Bytes())
select {
case <-h.s.Ready():
- case <-time.After(2 * time.Second):
+ case <-time.After(expectWait):
t.Fatal("not ready after the create")
}
}
@@ -144,7 +144,7 @@ func TestCreateFailureEndsSession(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("code %d: err = %v", c.code, err)
}
- case <-time.After(2 * time.Second):
+ case <-time.After(expectWait):
t.Fatalf("code %d: session did not end", c.code)
}
if n := len(h.srv.Skipped()); n != 0 {
@@ -169,7 +169,7 @@ func TestCreateEmptyReplyEndsSession(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "empty reply") {
t.Fatalf("err = %v", err)
}
- case <-time.After(2 * time.Second):
+ case <-time.After(expectWait):
t.Fatal("session did not end")
}
}
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).
diff --git a/internal/session/party_test.go b/internal/session/party_test.go
index cf3ed8c0..1e959fef 100644
--- a/internal/session/party_test.go
+++ b/internal/session/party_test.go
@@ -23,7 +23,7 @@ func fullStats(g packet.GUID, hp, maxHP uint16) []byte {
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
- deadline := time.Now().Add(2 * time.Second)
+ deadline := time.Now().Add(expectWait)
for !cond() {
if time.Now().After(deadline) {
t.Fatal("condition not reached")
diff --git a/internal/session/replay_test.go b/internal/session/replay_test.go
index af7aef90..09e5ca4e 100644
--- a/internal/session/replay_test.go
+++ b/internal/session/replay_test.go
@@ -2,6 +2,7 @@ package session
import (
"context"
+ "os"
"path/filepath"
"reflect"
"testing"
@@ -24,13 +25,34 @@ func allEvents(l *events.Log) []events.Event {
// liveScenario drives a session through combat with names (one of them never answered),
// vendor and quest requests whose events are inferred from the agent's own packets, a
// knockback with its landing, and a logout, all recorded to a capture.
+//
+// The capture stamps each record with the wall clock, which a WSL2 host steps back about two
+// seconds every half minute (clock.go). A step inside the scenario puts later records before
+// earlier ones and the replay then lands the knockback in the past (17 decoded events, 18 live).
+// The scenario therefore runs again when the wall clock moved against the monotonic one while
+// it ran; the step is the host's, not the replay's, and the next run is a fresh one.
func liveScenario(t *testing.T, path string) []events.Event {
+ t.Helper()
+ const attempts = 5
+ for i := 1; ; i++ {
+ _ = os.Remove(path) // capture.Create refuses a file that exists
+ began := time.Now()
+ live := liveScenarioOnce(t, path)
+ skew := time.Since(began) - time.Now().Round(0).Sub(began.Round(0)) // monotonic elapsed minus wall elapsed
+ if i == attempts || skew > -50*time.Millisecond && skew < 50*time.Millisecond {
+ return live
+ }
+ t.Logf("the wall clock stepped back %v during the scenario; running it again (%d of %d)", skew, i, attempts)
+ }
+}
+
+func liveScenarioOnce(t *testing.T, path string) []events.Event {
t.Helper()
w, err := capture.Create(path, []byte("SECRETPW"))
if err != nil {
t.Fatal(err)
}
- h := startTapped(t, Options{NamesWait: 100 * time.Millisecond}, testKey, testKey, nil, w)
+ h := startClocked(t, Options{NamesWait: 100 * time.Millisecond}, testKey, testKey, nil, w, false) // the real clock: the events take the live stamps
h.enterWorld()
ctx := context.Background()
stranger := packet.GUID(0x40)
diff --git a/internal/session/selection.go b/internal/session/selection.go
new file mode 100644
index 00000000..49623fb2
--- /dev/null
+++ b/internal/session/selection.go
@@ -0,0 +1,371 @@
+package session
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/pjerra/turtle-agent/internal/game"
+ "github.com/pjerra/turtle-agent/internal/objects"
+ "github.com/pjerra/turtle-agent/internal/opcode"
+ "github.com/pjerra/turtle-agent/internal/packet"
+)
+
+// The selection log ties the server's echo of the selection (UNIT_FIELD_TARGET of the player's own object) to the CMSG_SET_SELECTION packets
+// the session sent, so that a stale echo cannot confirm a selection that is still on its way (issue #296: an echo showing the lead from an
+// earlier select was taken for the new one while a stranger's select, sent in between, was what the server had in force when the GM line ran).
+//
+// What a GM command acts on is the player's selection GUID (Player::GetSelectionGuid, Chat.cpp:2334), which only CMSG_SET_SELECTION sets. The
+// field the session can see is UNIT_FIELD_TARGET: the core writes it on that packet too, but ALSO without touching the selection GUID when
+// the player starts or stops attacking (Unit::Attack Unit.cpp:5189 writes the victim, Unit::AttackStop :5257 writes 0, also through
+// RemoveAllAttackers on the victim's death) and around a stun (SpellAuras.cpp:3359 writes 0, :3412 the victim again). So the field is evidence
+// of the selection only where it answers a select the session sent:
+// - the core applies the selections of one session in the order they were sent, and an update that carries the field shows it after some
+// prefix of them (several applied in one map update give one echo, and a select that does not change the field gives none);
+// - an echo of a value the session never sent (a combat or stun rewrite, a create block) leaves the selection in force UNKNOWN; the
+// sends after the lower bound stay pending.
+// - those rewrites happen only while the player attacks, is in combat or is stunned (the core's other SetTargetGuid calls are for
+// creatures, Unit.cpp:9815/9859). A combat rewrite to exactly the unit of a pending select would read as that select's echo, so while
+// the player is HOT (attacking since an SMSG_ATTACKSTART of its own until the SMSG_ATTACKSTOP, UNIT_FLAG_IN_COMBAT or UNIT_FLAG_STUNNED
+// on its object) and for SelectionCombatSettle after, no echo confirms anything and no GM line on a selection goes out. When the player
+// cools down, every send so far is fenced off (treated as dropped: an echo of one of those units confirms nothing until an echo of a
+// later send was seen), and SelectConfirmed selects again, through a neutral selection where its unit is among the fenced ones. A run
+// that stays hot past the wait gets an error and no line. Left: a rewrite whose sign of combat comes in a LATER update than the rewrite
+// itself (none of the core paths above does this for a player: the melee start is announced before the update, the flags of a cast's
+// combat and of a stun go in the same update).
+//
+// selLog[0] is the selection known to be in force at a lower bound of the server's progress through the sent selections, known only when an
+// echo answered one of the session's own sends (selBaseKnown); selLog[1:] are the selections sent since, any of which may still be pending.
+// An echo of v moves the lower bound to the FIRST pending entry that is v (the oldest send it can answer), and the selection g is confirmed
+// only when every entry from there on is g: whichever of them the server has reached, g is in force, and stays in force until another
+// selection is sent. Selecting g again while g is confirmed is confirmed at once, with no echo needed (the core sends none). Where an echo
+// of a new select could not be told from an older one, or would not come at all because the field already shows the unit, SelectConfirmed
+// first makes and confirms a neutral selection.
+
+// selCap bounds the log: the selections sent with no echo between them. Past it the oldest are dropped, the selection in force becomes
+// unknown, and an echo of a dropped unit confirms nothing until an echo of a later send was seen.
+const selCap = 32
+
+// SelectionCombatSettle is how long after the player stopped attacking, left combat or a stun the selection echoes are still not trusted:
+// the field rewrite of an AttackStop comes in the update after the SMSG_ATTACKSTOP.
+var SelectionCombatSettle = time.Second
+
+// selHotLocked says whether the field may be rewritten by combat or a stun now. The caller holds s.mu.
+func (s *Session) selHotLocked() bool {
+ return s.selHot || time.Now().Before(s.selCoolAt)
+}
+
+// selSetHotLocked records whether the player attacks, is in combat or is stunned. Cooling down fences off every send so far. The caller
+// holds s.mu.
+func (s *Session) selSetHotLocked(hot bool) {
+ if hot == s.selHot {
+ return
+ }
+ s.selHot = hot
+ if hot {
+ return
+ }
+ s.selCoolAt = time.Now().Add(SelectionCombatSettle)
+ if s.selDropped == nil {
+ s.selDropped = map[packet.GUID]bool{}
+ }
+ for i, x := range s.selLog {
+ if i > 0 || s.selBaseKnown {
+ s.selDropped[x] = true
+ }
+ }
+ s.selLog, s.selBaseKnown = []packet.GUID{0}, false
+}
+
+// updateHot recomputes the hot state from the player's own object and its melee attack. Run before an update's target field is read.
+func (s *Session) updateHot(self packet.GUID) {
+ flags := uint32(0)
+ if u, ok := s.store.Get(self, 0, 0, 0); ok && self != 0 {
+ flags = u.Flags
+ }
+ s.mu.Lock()
+ s.selSetHotLocked(s.selAttacking || flags&(objects.UnitFlagInCombat|objects.UnitFlagStunned) != 0)
+ s.mu.Unlock()
+}
+
+// setAttacking records an SMSG_ATTACKSTART (true) or SMSG_ATTACKSTOP (false) of the player itself.
+func (s *Session) setAttacking(on bool) {
+ s.mu.Lock()
+ s.selAttacking = on
+ self := s.self.GUID
+ s.mu.Unlock()
+ s.updateHot(self)
+}
+
+// selSentLocked records a CMSG_SET_SELECTION of g written to the server. The caller holds s.mu.
+func (s *Session) selSentLocked(g packet.GUID) {
+ if len(s.selLog) == 0 {
+ s.selLog, s.selBaseKnown = []packet.GUID{0}, false
+ }
+ if n := len(s.selLog); s.selLog[n-1] == g && (n > 1 || s.selBaseKnown) {
+ return // the same unit again: an echo of it answers the older send as well, so the log need not grow
+ }
+ s.selLog = append(s.selLog, g)
+ if len(s.selLog) > selCap {
+ drop := len(s.selLog) - (selCap - 1) // entries leaving the log; the new base is unknown
+ if s.selDropped == nil {
+ s.selDropped = map[packet.GUID]bool{}
+ }
+ for i, x := range s.selLog[:drop] {
+ if i > 0 || s.selBaseKnown {
+ s.selDropped[x] = true
+ }
+ }
+ kept := append([]packet.GUID{0}, s.selLog[drop:]...)
+ s.selLog, s.selBaseKnown = kept, false
+ }
+}
+
+// selObservedLocked records that the server shows v in the player's target field. The caller holds s.mu.
+func (s *Session) selObservedLocked(v packet.GUID) {
+ s.selShown, s.selShownOK = v, true
+ if s.selHotLocked() {
+ s.selBaseKnown = false // combat or a stun may have written it
+ return
+ }
+ if len(s.selLog) == 0 {
+ s.selLog, s.selBaseKnown = []packet.GUID{0}, false // nothing sent: the field says nothing about the selection
+ return
+ }
+ start := 1
+ if s.selBaseKnown {
+ start = 0
+ }
+ for i := start; i < len(s.selLog); i++ {
+ if s.selLog[i] != v {
+ continue
+ }
+ if s.selDropped[v] {
+ // It may answer a dropped send, with everything after it pending.
+ s.selBaseKnown = false
+ return
+ }
+ s.selLog, s.selBaseKnown = s.selLog[i:], true
+ if i > 0 {
+ s.selDropped = nil // a kept send was applied, so every dropped one was too
+ }
+ return
+ }
+ // A value no pending send holds: the core rewrote the field itself (combat, a stun, a create block). The selection in force is unknown.
+ s.selBaseKnown = false
+}
+
+// selUnknownLocked forgets the selection in force (a far teleport replaces the player's object); the selections sent stay pending. The
+// caller holds s.mu.
+func (s *Session) selUnknownLocked() {
+ if len(s.selLog) == 0 {
+ s.selLog = []packet.GUID{0}
+ }
+ s.selBaseKnown, s.selShownOK = false, false
+}
+
+func (s *Session) selConfirmedLocked(g packet.GUID) bool {
+ if !s.selBaseKnown || len(s.selLog) == 0 || s.selHotLocked() {
+ return false
+ }
+ for _, x := range s.selLog {
+ if x != g {
+ return false
+ }
+ }
+ return true
+}
+
+// selNeedsNeutralLocked says whether a new select of g could stay unconfirmed: its echo could not be told from an older one (g was sent,
+// then something else, and the server has not shown how far it got), or no echo would come (the selection in force is unknown and the field
+// already shows g, or the field is not known).
+func (s *Session) selNeedsNeutralLocked(g packet.GUID) bool {
+ if s.selConfirmedLocked(g) {
+ return false
+ }
+ if !s.selBaseKnown && (!s.selShownOK || s.selShown == g) || s.selDropped[g] {
+ return true
+ }
+ seen := false
+ for i, x := range s.selLog {
+ if i == 0 && !s.selBaseKnown {
+ continue
+ }
+ if x == g {
+ seen = true
+ } else if seen {
+ return true
+ }
+ }
+ return false
+}
+
+// selNeutralLocked is a selection whose echo answers its own send alone: one no entry of the log (or a dropped one) holds and the field does
+// not show, so the field must change. No selection (0) or else the player itself; ok is false when neither will do.
+func (s *Session) selNeutralLocked() (packet.GUID, bool) {
+ for _, c := range []packet.GUID{0, s.self.GUID} {
+ if s.selDropped[c] || (s.selShownOK && s.selShown == c) {
+ continue
+ }
+ in := false
+ for i, x := range s.selLog {
+ if x == c && (i > 0 || s.selBaseKnown) { // an unknown base is a placeholder, not a unit
+ in = true
+ break
+ }
+ }
+ if !in {
+ return c, true
+ }
+ }
+ return 0, false
+}
+
+// SelectionConfirmed says whether the server is known to have the unit g (0: none) in force as the player's selection: an echo that can only
+// answer one of the session's own selects of g, sent no earlier than its last select of anything else, showed it. A stale echo, or the
+// field rewritten by combat or a stun, does not confirm it.
+func (s *Session) SelectionConfirmed(g packet.GUID) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.selConfirmedLocked(g)
+}
+
+// SelectionAmbiguous says whether SelectConfirmed would make a neutral selection before selecting g.
+func (s *Session) SelectionAmbiguous(g packet.GUID) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.selNeedsNeutralLocked(g)
+}
+
+// sendSelection writes CMSG_SET_SELECTION of g (0: none) from the owner goroutine and records it.
+func (s *Session) sendSelection(ctx context.Context, g packet.GUID) error {
+ _, err := s.do(ctx, func() (any, error) {
+ if err := s.inWorld(); err != nil {
+ return nil, err
+ }
+ if err := s.conn.WritePacket(opcode.CMSGSetSelection, game.BuildSetSelection(g)); err != nil {
+ return nil, err
+ }
+ s.mu.Lock()
+ s.target = g
+ s.selSentLocked(g)
+ s.mu.Unlock()
+ return nil, nil
+ })
+ return err
+}
+
+// errSelHot ends a wait for a selection when the player turned hot (selHotLocked): the selection is made again once it cooled down.
+var errSelHot = errors.New("the player is attacking, in combat or stunned")
+
+// SelectConfirmed selects the unit g (0: none) and waits, up to wait in all, until the server is known to have it in force
+// (SelectionConfirmed). When a new select of g could stay unconfirmed (SelectionAmbiguous), a neutral selection (none, or the player itself)
+// is made and confirmed first. While the player is attacking, in combat or stunned nothing is selected and nothing is confirmed (the core
+// rewrites the field then); the selection is made once the player cooled down. An error when the confirmation does not come in time; a
+// line that acts on the selection must not be sent then.
+func (s *Session) SelectConfirmed(ctx context.Context, g packet.GUID, wait time.Duration) error {
+ timer := time.NewTimer(wait)
+ defer timer.Stop()
+ for {
+ if err := s.awaitCool(ctx, wait, timer.C); err != nil {
+ return err
+ }
+ if err := s.selectOnce(ctx, g, wait, timer.C); !errors.Is(err, errSelHot) {
+ return err
+ }
+ }
+}
+
+func (s *Session) selectOnce(ctx context.Context, g packet.GUID, wait time.Duration, expired <-chan time.Time) error {
+ s.mu.Lock()
+ need := s.selNeedsNeutralLocked(g)
+ n, ok := s.selNeutralLocked()
+ s.mu.Unlock()
+ if need && ok {
+ if err := s.sendSelection(ctx, n); err != nil {
+ return err
+ }
+ if err := s.awaitSelection(ctx, n, wait, expired); err != nil {
+ return fmt.Errorf("%w (the neutral selection made before %v)", err, g)
+ }
+ }
+ if err := s.sendSelection(ctx, g); err != nil {
+ return err
+ }
+ return s.awaitSelection(ctx, g, wait, expired)
+}
+
+// awaitCool waits until the player is not hot.
+func (s *Session) awaitCool(ctx context.Context, wait time.Duration, expired <-chan time.Time) error {
+ tick := time.NewTicker(10 * time.Millisecond)
+ defer tick.Stop()
+ for {
+ s.mu.Lock()
+ hot := s.selHotLocked()
+ s.mu.Unlock()
+ if !hot {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-expired:
+ return fmt.Errorf("the agent was attacking, in combat or stunned for all of %s (the core rewrites its target field then, so no selection can be confirmed): not sending a line that acts on the selection", wait)
+ case <-tick.C:
+ }
+ }
+}
+
+func (s *Session) awaitSelection(ctx context.Context, g packet.GUID, wait time.Duration, expired <-chan time.Time) error {
+ tick := time.NewTicker(10 * time.Millisecond)
+ defer tick.Stop()
+ for {
+ s.mu.Lock()
+ ok, hot := s.selConfirmedLocked(g), s.selHotLocked()
+ s.mu.Unlock()
+ if ok {
+ return nil
+ }
+ if hot {
+ return errSelHot
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-expired:
+ sel, _ := s.ServerSelection()
+ return fmt.Errorf("the server did not show %v as the agent's selection within %s (it shows %v): not sending a line that acts on the selection", g, wait, sel)
+ case <-tick.C:
+ }
+ }
+}
+
+// observeSelection records the target field the server shows when one of blocks replaced the player's object or carried the field. Run
+// after the store applied them; the store's value is the last of them.
+func (s *Session) observeSelection(blocks []objects.Block, self packet.GUID) {
+ touched := false
+ for _, b := range blocks {
+ if b.GUID != self || self == 0 {
+ continue
+ }
+ switch b.Type {
+ case objects.BlockCreate, objects.BlockCreate2:
+ touched = true
+ case objects.BlockValues:
+ _, lo := b.Values[objects.UnitTarget]
+ _, hi := b.Values[objects.UnitTarget+1]
+ touched = touched || lo || hi
+ }
+ }
+ if !touched {
+ return
+ }
+ u, ok := s.store.Get(self, 0, 0, 0)
+ if !ok {
+ return
+ }
+ s.mu.Lock()
+ s.selObservedLocked(u.Target)
+ s.mu.Unlock()
+}
diff --git a/internal/session/selection_test.go b/internal/session/selection_test.go
new file mode 100644
index 00000000..4454b98f
--- /dev/null
+++ b/internal/session/selection_test.go
@@ -0,0 +1,249 @@
+package session
+
+import (
+ "testing"
+ "time"
+
+ "github.com/pjerra/turtle-agent/internal/packet"
+)
+
+// selRig drives the selection log of a bare session: sends and echoes, no connection.
+type selRig struct{ s *Session }
+
+func newSelRig() selRig {
+ s := &Session{}
+ s.self.GUID = 0x2A
+ return selRig{s}
+}
+
+func (r selRig) sent(gs ...packet.GUID) {
+ r.s.mu.Lock()
+ defer r.s.mu.Unlock()
+ for _, g := range gs {
+ r.s.selSentLocked(g)
+ }
+}
+
+func (r selRig) echo(v packet.GUID) {
+ r.s.mu.Lock()
+ defer r.s.mu.Unlock()
+ r.s.selObservedLocked(v)
+}
+
+func (r selRig) neutral() (packet.GUID, bool) {
+ r.s.mu.Lock()
+ defer r.s.mu.Unlock()
+ return r.s.selNeutralLocked()
+}
+
+const (
+ selLead = packet.GUID(0x23A)
+ selStranger = packet.GUID(0x4D2)
+ selMob = packet.GUID(0xF130000000000077)
+)
+
+// own makes g the confirmed selection by an echo of the session's own send.
+func (r selRig) own(t *testing.T, g packet.GUID) {
+ t.Helper()
+ r.echo(0x999) // the create block: says nothing about the selection
+ r.sent(g)
+ r.echo(g)
+ if !r.s.SelectionConfirmed(g) {
+ t.Fatalf("%v not confirmed by the echo of its own send", g)
+ }
+}
+
+func TestSelectionLogFreshEchoConfirms(t *testing.T) {
+ r := newSelRig()
+ r.echo(0) // the create block
+ if r.s.SelectionConfirmed(0) {
+ t.Fatal("a create block confirmed a selection")
+ }
+ r.sent(selLead)
+ if r.s.SelectionConfirmed(selLead) {
+ t.Fatal("confirmed before an echo")
+ }
+ r.echo(selLead)
+ if !r.s.SelectionConfirmed(selLead) {
+ t.Fatal("the fresh echo did not confirm")
+ }
+}
+
+func TestSelectionLogStaleEchoDoesNotConfirm(t *testing.T) {
+ r := newSelRig()
+ r.own(t, selLead)
+ r.sent(selStranger, selLead)
+ r.echo(selLead) // may answer the first select, with the stranger's pending
+ if r.s.SelectionConfirmed(selLead) {
+ t.Fatal("a stale echo confirmed")
+ }
+ if !r.s.SelectionAmbiguous(selLead) {
+ t.Fatal("not ambiguous")
+ }
+ r.echo(selStranger)
+ if r.s.SelectionConfirmed(selLead) || r.s.SelectionConfirmed(selStranger) {
+ t.Fatal("the stranger's echo, with the lead sent after it, confirmed")
+ }
+ r.echo(selLead)
+ if !r.s.SelectionConfirmed(selLead) {
+ t.Fatal("the lead's own echo did not confirm")
+ }
+}
+
+// The core sends no update for a select that does not change the field: reselecting the confirmed unit is confirmed with no echo.
+func TestSelectionLogUnchangedSelectionNeedsNoEcho(t *testing.T) {
+ r := newSelRig()
+ r.own(t, selLead)
+ for i := 0; i < 5; i++ {
+ r.sent(selLead)
+ }
+ if !r.s.SelectionConfirmed(selLead) || r.s.SelectionAmbiguous(selLead) {
+ t.Fatal("the unchanged selection is not confirmed at once")
+ }
+ if n := len(r.s.selLog); n != 1 {
+ t.Fatalf("log grew to %d", n)
+ }
+}
+
+// Unit::AttackStop writes 0 into the target field (the victim died) without touching the selection the GM commands read: that is not a
+// cleared selection, and clearing it now gets no echo (the field does not change), so it must go through a neutral selection.
+func TestSelectionLogAttackStopZeroIsNotAClearedSelection(t *testing.T) {
+ r := newSelRig()
+ r.own(t, selMob)
+ r.echo(0) // AttackStop
+ if r.s.SelectionConfirmed(0) || r.s.SelectionConfirmed(selMob) {
+ t.Fatal("the combat rewrite confirmed a selection")
+ }
+ if !r.s.SelectionAmbiguous(0) {
+ t.Fatal("clearing would wait for an echo that never comes")
+ }
+ if n, ok := r.neutral(); !ok || n != r.s.self.GUID {
+ t.Fatalf("neutral %v %v: 0 is what the field shows, the player itself must be used", n, ok)
+ }
+ r.sent(0) // without the neutral: the clear is recorded, not deduped into a confirmation
+ if r.s.SelectionConfirmed(0) {
+ t.Fatal("the clear was confirmed with no echo")
+ }
+ // The neutral round trip.
+ r.sent(r.s.self.GUID)
+ r.echo(r.s.self.GUID)
+ if !r.s.SelectionConfirmed(r.s.self.GUID) {
+ t.Fatal("neutral not confirmed")
+ }
+ r.sent(0)
+ r.echo(0)
+ if !r.s.SelectionConfirmed(0) {
+ t.Fatal("the clear after the neutral was not confirmed")
+ }
+}
+
+// Unit::Attack writes the victim into the field: with nothing selected, an attack on a player shows that player without selecting it.
+func TestSelectionLogAttackOnAPlayerIsNotItsSelection(t *testing.T) {
+ r := newSelRig()
+ r.own(t, 0)
+ r.echo(selLead) // Attack
+ if r.s.SelectionConfirmed(selLead) {
+ t.Fatal("the attack confirmed the victim as the selection")
+ }
+ if !r.s.SelectionAmbiguous(selLead) {
+ t.Fatal("selecting the victim would wait for an echo that never comes")
+ }
+ r.sent(selLead)
+ if r.s.SelectionConfirmed(selLead) {
+ t.Fatal("confirmed with no echo")
+ }
+ // A stun end restoring the victim, after a stun cleared the field, is no echo of the select either... unless it is: that is the one
+ // case the log cannot tell apart (a rewrite to exactly the unit of a pending select).
+ r.echo(0) // stun
+ if r.s.SelectionConfirmed(0) {
+ t.Fatal("the stun's 0 confirmed a clear")
+ }
+}
+
+// The log is capped; an echo of a unit whose send was dropped confirms nothing, and the neutral avoids dropped units.
+func TestSelectionLogCap(t *testing.T) {
+ r := newSelRig()
+ r.own(t, selLead)
+ r.sent(0) // will be dropped
+ for i := 0; i < 2*selCap; i++ {
+ r.sent(selStranger+packet.GUID(i%7), selLead)
+ }
+ if n := len(r.s.selLog); n > selCap {
+ t.Fatalf("log at %d", n)
+ }
+ if r.s.selBaseKnown {
+ t.Fatal("the base is known after a drop")
+ }
+ r.echo(selLead) // may answer a dropped select
+ if r.s.SelectionConfirmed(selLead) {
+ t.Fatal("an echo of a dropped unit confirmed")
+ }
+ if n, ok := r.neutral(); !ok || n != r.s.self.GUID {
+ t.Fatalf("neutral %v %v: 0 was dropped", n, ok)
+ }
+ // The neutral round trip resets the log.
+ r.sent(r.s.self.GUID)
+ r.echo(r.s.self.GUID)
+ if !r.s.SelectionConfirmed(r.s.self.GUID) || len(r.s.selLog) != 1 || r.s.selDropped != nil {
+ t.Fatalf("log %v dropped %v", r.s.selLog, r.s.selDropped)
+ }
+}
+
+// A far teleport forgets the selection in force.
+func TestSelectionLogNewWorldForgets(t *testing.T) {
+ r := newSelRig()
+ r.own(t, selLead)
+ r.s.mu.Lock()
+ r.s.selUnknownLocked()
+ r.s.mu.Unlock()
+ if r.s.SelectionConfirmed(selLead) || !r.s.SelectionAmbiguous(selLead) {
+ t.Fatal("still confirmed after the teleport")
+ }
+}
+
+func (r selRig) hot(on bool) {
+ r.s.mu.Lock()
+ defer r.s.mu.Unlock()
+ r.s.selSetHotLocked(on)
+}
+
+// Codex's case: X confirmed, G pending, the player in combat, the core rewrites the field to G. That echo confirms nothing, nor does any
+// echo while the player is hot or settling; after it cooled down, G needs a neutral selection (its old send was fenced off) and a fresh
+// echo of a new send.
+func TestSelectionLogCombatRewriteToThePendingUnitConfirmsNothing(t *testing.T) {
+ old := SelectionCombatSettle
+ SelectionCombatSettle = 50 * time.Millisecond
+ defer func() { SelectionCombatSettle = old }()
+ r := newSelRig()
+ r.own(t, selLead) // X
+ r.sent(selStranger) // G, pending
+ r.hot(true)
+ r.echo(selStranger) // Unit::Attack on G
+ if r.s.SelectionConfirmed(selStranger) {
+ t.Fatal("a combat rewrite confirmed the pending unit")
+ }
+ r.hot(false)
+ r.echo(selStranger) // within the settle: still not trusted
+ if r.s.SelectionConfirmed(selStranger) {
+ t.Fatal("confirmed while settling")
+ }
+ time.Sleep(60 * time.Millisecond)
+ r.echo(selStranger) // after it: the old send of G was fenced off
+ if r.s.SelectionConfirmed(selStranger) {
+ t.Fatal("an echo of a fenced-off send confirmed")
+ }
+ if !r.s.SelectionAmbiguous(selStranger) {
+ t.Fatal("G was fenced off: a neutral selection is needed")
+ }
+ n, ok := r.neutral()
+ if !ok || n == selStranger || n == selLead {
+ t.Fatalf("neutral %v %v", n, ok)
+ }
+ r.sent(n)
+ r.echo(n)
+ r.sent(selStranger)
+ r.echo(selStranger)
+ if !r.s.SelectionConfirmed(selStranger) {
+ t.Fatal("the fresh echo after the neutral did not confirm")
+ }
+}
diff --git a/internal/session/session.go b/internal/session/session.go
index 171b411d..4e7b3313 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -203,23 +203,31 @@ type Session struct {
clockFn func() time.Time // nil: the wall clock; a replay sets the time of the record it handles
afterTake func() // tests only: runs in takeAll between the take and the close
- mu sync.Mutex // guards these fields for readers outside the loop
- state Status
- self Character
- mapID uint32
- pos [4]float32
- walkDist float64 // yards along its path the last walk got to when it stopped, arrived or was aborted; read by walk_path
- latency uint32
- unknown map[uint16]int
- parseErrs int
- lastErr string
- party PartyInfo
- offer *resurrectOffer // the open resurrection offer, death.go
- reclaimAt time.Time // the corpse may be reclaimed from here, death.go
- marks [game.RaidIconCount]packet.GUID // the group's raid icons
- target packet.GUID
- falling bool // s.fall != nil, for Status
- zUnknown bool
+ mu sync.Mutex // guards these fields for readers outside the loop
+ state Status
+ self Character
+ mapID uint32
+ pos [4]float32
+ walkDist float64 // yards along its path the last walk got to when it stopped, arrived or was aborted; read by walk_path
+ latency uint32
+ unknown map[uint16]int
+ parseErrs int
+ lastErr string
+ party PartyInfo
+ offer *resurrectOffer // the open resurrection offer, death.go
+ reclaimAt time.Time // the corpse may be reclaimed from here, death.go
+ marks [game.RaidIconCount]packet.GUID // the group's raid icons
+ target packet.GUID
+ selLog []packet.GUID // the selections sent and the one known in force, selection.go
+ selBaseKnown bool
+ selShown packet.GUID // the target field the server showed last; selShownOK: one was seen since the object was (re)created
+ selShownOK bool
+ selDropped map[packet.GUID]bool // units of sends dropped from the log at selCap or fenced off when the player cooled down
+ selHot bool // attacking, in combat or stunned, selection.go
+ selCoolAt time.Time // until then the echoes are still not trusted
+ selAttacking bool // between the player's own SMSG_ATTACKSTART and SMSG_ATTACKSTOP
+ falling bool // s.fall != nil, for Status
+ zUnknown bool
// owner-loop only
finished bool
diff --git a/internal/session/session_test.go b/internal/session/session_test.go
index 41c187ff..eb135eae 100644
--- a/internal/session/session_test.go
+++ b/internal/session/session_test.go
@@ -53,6 +53,30 @@ func startWrapped(t *testing.T, opts Options, clientKey, serverKey []byte, wrap
// startTapped is startWrapped with a tap on the client's connection (nil: none), the way
// --capture records one.
func startTapped(t *testing.T, opts Options, clientKey, serverKey []byte, wrap func(net.Conn) net.Conn, tap worldconn.Tap) *harness {
+ t.Helper()
+ return startClocked(t, opts, clientKey, serverKey, wrap, tap, true)
+}
+
+// expectWait is how long a test waits for a packet the session is about to send. The session
+// runs in real time (a walk leg takes a second, a flight a second or two), so a short
+// deadline turns a slow or loaded machine into a failure that says nothing about the code.
+// The wait ends when the packet arrives; the deadline only bounds a test that would fail anyway.
+const expectWait = 15 * time.Second
+
+// steadyClock is a session clock that only the monotonic clock drives. The host's wall clock
+// can be stepped (a WSL2 host steps it back about two seconds every half minute), and the
+// session slews its movement time towards the wall clock (clock.go), so a step during a walk
+// or a flight slows its client time for seconds and stretches the walk or skews a step's
+// time gap against its distance. The anticheat bounds this tolerates are covered in
+// clock_test.go; the tests of the walk, the flights and the packets' timing want the
+// session's own pace, which is what a steady wall clock gives.
+func steadyClock(start time.Time) func() time.Time {
+ return func() time.Time { return start.Add(time.Since(start)) }
+}
+
+// startClocked is startTapped with the session's clock steady (see steadyClock) or the real
+// one.
+func startClocked(t *testing.T, opts Options, clientKey, serverKey []byte, wrap func(net.Conn) net.Conn, tap worldconn.Tap, steady bool) *harness {
t.Helper()
cli, srvConn := net.Pipe()
t.Cleanup(func() { cli.Close(); srvConn.Close() })
@@ -78,6 +102,9 @@ func startTapped(t *testing.T, opts Options, clientKey, serverKey []byte, wrap f
}
h := &harness{t: t, srv: <-ch, run: make(chan error, 1)}
h.s = New(conn, opts)
+ if steady {
+ h.s.clockFn = steadyClock(h.s.start)
+ }
go func() { h.run <- h.s.Run(context.Background()) }()
// Ruling P1/P2: no Run goroutine outlives its test. Closing the pipes ends Run;
// Done closes before Run returns into the buffered h.run, drained or not.
@@ -115,7 +142,7 @@ func charEnumBody(chars ...Character) []byte {
func (h *harness) expect(op uint16) worldconn.Packet {
h.t.Helper()
- p, err := h.srv.Expect(op, 2*time.Second)
+ p, err := h.srv.Expect(op, expectWait)
if err != nil {
h.t.Fatal(err)
}
@@ -140,7 +167,7 @@ func (h *harness) enterWorld() {
h.expect(opcode.CMSGSetActiveMover)
select {
case <-h.s.Ready():
- case <-time.After(2 * time.Second):
+ case <-time.After(expectWait):
h.t.Fatal("not ready")
}
}
@@ -443,7 +470,7 @@ func TestPongTimeoutDisconnects(t *testing.T) {
if !errors.Is(err, ErrDisconnected) {
t.Fatalf("err = %v", err)
}
- case <-time.After(2 * time.Second):
+ case <-time.After(expectWait):
t.Fatal("no disconnect without pongs")
}
}
@@ -554,7 +581,7 @@ func TestLockedCharacterLoginIsRetried(t *testing.T) {
h.expect(opcode.CMSGSetActiveMover)
select {
case <-h.s.Ready():
- case <-time.After(2 * time.Second):
+ case <-time.After(expectWait):
t.Fatal("not ready after the retry")
}
if !strings.Contains(log.String(), "retrying") {
@@ -576,7 +603,7 @@ func TestLockedCharacterGivesUpAfterRetries(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "code 1") {
t.Fatalf("err = %v", err)
}
- case <-time.After(2 * time.Second):
+ case <-time.After(expectWait):
t.Fatal("session did not give up")
}
}
diff --git a/internal/session/world.go b/internal/session/world.go
index 1456ea38..dfa357e4 100644
--- a/internal/session/world.go
+++ b/internal/session/world.go
@@ -134,6 +134,8 @@ func (s *Session) applyBlocks(blocks []objects.Block) {
s.seedRunSpeed(blocks)
changes := s.store.Apply(blocks)
self := s.selfGUID()
+ s.updateHot(self) // first: a stun's flag and its field rewrite come in one update
+ s.observeSelection(blocks, self)
var selfAuras []uint32 // positive auras the player gained in this update: a mount's is among them
for _, c := range changes {
if c.GUID == self && c.Kind == objects.ChangeAura && c.New != 0 && c.Slot < objects.PositiveAuraSlots {
diff --git a/internal/tbm/commander.go b/internal/tbm/commander.go
index fd887402..da10f693 100644
--- a/internal/tbm/commander.go
+++ b/internal/tbm/commander.go
@@ -22,6 +22,9 @@ import (
var (
CommanderVerbs = []string{"bind", "unbind", "status", "view"}
CmdVerbs = []string{"attack", "pull", "pullback", "cc", "focus", "move", "stay", "follow", "rest", "revive", "summon", "enter", "loot", "open", "use", "ritual", "lootmethod", "state", "gossip"}
+ // TraceVerbs are the `.bot trace` verbs a run sends for the server's run recorder (run_meta.server_trace): start, stop, and the
+ // status its stop retries read to see whether the trace running is still the run's own. Not a commander command: a narrow allow.
+ TraceVerbs = []string{"start", "stop", "status"}
)
// CCMarks are the raid marks `.bot cmd cc` takes.
@@ -31,6 +34,10 @@ var CCMarks = []string{"skull", "cross", "square", "moon", "triangle", "diamond"
// put a second command on the line.
var argRe = regexp.MustCompile(`^[A-Za-z0-9_,:+\-][A-Za-z0-9_.,:+@\-]{0,63}$`)
+// traceDriverRe is the `driver=` word of a trace start (`driver=::`), the one argument outside the commander's
+// grammar: the server stores up to 96 characters there (dungeon.RunMeta.Check), so the word allows 1-96.
+var traceDriverRe = regexp.MustCompile(`^driver=[A-Za-z0-9_.+\-:]{1,96}$`)
+
// BadOrderWords says why a text cannot be the tail of one command line: every whitespace-separated word has to be an argument the
// commander accepts (argRe). It returns "" for a text that can, else the first word that cannot, with the reason.
func BadOrderWords(text string) string {
@@ -100,8 +107,12 @@ func CheckCommand(command string) error {
verbs = CmdVerbs
case "run":
verbs = RunVerbs
+ case "trace":
+ // The server's run recorder, sent by a remote run with run_meta.server_trace (dungeon.startServerTrace and stopServerTrace).
+ // Only these verbs: every other `.bot` line stays refused.
+ verbs = TraceVerbs
default:
- return fmt.Errorf("%q is not a commander command: use commander, cmd or run", f[0])
+ return fmt.Errorf("%q is not a commander command: use commander, cmd, run or trace", f[0])
}
found := false
for _, v := range verbs {
@@ -110,6 +121,25 @@ func CheckCommand(command string) error {
if !found {
return fmt.Errorf("%s %s: unknown verb (%s)", group, verb, strings.Join(verbs, ", "))
}
+ if group == "trace" {
+ // The recorder's own grammar, no wider than the lines a server trace sends: `start [driver=...]`, `stop`, `status`.
+ if verb != "start" {
+ if len(f) > 2 {
+ return fmt.Errorf("trace %s: takes no argument", verb)
+ }
+ return nil
+ }
+ if len(f) < 3 || len(f) > 4 {
+ return errors.New("trace start: use `trace start [driver=::]`")
+ }
+ if !argRe.MatchString(f[2]) {
+ return fmt.Errorf("trace start: %q is not a run id (letters, digits and _ . , : + - only)", f[2])
+ }
+ if len(f) == 4 && !traceDriverRe.MatchString(f[3]) {
+ return fmt.Errorf("trace start: %q is not a driver word (driver=::
|